Compare commits
48 Commits
v0.58.0-gi
...
feat/timez
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b48eb81c6 | ||
|
|
504811546e | ||
|
|
e012f10395 | ||
|
|
676e32ea09 | ||
|
|
28045772b8 | ||
|
|
b1120c7d16 | ||
|
|
55c9205aad | ||
|
|
5b4f423f9f | ||
|
|
cc376ce6a8 | ||
|
|
20e00c597a | ||
|
|
ff7da5c05b | ||
|
|
14ccadaeb5 | ||
|
|
984f3829dd | ||
|
|
31a9ead2fc | ||
|
|
65ce8eaf14 | ||
|
|
daec491c79 | ||
|
|
49e29567f4 | ||
|
|
8edd5fe7d6 | ||
|
|
178a3153dd | ||
|
|
e7f1b27a5b | ||
|
|
dbf0f236be | ||
|
|
d93f72f18d | ||
|
|
a59e7b9dfb | ||
|
|
91bbeaf175 | ||
|
|
22e61e1605 | ||
|
|
656d1c2b1c | ||
|
|
493ae4fd07 | ||
|
|
cd1ec561b1 | ||
|
|
0acf39a532 | ||
|
|
35f4eaa23b | ||
|
|
77c5f17dce | ||
|
|
c1478c4e54 | ||
|
|
371224a64a | ||
|
|
504bc0d541 | ||
|
|
2faa0c6d4f | ||
|
|
969ac5028e | ||
|
|
323da3494b | ||
|
|
01fda51959 | ||
|
|
85ac21f253 | ||
|
|
fd9e9f0fb3 | ||
|
|
d5523fc092 | ||
|
|
2ec641b99e | ||
|
|
d1503f1418 | ||
|
|
e974e9d47f | ||
|
|
577a169508 | ||
|
|
939e2a3570 | ||
|
|
b64326070c | ||
|
|
63872983c6 |
@@ -66,28 +66,6 @@ processors:
|
||||
# Using OTEL_RESOURCE_ATTRIBUTES envvar, env detector adds custom labels.
|
||||
detectors: [env, system] # include ec2 for AWS, gcp for GCP and azure for Azure.
|
||||
timeout: 2s
|
||||
signozspanmetrics/cumulative:
|
||||
metrics_exporter: clickhousemetricswrite
|
||||
latency_histogram_buckets: [100us, 1ms, 2ms, 6ms, 10ms, 50ms, 100ms, 250ms, 500ms, 1000ms, 1400ms, 2000ms, 5s, 10s, 20s, 40s, 60s ]
|
||||
dimensions_cache_size: 100000
|
||||
dimensions:
|
||||
- name: service.namespace
|
||||
default: default
|
||||
- name: deployment.environment
|
||||
default: default
|
||||
# This is added to ensure the uniqueness of the timeseries
|
||||
# Otherwise, identical timeseries produced by multiple replicas of
|
||||
# collectors result in incorrect APM metrics
|
||||
- name: signoz.collector.id
|
||||
- name: service.version
|
||||
- name: browser.platform
|
||||
- name: browser.mobile
|
||||
- name: k8s.cluster.name
|
||||
- name: k8s.node.name
|
||||
- name: k8s.namespace.name
|
||||
- name: host.name
|
||||
- name: host.type
|
||||
- name: container.name
|
||||
# memory_limiter:
|
||||
# # 80% of maximum memory up to 2G
|
||||
# limit_mib: 1500
|
||||
@@ -138,6 +116,8 @@ exporters:
|
||||
enabled: true
|
||||
clickhousemetricswrite/prometheus:
|
||||
endpoint: tcp://clickhouse:9000/signoz_metrics
|
||||
clickhousemetricswritev2:
|
||||
dsn: tcp://clickhouse:9000/signoz_metrics
|
||||
# logging: {}
|
||||
clickhouselogsexporter:
|
||||
dsn: tcp://clickhouse:9000/signoz_logs
|
||||
@@ -161,20 +141,20 @@ service:
|
||||
pipelines:
|
||||
traces:
|
||||
receivers: [jaeger, otlp]
|
||||
processors: [signozspanmetrics/cumulative, signozspanmetrics/delta, batch]
|
||||
processors: [signozspanmetrics/delta, batch]
|
||||
exporters: [clickhousetraces]
|
||||
metrics:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [clickhousemetricswrite]
|
||||
metrics/generic:
|
||||
exporters: [clickhousemetricswrite, clickhousemetricswritev2]
|
||||
metrics/hostmetrics:
|
||||
receivers: [hostmetrics]
|
||||
processors: [resourcedetection, batch]
|
||||
exporters: [clickhousemetricswrite]
|
||||
exporters: [clickhousemetricswrite, clickhousemetricswritev2]
|
||||
metrics/prometheus:
|
||||
receivers: [prometheus]
|
||||
processors: [batch]
|
||||
exporters: [clickhousemetricswrite/prometheus]
|
||||
exporters: [clickhousemetricswrite/prometheus, clickhousemetricswritev2]
|
||||
logs:
|
||||
receivers: [otlp, tcplog/docker]
|
||||
processors: [batch]
|
||||
|
||||
@@ -57,35 +57,11 @@ receivers:
|
||||
labels:
|
||||
job_name: otel-collector
|
||||
|
||||
|
||||
processors:
|
||||
batch:
|
||||
send_batch_size: 10000
|
||||
send_batch_max_size: 11000
|
||||
timeout: 10s
|
||||
signozspanmetrics/cumulative:
|
||||
metrics_exporter: clickhousemetricswrite
|
||||
metrics_flush_interval: 60s
|
||||
latency_histogram_buckets: [100us, 1ms, 2ms, 6ms, 10ms, 50ms, 100ms, 250ms, 500ms, 1000ms, 1400ms, 2000ms, 5s, 10s, 20s, 40s, 60s ]
|
||||
dimensions_cache_size: 100000
|
||||
dimensions:
|
||||
- name: service.namespace
|
||||
default: default
|
||||
- name: deployment.environment
|
||||
default: default
|
||||
# This is added to ensure the uniqueness of the timeseries
|
||||
# Otherwise, identical timeseries produced by multiple replicas of
|
||||
# collectors result in incorrect APM metrics
|
||||
- name: signoz.collector.id
|
||||
- name: service.version
|
||||
- name: browser.platform
|
||||
- name: browser.mobile
|
||||
- name: k8s.cluster.name
|
||||
- name: k8s.node.name
|
||||
- name: k8s.namespace.name
|
||||
- name: host.name
|
||||
- name: host.type
|
||||
- name: container.name
|
||||
# memory_limiter:
|
||||
# # 80% of maximum memory up to 2G
|
||||
# limit_mib: 1500
|
||||
@@ -149,6 +125,8 @@ exporters:
|
||||
enabled: true
|
||||
clickhousemetricswrite/prometheus:
|
||||
endpoint: tcp://clickhouse:9000/signoz_metrics
|
||||
clickhousemetricswritev2:
|
||||
dsn: tcp://clickhouse:9000/signoz_metrics
|
||||
clickhouselogsexporter:
|
||||
dsn: tcp://clickhouse:9000/signoz_logs
|
||||
timeout: 10s
|
||||
@@ -168,20 +146,20 @@ service:
|
||||
pipelines:
|
||||
traces:
|
||||
receivers: [jaeger, otlp]
|
||||
processors: [signozspanmetrics/cumulative, signozspanmetrics/delta, batch]
|
||||
processors: [signozspanmetrics/delta, batch]
|
||||
exporters: [clickhousetraces]
|
||||
metrics:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [clickhousemetricswrite]
|
||||
metrics/generic:
|
||||
exporters: [clickhousemetricswrite, clickhousemetricswritev2]
|
||||
metrics/hostmetrics:
|
||||
receivers: [hostmetrics]
|
||||
processors: [resourcedetection, batch]
|
||||
exporters: [clickhousemetricswrite]
|
||||
exporters: [clickhousemetricswrite, clickhousemetricswritev2]
|
||||
metrics/prometheus:
|
||||
receivers: [prometheus]
|
||||
processors: [batch]
|
||||
exporters: [clickhousemetricswrite/prometheus]
|
||||
exporters: [clickhousemetricswrite/prometheus, clickhousemetricswritev2]
|
||||
logs:
|
||||
receivers: [otlp, tcplog/docker]
|
||||
processors: [batch]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# use a minimal alpine image
|
||||
FROM alpine:3.18.6
|
||||
FROM alpine:3.20.3
|
||||
|
||||
# Add Maintainer Info
|
||||
LABEL maintainer="signoz"
|
||||
|
||||
@@ -40,6 +40,7 @@ type APIHandlerOptions struct {
|
||||
// Querier Influx Interval
|
||||
FluxInterval time.Duration
|
||||
UseLogsNewSchema bool
|
||||
UseLicensesV3 bool
|
||||
}
|
||||
|
||||
type APIHandler struct {
|
||||
@@ -65,6 +66,7 @@ func NewAPIHandler(opts APIHandlerOptions) (*APIHandler, error) {
|
||||
Cache: opts.Cache,
|
||||
FluxInterval: opts.FluxInterval,
|
||||
UseLogsNewSchema: opts.UseLogsNewSchema,
|
||||
UseLicensesV3: opts.UseLicensesV3,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -173,10 +175,25 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *baseapp.AuthMiddlew
|
||||
router.HandleFunc("/api/v1/dashboards/{uuid}/lock", am.EditAccess(ah.lockDashboard)).Methods(http.MethodPut)
|
||||
router.HandleFunc("/api/v1/dashboards/{uuid}/unlock", am.EditAccess(ah.unlockDashboard)).Methods(http.MethodPut)
|
||||
|
||||
// v2
|
||||
router.HandleFunc("/api/v2/licenses",
|
||||
am.ViewAccess(ah.listLicensesV2)).
|
||||
Methods(http.MethodGet)
|
||||
|
||||
// v3
|
||||
router.HandleFunc("/api/v3/licenses",
|
||||
am.ViewAccess(ah.listLicensesV3)).
|
||||
Methods(http.MethodGet)
|
||||
|
||||
router.HandleFunc("/api/v3/licenses",
|
||||
am.AdminAccess(ah.applyLicenseV3)).
|
||||
Methods(http.MethodPost)
|
||||
|
||||
router.HandleFunc("/api/v3/licenses",
|
||||
am.AdminAccess(ah.refreshLicensesV3)).
|
||||
Methods(http.MethodPut)
|
||||
|
||||
// v4
|
||||
router.HandleFunc("/api/v4/query_range", am.ViewAccess(ah.queryRangeV4)).Methods(http.MethodPost)
|
||||
|
||||
// Gateway
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"go.signoz.io/signoz/ee/query-service/constants"
|
||||
"go.signoz.io/signoz/ee/query-service/model"
|
||||
"go.signoz.io/signoz/pkg/http/render"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -59,6 +60,21 @@ type billingDetails struct {
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type ApplyLicenseRequest struct {
|
||||
LicenseKey string `json:"key"`
|
||||
}
|
||||
|
||||
type ListLicenseResponse map[string]interface{}
|
||||
|
||||
func convertLicenseV3ToListLicenseResponse(licensesV3 []*model.LicenseV3) []ListLicenseResponse {
|
||||
listLicenses := []ListLicenseResponse{}
|
||||
|
||||
for _, license := range licensesV3 {
|
||||
listLicenses = append(listLicenses, license.Data)
|
||||
}
|
||||
return listLicenses
|
||||
}
|
||||
|
||||
func (ah *APIHandler) listLicenses(w http.ResponseWriter, r *http.Request) {
|
||||
licenses, apiError := ah.LM().GetLicenses(context.Background())
|
||||
if apiError != nil {
|
||||
@@ -88,6 +104,51 @@ func (ah *APIHandler) applyLicense(w http.ResponseWriter, r *http.Request) {
|
||||
ah.Respond(w, license)
|
||||
}
|
||||
|
||||
func (ah *APIHandler) listLicensesV3(w http.ResponseWriter, r *http.Request) {
|
||||
licenses, apiError := ah.LM().GetLicensesV3(r.Context())
|
||||
|
||||
if apiError != nil {
|
||||
RespondError(w, apiError, nil)
|
||||
return
|
||||
}
|
||||
|
||||
ah.Respond(w, convertLicenseV3ToListLicenseResponse(licenses))
|
||||
}
|
||||
|
||||
// this function is called by zeus when inserting licenses in the query-service
|
||||
func (ah *APIHandler) applyLicenseV3(w http.ResponseWriter, r *http.Request) {
|
||||
var licenseKey ApplyLicenseRequest
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&licenseKey); err != nil {
|
||||
RespondError(w, model.BadRequest(err), nil)
|
||||
return
|
||||
}
|
||||
|
||||
if licenseKey.LicenseKey == "" {
|
||||
RespondError(w, model.BadRequest(fmt.Errorf("license key is required")), nil)
|
||||
return
|
||||
}
|
||||
|
||||
_, apiError := ah.LM().ActivateV3(r.Context(), licenseKey.LicenseKey)
|
||||
if apiError != nil {
|
||||
RespondError(w, apiError, nil)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusAccepted, nil)
|
||||
}
|
||||
|
||||
func (ah *APIHandler) refreshLicensesV3(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
apiError := ah.LM().RefreshLicense(r.Context())
|
||||
if apiError != nil {
|
||||
RespondError(w, apiError, nil)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (ah *APIHandler) checkout(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
type checkoutResponse struct {
|
||||
@@ -154,11 +215,45 @@ func (ah *APIHandler) getBilling(w http.ResponseWriter, r *http.Request) {
|
||||
ah.Respond(w, billingResponse.Data)
|
||||
}
|
||||
|
||||
func convertLicenseV3ToLicenseV2(licenses []*model.LicenseV3) []model.License {
|
||||
licensesV2 := []model.License{}
|
||||
for _, l := range licenses {
|
||||
licenseV2 := model.License{
|
||||
Key: l.Key,
|
||||
ActivationId: "",
|
||||
PlanDetails: "",
|
||||
FeatureSet: l.Features,
|
||||
ValidationMessage: "",
|
||||
IsCurrent: l.IsCurrent,
|
||||
LicensePlan: model.LicensePlan{
|
||||
PlanKey: l.PlanName,
|
||||
ValidFrom: l.ValidFrom,
|
||||
ValidUntil: l.ValidUntil,
|
||||
Status: l.Status},
|
||||
}
|
||||
licensesV2 = append(licensesV2, licenseV2)
|
||||
}
|
||||
return licensesV2
|
||||
}
|
||||
|
||||
func (ah *APIHandler) listLicensesV2(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
licenses, apiError := ah.LM().GetLicenses(context.Background())
|
||||
if apiError != nil {
|
||||
RespondError(w, apiError, nil)
|
||||
var licenses []model.License
|
||||
|
||||
if ah.UseLicensesV3 {
|
||||
licensesV3, err := ah.LM().GetLicensesV3(r.Context())
|
||||
if err != nil {
|
||||
RespondError(w, err, nil)
|
||||
return
|
||||
}
|
||||
licenses = convertLicenseV3ToLicenseV2(licensesV3)
|
||||
} else {
|
||||
_licenses, apiError := ah.LM().GetLicenses(r.Context())
|
||||
if apiError != nil {
|
||||
RespondError(w, apiError, nil)
|
||||
return
|
||||
}
|
||||
licenses = _licenses
|
||||
}
|
||||
|
||||
resp := model.Licenses{
|
||||
|
||||
@@ -77,6 +77,7 @@ type ServerOptions struct {
|
||||
Cluster string
|
||||
GatewayUrl string
|
||||
UseLogsNewSchema bool
|
||||
UseLicensesV3 bool
|
||||
}
|
||||
|
||||
// Server runs HTTP api service
|
||||
@@ -133,7 +134,7 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
|
||||
}
|
||||
|
||||
// initiate license manager
|
||||
lm, err := licensepkg.StartManager("sqlite", localDB)
|
||||
lm, err := licensepkg.StartManager("sqlite", localDB, serverOptions.UseLicensesV3)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -269,6 +270,7 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
|
||||
FluxInterval: fluxInterval,
|
||||
Gateway: gatewayProxy,
|
||||
UseLogsNewSchema: serverOptions.UseLogsNewSchema,
|
||||
UseLicensesV3: serverOptions.UseLicensesV3,
|
||||
}
|
||||
|
||||
apiHandler, err := api.NewAPIHandler(apiOpts)
|
||||
|
||||
@@ -13,6 +13,7 @@ var LicenseAPIKey = GetOrDefaultEnv("SIGNOZ_LICENSE_API_KEY", "")
|
||||
var SaasSegmentKey = GetOrDefaultEnv("SIGNOZ_SAAS_SEGMENT_KEY", "")
|
||||
var FetchFeatures = GetOrDefaultEnv("FETCH_FEATURES", "false")
|
||||
var ZeusFeaturesURL = GetOrDefaultEnv("ZEUS_FEATURES_URL", "ZeusFeaturesURL")
|
||||
var ZeusURL = GetOrDefaultEnv("ZEUS_URL", "ZeusURL")
|
||||
|
||||
func GetOrDefaultEnv(key string, fallback string) string {
|
||||
v := os.Getenv(key)
|
||||
|
||||
@@ -13,3 +13,8 @@ type ActivationResponse struct {
|
||||
ActivationId string `json:"ActivationId"`
|
||||
PlanDetails string `json:"PlanDetails"`
|
||||
}
|
||||
|
||||
type ValidateLicenseResponse struct {
|
||||
Status status `json:"status"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"go.uber.org/zap"
|
||||
@@ -23,12 +24,14 @@ const (
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
Prefix string
|
||||
Prefix string
|
||||
GatewayUrl string
|
||||
}
|
||||
|
||||
func New() *Client {
|
||||
return &Client{
|
||||
Prefix: constants.LicenseSignozIo,
|
||||
Prefix: constants.LicenseSignozIo,
|
||||
GatewayUrl: constants.ZeusURL,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +119,60 @@ func ValidateLicense(activationId string) (*ActivationResponse, *model.ApiError)
|
||||
|
||||
}
|
||||
|
||||
func ValidateLicenseV3(licenseKey string) (*model.LicenseV3, *model.ApiError) {
|
||||
|
||||
// Creating an HTTP client with a timeout for better control
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", C.GatewayUrl+"/v2/licenses/me", nil)
|
||||
if err != nil {
|
||||
return nil, model.BadRequest(errors.Wrap(err, fmt.Sprintf("failed to create request: %w", err)))
|
||||
}
|
||||
|
||||
// Setting the custom header
|
||||
req.Header.Set("X-Signoz-Cloud-Api-Key", licenseKey)
|
||||
|
||||
response, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, model.BadRequest(errors.Wrap(err, fmt.Sprintf("failed to make post request: %w", err)))
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, model.BadRequest(errors.Wrap(err, fmt.Sprintf("failed to read validation response from %v", C.GatewayUrl)))
|
||||
}
|
||||
|
||||
defer response.Body.Close()
|
||||
|
||||
switch response.StatusCode {
|
||||
case 200:
|
||||
a := ValidateLicenseResponse{}
|
||||
err = json.Unmarshal(body, &a)
|
||||
if err != nil {
|
||||
return nil, model.BadRequest(errors.Wrap(err, "failed to marshal license validation response"))
|
||||
}
|
||||
|
||||
license, err := model.NewLicenseV3(a.Data)
|
||||
if err != nil {
|
||||
return nil, model.BadRequest(errors.Wrap(err, "failed to generate new license v3"))
|
||||
}
|
||||
|
||||
return license, nil
|
||||
case 400:
|
||||
return nil, model.BadRequest(errors.Wrap(fmt.Errorf(string(body)),
|
||||
fmt.Sprintf("bad request error received from %v", C.GatewayUrl)))
|
||||
case 401:
|
||||
return nil, model.Unauthorized(errors.Wrap(fmt.Errorf(string(body)),
|
||||
fmt.Sprintf("unauthorized request error received from %v", C.GatewayUrl)))
|
||||
default:
|
||||
return nil, model.InternalError(errors.Wrap(fmt.Errorf(string(body)),
|
||||
fmt.Sprintf("internal request error received from %v", C.GatewayUrl)))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func NewPostRequestWithCtx(ctx context.Context, url string, contentType string, body io.Reader) (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, POST, url, body)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,10 +3,12 @@ package license
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/mattn/go-sqlite3"
|
||||
|
||||
"go.signoz.io/signoz/ee/query-service/license/sqlite"
|
||||
"go.signoz.io/signoz/ee/query-service/model"
|
||||
@@ -48,6 +50,34 @@ func (r *Repo) GetLicenses(ctx context.Context) ([]model.License, error) {
|
||||
return licenses, nil
|
||||
}
|
||||
|
||||
func (r *Repo) GetLicensesV3(ctx context.Context) ([]*model.LicenseV3, error) {
|
||||
licensesData := []model.LicenseDB{}
|
||||
licenseV3Data := []*model.LicenseV3{}
|
||||
|
||||
query := "SELECT id,key,data FROM licenses_v3"
|
||||
|
||||
err := r.db.Select(&licensesData, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get licenses from db: %v", err)
|
||||
}
|
||||
|
||||
for _, l := range licensesData {
|
||||
var licenseData map[string]interface{}
|
||||
err := json.Unmarshal([]byte(l.Data), &licenseData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal data into licenseData : %v", err)
|
||||
}
|
||||
|
||||
license, err := model.NewLicenseV3WithIDAndKey(l.ID, l.Key, licenseData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get licenses v3 schema : %v", err)
|
||||
}
|
||||
licenseV3Data = append(licenseV3Data, license)
|
||||
}
|
||||
|
||||
return licenseV3Data, nil
|
||||
}
|
||||
|
||||
// GetActiveLicense fetches the latest active license from DB.
|
||||
// If the license is not present, expect a nil license and a nil error in the output.
|
||||
func (r *Repo) GetActiveLicense(ctx context.Context) (*model.License, *basemodel.ApiError) {
|
||||
@@ -79,6 +109,45 @@ func (r *Repo) GetActiveLicense(ctx context.Context) (*model.License, *basemodel
|
||||
return active, nil
|
||||
}
|
||||
|
||||
func (r *Repo) GetActiveLicenseV3(ctx context.Context) (*model.LicenseV3, error) {
|
||||
var err error
|
||||
licenses := []model.LicenseDB{}
|
||||
|
||||
query := "SELECT id,key,data FROM licenses_v3"
|
||||
|
||||
err = r.db.Select(&licenses, query)
|
||||
if err != nil {
|
||||
return nil, basemodel.InternalError(fmt.Errorf("failed to get active licenses from db: %v", err))
|
||||
}
|
||||
|
||||
var active *model.LicenseV3
|
||||
for _, l := range licenses {
|
||||
var licenseData map[string]interface{}
|
||||
err := json.Unmarshal([]byte(l.Data), &licenseData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal data into licenseData : %v", err)
|
||||
}
|
||||
|
||||
license, err := model.NewLicenseV3WithIDAndKey(l.ID, l.Key, licenseData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get licenses v3 schema : %v", err)
|
||||
}
|
||||
|
||||
if active == nil &&
|
||||
(license.ValidFrom != 0) &&
|
||||
(license.ValidUntil == -1 || license.ValidUntil > time.Now().Unix()) {
|
||||
active = license
|
||||
}
|
||||
if active != nil &&
|
||||
license.ValidFrom > active.ValidFrom &&
|
||||
(license.ValidUntil == -1 || license.ValidUntil > time.Now().Unix()) {
|
||||
active = license
|
||||
}
|
||||
}
|
||||
|
||||
return active, nil
|
||||
}
|
||||
|
||||
// InsertLicense inserts a new license in db
|
||||
func (r *Repo) InsertLicense(ctx context.Context, l *model.License) error {
|
||||
|
||||
@@ -204,3 +273,59 @@ func (r *Repo) InitFeatures(req basemodel.FeatureSet) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InsertLicenseV3 inserts a new license v3 in db
|
||||
func (r *Repo) InsertLicenseV3(ctx context.Context, l *model.LicenseV3) *model.ApiError {
|
||||
|
||||
query := `INSERT INTO licenses_v3 (id, key, data) VALUES ($1, $2, $3)`
|
||||
|
||||
// licsense is the entity of zeus so putting the entire license here without defining schema
|
||||
licenseData, err := json.Marshal(l.Data)
|
||||
if err != nil {
|
||||
return &model.ApiError{Typ: basemodel.ErrorBadData, Err: err}
|
||||
}
|
||||
|
||||
_, err = r.db.ExecContext(ctx,
|
||||
query,
|
||||
l.ID,
|
||||
l.Key,
|
||||
string(licenseData),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
if sqliteErr, ok := err.(sqlite3.Error); ok {
|
||||
if sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique {
|
||||
zap.L().Error("error in inserting license data: ", zap.Error(sqliteErr))
|
||||
return &model.ApiError{Typ: model.ErrorConflict, Err: sqliteErr}
|
||||
}
|
||||
}
|
||||
zap.L().Error("error in inserting license data: ", zap.Error(err))
|
||||
return &model.ApiError{Typ: basemodel.ErrorExec, Err: err}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateLicenseV3 updates a new license v3 in db
|
||||
func (r *Repo) UpdateLicenseV3(ctx context.Context, l *model.LicenseV3) error {
|
||||
|
||||
// the key and id for the license can't change so only update the data here!
|
||||
query := `UPDATE licenses_v3 SET data=$1 WHERE id=$2;`
|
||||
|
||||
license, err := json.Marshal(l.Data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert license failed: license marshal error")
|
||||
}
|
||||
_, err = r.db.ExecContext(ctx,
|
||||
query,
|
||||
license,
|
||||
l.ID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
zap.L().Error("error in updating license data: ", zap.Error(err))
|
||||
return fmt.Errorf("failed to update license in db: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"sync"
|
||||
|
||||
@@ -45,11 +46,12 @@ type Manager struct {
|
||||
failedAttempts uint64
|
||||
|
||||
// keep track of active license and features
|
||||
activeLicense *model.License
|
||||
activeFeatures basemodel.FeatureSet
|
||||
activeLicense *model.License
|
||||
activeLicenseV3 *model.LicenseV3
|
||||
activeFeatures basemodel.FeatureSet
|
||||
}
|
||||
|
||||
func StartManager(dbType string, db *sqlx.DB, features ...basemodel.Feature) (*Manager, error) {
|
||||
func StartManager(dbType string, db *sqlx.DB, useLicensesV3 bool, features ...basemodel.Feature) (*Manager, error) {
|
||||
if LM != nil {
|
||||
return LM, nil
|
||||
}
|
||||
@@ -65,7 +67,31 @@ func StartManager(dbType string, db *sqlx.DB, features ...basemodel.Feature) (*M
|
||||
repo: &repo,
|
||||
}
|
||||
|
||||
if err := m.start(features...); err != nil {
|
||||
if useLicensesV3 {
|
||||
// get active license from the db
|
||||
active, err := m.repo.GetActiveLicense(context.Background())
|
||||
if err != nil {
|
||||
return m, err
|
||||
}
|
||||
|
||||
// if we have an active license then need to fetch the complete details
|
||||
if active != nil {
|
||||
// fetch the new license structure from control plane
|
||||
licenseV3, apiError := validate.ValidateLicenseV3(active.Key)
|
||||
if apiError != nil {
|
||||
return m, apiError
|
||||
}
|
||||
|
||||
// insert the licenseV3 in sqlite db
|
||||
apiError = m.repo.InsertLicenseV3(context.Background(), licenseV3)
|
||||
// if the license already exists move ahead.
|
||||
if apiError != nil && apiError.Typ != model.ErrorConflict {
|
||||
return m, apiError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.start(useLicensesV3, features...); err != nil {
|
||||
return m, err
|
||||
}
|
||||
LM = m
|
||||
@@ -73,8 +99,14 @@ func StartManager(dbType string, db *sqlx.DB, features ...basemodel.Feature) (*M
|
||||
}
|
||||
|
||||
// start loads active license in memory and initiates validator
|
||||
func (lm *Manager) start(features ...basemodel.Feature) error {
|
||||
err := lm.LoadActiveLicense(features...)
|
||||
func (lm *Manager) start(useLicensesV3 bool, features ...basemodel.Feature) error {
|
||||
|
||||
var err error
|
||||
if useLicensesV3 {
|
||||
err = lm.LoadActiveLicenseV3(features...)
|
||||
} else {
|
||||
err = lm.LoadActiveLicense(features...)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -108,6 +140,31 @@ func (lm *Manager) SetActive(l *model.License, features ...basemodel.Feature) {
|
||||
go lm.Validator(context.Background())
|
||||
}
|
||||
|
||||
}
|
||||
func (lm *Manager) SetActiveV3(l *model.LicenseV3, features ...basemodel.Feature) {
|
||||
lm.mutex.Lock()
|
||||
defer lm.mutex.Unlock()
|
||||
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
|
||||
lm.activeLicenseV3 = l
|
||||
lm.activeFeatures = append(l.Features, features...)
|
||||
// set default features
|
||||
setDefaultFeatures(lm)
|
||||
|
||||
err := lm.InitFeatures(lm.activeFeatures)
|
||||
if err != nil {
|
||||
zap.L().Panic("Couldn't activate features", zap.Error(err))
|
||||
}
|
||||
if !lm.validatorRunning {
|
||||
// we want to make sure only one validator runs,
|
||||
// we already have lock() so good to go
|
||||
lm.validatorRunning = true
|
||||
go lm.ValidatorV3(context.Background())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func setDefaultFeatures(lm *Manager) {
|
||||
@@ -137,6 +194,28 @@ func (lm *Manager) LoadActiveLicense(features ...basemodel.Feature) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (lm *Manager) LoadActiveLicenseV3(features ...basemodel.Feature) error {
|
||||
active, err := lm.repo.GetActiveLicenseV3(context.Background())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if active != nil {
|
||||
lm.SetActiveV3(active, features...)
|
||||
} else {
|
||||
zap.L().Info("No active license found, defaulting to basic plan")
|
||||
// if no active license is found, we default to basic(free) plan with all default features
|
||||
lm.activeFeatures = model.BasicPlan
|
||||
setDefaultFeatures(lm)
|
||||
err := lm.InitFeatures(lm.activeFeatures)
|
||||
if err != nil {
|
||||
zap.L().Error("Couldn't initialize features", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (lm *Manager) GetLicenses(ctx context.Context) (response []model.License, apiError *model.ApiError) {
|
||||
|
||||
licenses, err := lm.repo.GetLicenses(ctx)
|
||||
@@ -163,6 +242,23 @@ func (lm *Manager) GetLicenses(ctx context.Context) (response []model.License, a
|
||||
return
|
||||
}
|
||||
|
||||
func (lm *Manager) GetLicensesV3(ctx context.Context) (response []*model.LicenseV3, apiError *model.ApiError) {
|
||||
|
||||
licenses, err := lm.repo.GetLicensesV3(ctx)
|
||||
if err != nil {
|
||||
return nil, model.InternalError(err)
|
||||
}
|
||||
|
||||
for _, l := range licenses {
|
||||
if lm.activeLicenseV3 != nil && l.Key == lm.activeLicenseV3.Key {
|
||||
l.IsCurrent = true
|
||||
}
|
||||
response = append(response, l)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// Validator validates license after an epoch of time
|
||||
func (lm *Manager) Validator(ctx context.Context) {
|
||||
defer close(lm.terminated)
|
||||
@@ -187,6 +283,30 @@ func (lm *Manager) Validator(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Validator validates license after an epoch of time
|
||||
func (lm *Manager) ValidatorV3(ctx context.Context) {
|
||||
defer close(lm.terminated)
|
||||
tick := time.NewTicker(validationFrequency)
|
||||
defer tick.Stop()
|
||||
|
||||
lm.ValidateV3(ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-lm.done:
|
||||
return
|
||||
default:
|
||||
select {
|
||||
case <-lm.done:
|
||||
return
|
||||
case <-tick.C:
|
||||
lm.ValidateV3(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// Validate validates the current active license
|
||||
func (lm *Manager) Validate(ctx context.Context) (reterr error) {
|
||||
zap.L().Info("License validation started")
|
||||
@@ -254,6 +374,54 @@ func (lm *Manager) Validate(ctx context.Context) (reterr error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// todo[vikrantgupta25]: check the comparison here between old and new license!
|
||||
func (lm *Manager) RefreshLicense(ctx context.Context) *model.ApiError {
|
||||
|
||||
license, apiError := validate.ValidateLicenseV3(lm.activeLicenseV3.Key)
|
||||
if apiError != nil {
|
||||
zap.L().Error("failed to validate license", zap.Error(apiError.Err))
|
||||
return apiError
|
||||
}
|
||||
|
||||
err := lm.repo.UpdateLicenseV3(ctx, license)
|
||||
if err != nil {
|
||||
return model.BadRequest(errors.Wrap(err, "failed to update the new license"))
|
||||
}
|
||||
lm.SetActiveV3(license)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (lm *Manager) ValidateV3(ctx context.Context) (reterr error) {
|
||||
zap.L().Info("License validation started")
|
||||
if lm.activeLicenseV3 == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
defer func() {
|
||||
lm.mutex.Lock()
|
||||
|
||||
lm.lastValidated = time.Now().Unix()
|
||||
if reterr != nil {
|
||||
zap.L().Error("License validation completed with error", zap.Error(reterr))
|
||||
atomic.AddUint64(&lm.failedAttempts, 1)
|
||||
telemetry.GetInstance().SendEvent(telemetry.TELEMETRY_LICENSE_CHECK_FAILED,
|
||||
map[string]interface{}{"err": reterr.Error()}, "", true, false)
|
||||
} else {
|
||||
zap.L().Info("License validation completed with no errors")
|
||||
}
|
||||
|
||||
lm.mutex.Unlock()
|
||||
}()
|
||||
|
||||
err := lm.RefreshLicense(ctx)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Activate activates a license key with signoz server
|
||||
func (lm *Manager) Activate(ctx context.Context, key string) (licenseResponse *model.License, errResponse *model.ApiError) {
|
||||
defer func() {
|
||||
@@ -298,6 +466,35 @@ func (lm *Manager) Activate(ctx context.Context, key string) (licenseResponse *m
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func (lm *Manager) ActivateV3(ctx context.Context, licenseKey string) (licenseResponse *model.LicenseV3, errResponse *model.ApiError) {
|
||||
defer func() {
|
||||
if errResponse != nil {
|
||||
userEmail, err := auth.GetEmailFromJwt(ctx)
|
||||
if err == nil {
|
||||
telemetry.GetInstance().SendEvent(telemetry.TELEMETRY_LICENSE_ACT_FAILED,
|
||||
map[string]interface{}{"err": errResponse.Err.Error()}, userEmail, true, false)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
license, apiError := validate.ValidateLicenseV3(licenseKey)
|
||||
if apiError != nil {
|
||||
zap.L().Error("failed to get the license", zap.Error(apiError.Err))
|
||||
return nil, apiError
|
||||
}
|
||||
|
||||
// insert the new license to the sqlite db
|
||||
err := lm.repo.InsertLicenseV3(ctx, license)
|
||||
if err != nil {
|
||||
zap.L().Error("failed to activate license", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// license is valid, activate it
|
||||
lm.SetActiveV3(license)
|
||||
return license, nil
|
||||
}
|
||||
|
||||
// CheckFeature will be internally used by backend routines
|
||||
// for feature gating
|
||||
func (lm *Manager) CheckFeature(featureKey string) error {
|
||||
|
||||
@@ -48,5 +48,16 @@ func InitDB(db *sqlx.DB) error {
|
||||
return fmt.Errorf("error in creating feature_status table: %s", err.Error())
|
||||
}
|
||||
|
||||
table_schema = `CREATE TABLE IF NOT EXISTS licenses_v3 (
|
||||
id TEXT PRIMARY KEY,
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
data TEXT
|
||||
);`
|
||||
|
||||
_, err = db.Exec(table_schema)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error in creating licenses_v3 table: %s", err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ func main() {
|
||||
var cluster string
|
||||
|
||||
var useLogsNewSchema bool
|
||||
var useLicensesV3 bool
|
||||
var cacheConfigPath, fluxInterval string
|
||||
var enableQueryServiceLogOTLPExport bool
|
||||
var preferSpanMetrics bool
|
||||
@@ -104,6 +105,7 @@ func main() {
|
||||
var gatewayUrl string
|
||||
|
||||
flag.BoolVar(&useLogsNewSchema, "use-logs-new-schema", false, "use logs_v2 schema for logs")
|
||||
flag.BoolVar(&useLicensesV3, "use-licenses-v3", false, "use licenses_v3 schema for licenses")
|
||||
flag.StringVar(&promConfigPath, "config", "./config/prometheus.yml", "(prometheus config to read metrics)")
|
||||
flag.StringVar(&skipTopLvlOpsPath, "skip-top-level-ops", "", "(config file to skip top level operations)")
|
||||
flag.BoolVar(&disableRules, "rules.disable", false, "(disable rule evaluation)")
|
||||
@@ -143,6 +145,7 @@ func main() {
|
||||
Cluster: cluster,
|
||||
GatewayUrl: gatewayUrl,
|
||||
UseLogsNewSchema: useLogsNewSchema,
|
||||
UseLicensesV3: useLicensesV3,
|
||||
}
|
||||
|
||||
// Read the jwt secret key
|
||||
|
||||
@@ -46,6 +46,13 @@ func BadRequest(err error) *ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
func Unauthorized(err error) *ApiError {
|
||||
return &ApiError{
|
||||
Typ: basemodel.ErrorUnauthorized,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// BadRequestStr returns a ApiError object of bad request for string input
|
||||
func BadRequestStr(s string) *ApiError {
|
||||
return &ApiError{
|
||||
|
||||
@@ -3,6 +3,8 @@ package model
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
@@ -104,3 +106,144 @@ type SubscriptionServerResp struct {
|
||||
Status string `json:"status"`
|
||||
Data Licenses `json:"data"`
|
||||
}
|
||||
|
||||
type Plan struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type LicenseDB struct {
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
type LicenseV3 struct {
|
||||
ID string
|
||||
Key string
|
||||
Data map[string]interface{}
|
||||
PlanName string
|
||||
Features basemodel.FeatureSet
|
||||
Status string
|
||||
IsCurrent bool
|
||||
ValidFrom int64
|
||||
ValidUntil int64
|
||||
}
|
||||
|
||||
func extractKeyFromMapStringInterface[T any](data map[string]interface{}, key string) (T, error) {
|
||||
var zeroValue T
|
||||
if val, ok := data[key]; ok {
|
||||
if value, ok := val.(T); ok {
|
||||
return value, nil
|
||||
}
|
||||
return zeroValue, fmt.Errorf("%s key is not a valid %s", key, reflect.TypeOf(zeroValue))
|
||||
}
|
||||
return zeroValue, fmt.Errorf("%s key is missing", key)
|
||||
}
|
||||
|
||||
func NewLicenseV3(data map[string]interface{}) (*LicenseV3, error) {
|
||||
var features basemodel.FeatureSet
|
||||
|
||||
// extract id from data
|
||||
licenseID, err := extractKeyFromMapStringInterface[string](data, "id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
delete(data, "id")
|
||||
|
||||
// extract key from data
|
||||
licenseKey, err := extractKeyFromMapStringInterface[string](data, "key")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
delete(data, "key")
|
||||
|
||||
// extract status from data
|
||||
status, err := extractKeyFromMapStringInterface[string](data, "status")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
planMap, err := extractKeyFromMapStringInterface[map[string]any](data, "plan")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
planName, err := extractKeyFromMapStringInterface[string](planMap, "name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// if license status is inactive then default it to basic
|
||||
if status == LicenseStatusInactive {
|
||||
planName = PlanNameBasic
|
||||
}
|
||||
|
||||
featuresFromZeus := basemodel.FeatureSet{}
|
||||
if _features, ok := data["features"]; ok {
|
||||
featuresData, err := json.Marshal(_features)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to marshal features data")
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(featuresData, &featuresFromZeus); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to unmarshal features data")
|
||||
}
|
||||
}
|
||||
|
||||
switch planName {
|
||||
case PlanNameTeams:
|
||||
features = append(features, ProPlan...)
|
||||
case PlanNameEnterprise:
|
||||
features = append(features, EnterprisePlan...)
|
||||
case PlanNameBasic:
|
||||
features = append(features, BasicPlan...)
|
||||
default:
|
||||
features = append(features, BasicPlan...)
|
||||
}
|
||||
|
||||
if len(featuresFromZeus) > 0 {
|
||||
for _, feature := range featuresFromZeus {
|
||||
exists := false
|
||||
for i, existingFeature := range features {
|
||||
if existingFeature.Name == feature.Name {
|
||||
features[i] = feature // Replace existing feature
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !exists {
|
||||
features = append(features, feature) // Append if it doesn't exist
|
||||
}
|
||||
}
|
||||
}
|
||||
data["features"] = features
|
||||
|
||||
_validFrom, err := extractKeyFromMapStringInterface[float64](data, "valid_from")
|
||||
if err != nil {
|
||||
_validFrom = 0
|
||||
}
|
||||
validFrom := int64(_validFrom)
|
||||
|
||||
_validUntil, err := extractKeyFromMapStringInterface[float64](data, "valid_until")
|
||||
if err != nil {
|
||||
_validUntil = 0
|
||||
}
|
||||
validUntil := int64(_validUntil)
|
||||
|
||||
return &LicenseV3{
|
||||
ID: licenseID,
|
||||
Key: licenseKey,
|
||||
Data: data,
|
||||
PlanName: planName,
|
||||
Features: features,
|
||||
ValidFrom: validFrom,
|
||||
ValidUntil: validUntil,
|
||||
Status: status,
|
||||
}, nil
|
||||
|
||||
}
|
||||
|
||||
func NewLicenseV3WithIDAndKey(id string, key string, data map[string]interface{}) (*LicenseV3, error) {
|
||||
licenseDataWithIdAndKey := data
|
||||
licenseDataWithIdAndKey["id"] = id
|
||||
licenseDataWithIdAndKey["key"] = key
|
||||
return NewLicenseV3(licenseDataWithIdAndKey)
|
||||
}
|
||||
|
||||
170
ee/query-service/model/license_test.go
Normal file
170
ee/query-service/model/license_test.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.signoz.io/signoz/pkg/query-service/model"
|
||||
)
|
||||
|
||||
func TestNewLicenseV3(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
data []byte
|
||||
pass bool
|
||||
expected *LicenseV3
|
||||
error error
|
||||
}{
|
||||
{
|
||||
name: "Error for missing license id",
|
||||
data: []byte(`{}`),
|
||||
pass: false,
|
||||
error: errors.New("id key is missing"),
|
||||
},
|
||||
{
|
||||
name: "Error for license id not being a valid string",
|
||||
data: []byte(`{"id": 10}`),
|
||||
pass: false,
|
||||
error: errors.New("id key is not a valid string"),
|
||||
},
|
||||
{
|
||||
name: "Error for missing license key",
|
||||
data: []byte(`{"id":"does-not-matter"}`),
|
||||
pass: false,
|
||||
error: errors.New("key key is missing"),
|
||||
},
|
||||
{
|
||||
name: "Error for invalid string license key",
|
||||
data: []byte(`{"id":"does-not-matter","key":10}`),
|
||||
pass: false,
|
||||
error: errors.New("key key is not a valid string"),
|
||||
},
|
||||
{
|
||||
name: "Error for missing license status",
|
||||
data: []byte(`{"id":"does-not-matter", "key": "does-not-matter","category":"FREE"}`),
|
||||
pass: false,
|
||||
error: errors.New("status key is missing"),
|
||||
},
|
||||
{
|
||||
name: "Error for invalid string license status",
|
||||
data: []byte(`{"id":"does-not-matter","key": "does-not-matter", "category":"FREE", "status":10}`),
|
||||
pass: false,
|
||||
error: errors.New("status key is not a valid string"),
|
||||
},
|
||||
{
|
||||
name: "Error for missing license plan",
|
||||
data: []byte(`{"id":"does-not-matter","key":"does-not-matter-key","category":"FREE","status":"ACTIVE"}`),
|
||||
pass: false,
|
||||
error: errors.New("plan key is missing"),
|
||||
},
|
||||
{
|
||||
name: "Error for invalid json license plan",
|
||||
data: []byte(`{"id":"does-not-matter","key":"does-not-matter-key","category":"FREE","status":"ACTIVE","plan":10}`),
|
||||
pass: false,
|
||||
error: errors.New("plan key is not a valid map[string]interface {}"),
|
||||
},
|
||||
{
|
||||
name: "Error for invalid license plan",
|
||||
data: []byte(`{"id":"does-not-matter","key":"does-not-matter-key","category":"FREE","status":"ACTIVE","plan":{}}`),
|
||||
pass: false,
|
||||
error: errors.New("name key is missing"),
|
||||
},
|
||||
{
|
||||
name: "Parse the entire license properly",
|
||||
data: []byte(`{"id":"does-not-matter","key":"does-not-matter-key","category":"FREE","status":"ACTIVE","plan":{"name":"TEAMS"},"valid_from": 1730899309,"valid_until": -1}`),
|
||||
pass: true,
|
||||
expected: &LicenseV3{
|
||||
ID: "does-not-matter",
|
||||
Key: "does-not-matter-key",
|
||||
Data: map[string]interface{}{
|
||||
"plan": map[string]interface{}{
|
||||
"name": "TEAMS",
|
||||
},
|
||||
"category": "FREE",
|
||||
"status": "ACTIVE",
|
||||
"valid_from": float64(1730899309),
|
||||
"valid_until": float64(-1),
|
||||
},
|
||||
PlanName: PlanNameTeams,
|
||||
ValidFrom: 1730899309,
|
||||
ValidUntil: -1,
|
||||
Status: "ACTIVE",
|
||||
IsCurrent: false,
|
||||
Features: model.FeatureSet{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Fallback to basic plan if license status is inactive",
|
||||
data: []byte(`{"id":"does-not-matter","key":"does-not-matter-key","category":"FREE","status":"INACTIVE","plan":{"name":"TEAMS"},"valid_from": 1730899309,"valid_until": -1}`),
|
||||
pass: true,
|
||||
expected: &LicenseV3{
|
||||
ID: "does-not-matter",
|
||||
Key: "does-not-matter-key",
|
||||
Data: map[string]interface{}{
|
||||
"plan": map[string]interface{}{
|
||||
"name": "TEAMS",
|
||||
},
|
||||
"category": "FREE",
|
||||
"status": "INACTIVE",
|
||||
"valid_from": float64(1730899309),
|
||||
"valid_until": float64(-1),
|
||||
},
|
||||
PlanName: PlanNameBasic,
|
||||
ValidFrom: 1730899309,
|
||||
ValidUntil: -1,
|
||||
Status: "INACTIVE",
|
||||
IsCurrent: false,
|
||||
Features: model.FeatureSet{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fallback states for validFrom and validUntil",
|
||||
data: []byte(`{"id":"does-not-matter","key":"does-not-matter-key","category":"FREE","status":"ACTIVE","plan":{"name":"TEAMS"},"valid_from":1234.456,"valid_until":5678.567}`),
|
||||
pass: true,
|
||||
expected: &LicenseV3{
|
||||
ID: "does-not-matter",
|
||||
Key: "does-not-matter-key",
|
||||
Data: map[string]interface{}{
|
||||
"plan": map[string]interface{}{
|
||||
"name": "TEAMS",
|
||||
},
|
||||
"valid_from": 1234.456,
|
||||
"valid_until": 5678.567,
|
||||
"category": "FREE",
|
||||
"status": "ACTIVE",
|
||||
},
|
||||
PlanName: PlanNameTeams,
|
||||
ValidFrom: 1234,
|
||||
ValidUntil: 5678,
|
||||
Status: "ACTIVE",
|
||||
IsCurrent: false,
|
||||
Features: model.FeatureSet{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
var licensePayload map[string]interface{}
|
||||
err := json.Unmarshal(tc.data, &licensePayload)
|
||||
require.NoError(t, err)
|
||||
license, err := NewLicenseV3(licensePayload)
|
||||
if license != nil {
|
||||
license.Features = make(model.FeatureSet, 0)
|
||||
delete(license.Data, "features")
|
||||
}
|
||||
|
||||
if tc.pass {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, license)
|
||||
assert.Equal(t, tc.expected, license)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
assert.EqualError(t, err, tc.error.Error())
|
||||
require.Nil(t, license)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,17 @@ const SSO = "SSO"
|
||||
const Basic = "BASIC_PLAN"
|
||||
const Pro = "PRO_PLAN"
|
||||
const Enterprise = "ENTERPRISE_PLAN"
|
||||
|
||||
var (
|
||||
PlanNameEnterprise = "ENTERPRISE"
|
||||
PlanNameTeams = "TEAMS"
|
||||
PlanNameBasic = "BASIC"
|
||||
)
|
||||
|
||||
var (
|
||||
LicenseStatusInactive = "INACTIVE"
|
||||
)
|
||||
|
||||
const DisableUpsell = "DISABLE_UPSELL"
|
||||
const Onboarding = "ONBOARDING"
|
||||
const ChatSupport = "CHAT_SUPPORT"
|
||||
|
||||
@@ -61,6 +61,11 @@ func NewAnomalyRule(
|
||||
|
||||
zap.L().Info("creating new AnomalyRule", zap.String("id", id), zap.Any("opts", opts))
|
||||
|
||||
if p.RuleCondition.CompareOp == baserules.ValueIsBelow {
|
||||
target := -1 * *p.RuleCondition.Target
|
||||
p.RuleCondition.Target = &target
|
||||
}
|
||||
|
||||
baseRule, err := baserules.NewBaseRule(id, p, reader, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"@radix-ui/react-tooltip": "1.0.7",
|
||||
"@sentry/react": "7.102.1",
|
||||
"@sentry/webpack-plugin": "2.16.0",
|
||||
"@signozhq/design-tokens": "0.0.8",
|
||||
"@signozhq/design-tokens": "1.1.4",
|
||||
"@uiw/react-md-editor": "3.23.5",
|
||||
"@visx/group": "3.3.0",
|
||||
"@visx/shape": "3.5.0",
|
||||
@@ -186,6 +186,7 @@
|
||||
"@types/webpack-dev-server": "^4.7.2",
|
||||
"@typescript-eslint/eslint-plugin": "^4.33.0",
|
||||
"@typescript-eslint/parser": "^4.33.0",
|
||||
"@vvo/tzdb": "6.149.0",
|
||||
"autoprefixer": "10.4.19",
|
||||
"babel-plugin-styled-components": "^1.12.0",
|
||||
"compression-webpack-plugin": "9.0.0",
|
||||
|
||||
@@ -43,18 +43,11 @@
|
||||
"option_15min": "15 mins",
|
||||
"option_30min": "30 mins",
|
||||
"option_60min": "60 mins",
|
||||
"option_2hours": "2 hours",
|
||||
"option_3hours": "3 hours",
|
||||
"option_4hours": "4 hours",
|
||||
"option_5hours": "5 hours",
|
||||
"option_3hours": "3 hours",
|
||||
"option_6hours": "6 hours",
|
||||
"option_8hours": "8 hours",
|
||||
"option_10hours": "10 hours",
|
||||
"option_12hours": "12 hours",
|
||||
"option_24hours": "24 hours",
|
||||
"option_48hours": "48 hours",
|
||||
"option_72hours": "72 hours",
|
||||
"option_1week": "1 week",
|
||||
"field_threshold": "Alert Threshold",
|
||||
"option_allthetimes": "all the times",
|
||||
"option_atleastonce": "at least once",
|
||||
|
||||
24
frontend/public/locales/en-GB/messagingQueues.json
Normal file
24
frontend/public/locales/en-GB/messagingQueues.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"metricGraphCategory": {
|
||||
"brokerMetrics": {
|
||||
"title": "Broker Metrics",
|
||||
"description": "The Kafka Broker metrics here inform you of data loss/delay through unclean leader elections and network throughputs, as well as request fails through request purgatories and timeouts metrics"
|
||||
},
|
||||
"consumerMetrics": {
|
||||
"title": "Consumer Metrics",
|
||||
"description": "Kafka Consumer metrics provide insights into lag between message production and consumption, success rates and latency of message delivery, and the volume of data consumed."
|
||||
},
|
||||
"producerMetrics": {
|
||||
"title": "Producer Metrics",
|
||||
"description": "Kafka Producers send messages to brokers for storage and distribution by topic. These metrics inform you of the volume and rate of data sent, and the success rate of message delivery."
|
||||
},
|
||||
"brokerJVMMetrics": {
|
||||
"title": "Broker JVM Metrics",
|
||||
"description": "Kafka brokers are Java applications that expose JVM metrics to inform on the broker's system health. Garbage collection metrics like those below provide key insights into free memory, broker performance, and heap size. You need to enable new_gc_metrics for this section to populate."
|
||||
},
|
||||
"partitionMetrics": {
|
||||
"title": "Partition Metrics",
|
||||
"description": "Kafka partitions are the unit of parallelism in Kafka. These metrics inform you of the number of partitions per topic, the current offset of each partition, the oldest offset, and the number of in-sync replicas."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,54 @@
|
||||
{
|
||||
"breadcrumb": "Messaging Queues",
|
||||
"header": "Kafka / Overview",
|
||||
"overview": {
|
||||
"title": "Start sending data in as little as 20 minutes",
|
||||
"subtitle": "Connect and Monitor Your Data Streams"
|
||||
},
|
||||
"configureConsumer": {
|
||||
"title": "Configure Consumer",
|
||||
"description": "Add consumer data sources to gain insights and enhance monitoring.",
|
||||
"button": "Get Started"
|
||||
},
|
||||
"configureProducer": {
|
||||
"title": "Configure Producer",
|
||||
"description": "Add producer data sources to gain insights and enhance monitoring.",
|
||||
"button": "Get Started"
|
||||
},
|
||||
"monitorKafka": {
|
||||
"title": "Monitor kafka",
|
||||
"description": "Add your Kafka source to gain insights and enhance activity tracking.",
|
||||
"button": "Get Started"
|
||||
},
|
||||
"summarySection": {
|
||||
"viewDetailsButton": "View Details"
|
||||
},
|
||||
"confirmModal": {
|
||||
"content": "Before navigating to the details page, please make sure you have configured all the required setup to ensure correct data monitoring.",
|
||||
"okText": "Proceed"
|
||||
}
|
||||
}
|
||||
"breadcrumb": "Messaging Queues",
|
||||
"header": "Kafka / Overview",
|
||||
"overview": {
|
||||
"title": "Start sending data in as little as 20 minutes",
|
||||
"subtitle": "Connect and Monitor Your Data Streams"
|
||||
},
|
||||
"configureConsumer": {
|
||||
"title": "Configure Consumer",
|
||||
"description": "Add consumer data sources to gain insights and enhance monitoring.",
|
||||
"button": "Get Started"
|
||||
},
|
||||
"configureProducer": {
|
||||
"title": "Configure Producer",
|
||||
"description": "Add producer data sources to gain insights and enhance monitoring.",
|
||||
"button": "Get Started"
|
||||
},
|
||||
"monitorKafka": {
|
||||
"title": "Monitor kafka",
|
||||
"description": "Add your Kafka source to gain insights and enhance activity tracking.",
|
||||
"button": "Get Started"
|
||||
},
|
||||
"summarySection": {
|
||||
"viewDetailsButton": "View Details",
|
||||
"consumer": {
|
||||
"title": "Consumer lag view",
|
||||
"description": "Connect and Monitor Your Data Streams"
|
||||
},
|
||||
"producer": {
|
||||
"title": "Producer latency view",
|
||||
"description": "Connect and Monitor Your Data Streams"
|
||||
},
|
||||
"partition": {
|
||||
"title": "Partition Latency view",
|
||||
"description": "Connect and Monitor Your Data Streams"
|
||||
},
|
||||
"dropRate": {
|
||||
"title": "Drop Rate view",
|
||||
"description": "Connect and Monitor Your Data Streams"
|
||||
},
|
||||
"metricPage": {
|
||||
"title": "Metric View",
|
||||
"description": "Connect and Monitor Your Data Streams"
|
||||
}
|
||||
},
|
||||
"confirmModal": {
|
||||
"content": "Before navigating to the details page, please make sure you have configured all the required setup to ensure correct data monitoring.",
|
||||
"okText": "Proceed"
|
||||
},
|
||||
"overviewSummarySection": {
|
||||
"title": "Monitor Your Data Streams",
|
||||
"subtitle": "Monitor key Kafka metrics like consumer lag and latency to ensure efficient data flow and troubleshoot in real time."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,24 +29,12 @@
|
||||
"text_condition1": "Send a notification when",
|
||||
"text_condition2": "the threshold",
|
||||
"text_condition3": "during the last",
|
||||
"option_1min": "1 min",
|
||||
"option_5min": "5 mins",
|
||||
"option_10min": "10 mins",
|
||||
"option_15min": "15 mins",
|
||||
"option_30min": "30 mins",
|
||||
"option_60min": "60 mins",
|
||||
"option_2hours": "2 hours",
|
||||
"option_3hours": "3 hours",
|
||||
"option_4hours": "4 hours",
|
||||
"option_5hours": "5 hours",
|
||||
"option_6hours": "6 hours",
|
||||
"option_8hours": "8 hours",
|
||||
"option_10hours": "10 hours",
|
||||
"option_12hours": "12 hours",
|
||||
"option_24hours": "24 hours",
|
||||
"option_48hours": "48 hours",
|
||||
"option_72hours": "72 hours",
|
||||
"option_1week": "1 week",
|
||||
"field_threshold": "Alert Threshold",
|
||||
"option_allthetimes": "all the times",
|
||||
"option_atleastonce": "at least once",
|
||||
|
||||
@@ -47,18 +47,11 @@
|
||||
"option_15min": "15 mins",
|
||||
"option_30min": "30 mins",
|
||||
"option_60min": "60 mins",
|
||||
"option_2hours": "2 hours",
|
||||
"option_3hours": "3 hours",
|
||||
"option_4hours": "4 hours",
|
||||
"option_5hours": "5 hours",
|
||||
"option_6hours": "6 hours",
|
||||
"option_8hours": "8 hours",
|
||||
"option_10hours": "10 hours",
|
||||
"option_12hours": "12 hours",
|
||||
"option_24hours": "24 hours",
|
||||
"option_48hours": "48 hours",
|
||||
"option_72hours": "72 hours",
|
||||
"option_1week": "1 week",
|
||||
"field_threshold": "Alert Threshold",
|
||||
"option_allthetimes": "all the times",
|
||||
"option_atleastonce": "at least once",
|
||||
|
||||
24
frontend/public/locales/en/messagingQueues.json
Normal file
24
frontend/public/locales/en/messagingQueues.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"metricGraphCategory": {
|
||||
"brokerMetrics": {
|
||||
"title": "Broker Metrics",
|
||||
"description": "The Kafka Broker metrics here inform you of data loss/delay through unclean leader elections and network throughputs, as well as request fails through request purgatories and timeouts metrics"
|
||||
},
|
||||
"consumerMetrics": {
|
||||
"title": "Consumer Metrics",
|
||||
"description": "Kafka Consumer metrics provide insights into lag between message production and consumption, success rates and latency of message delivery, and the volume of data consumed."
|
||||
},
|
||||
"producerMetrics": {
|
||||
"title": "Producer Metrics",
|
||||
"description": "Kafka Producers send messages to brokers for storage and distribution by topic. These metrics inform you of the volume and rate of data sent, and the success rate of message delivery."
|
||||
},
|
||||
"brokerJVMMetrics": {
|
||||
"title": "Broker JVM Metrics",
|
||||
"description": "Kafka brokers are Java applications that expose JVM metrics to inform on the broker's system health. Garbage collection metrics like those below provide key insights into free memory, broker performance, and heap size. You need to enable new_gc_metrics for this section to populate."
|
||||
},
|
||||
"partitionMetrics": {
|
||||
"title": "Partition Metrics",
|
||||
"description": "Kafka partitions are the unit of parallelism in Kafka. These metrics inform you of the number of partitions per topic, the current offset of each partition, the oldest offset, and the number of in-sync replicas."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,10 @@
|
||||
"dropRate": {
|
||||
"title": "Drop Rate view",
|
||||
"description": "Connect and Monitor Your Data Streams"
|
||||
},
|
||||
"metricPage": {
|
||||
"title": "Metric View",
|
||||
"description": "Connect and Monitor Your Data Streams"
|
||||
}
|
||||
},
|
||||
"confirmModal": {
|
||||
|
||||
@@ -29,24 +29,12 @@
|
||||
"text_condition1": "Send a notification when",
|
||||
"text_condition2": "the threshold",
|
||||
"text_condition3": "during the last",
|
||||
"option_1min": "1 min",
|
||||
"option_5min": "5 mins",
|
||||
"option_10min": "10 mins",
|
||||
"option_15min": "15 mins",
|
||||
"option_30min": "30 mins",
|
||||
"option_60min": "60 mins",
|
||||
"option_2hours": "2 hours",
|
||||
"option_3hours": "3 hours",
|
||||
"option_4hours": "4 hours",
|
||||
"option_5hours": "5 hours",
|
||||
"option_6hours": "6 hours",
|
||||
"option_8hours": "8 hours",
|
||||
"option_10hours": "10 hours",
|
||||
"option_12hours": "12 hours",
|
||||
"option_24hours": "24 hours",
|
||||
"option_48hours": "48 hours",
|
||||
"option_72hours": "72 hours",
|
||||
"option_1week": "1 week",
|
||||
"field_threshold": "Alert Threshold",
|
||||
"option_allthetimes": "all the times",
|
||||
"option_atleastonce": "at least once",
|
||||
|
||||
@@ -22,6 +22,7 @@ import AppActions from 'types/actions';
|
||||
import { UPDATE_USER_IS_FETCH } from 'types/actions/app';
|
||||
import { Organization } from 'types/api/user/getOrganization';
|
||||
import AppReducer from 'types/reducer/app';
|
||||
import { isCloudUser } from 'utils/app';
|
||||
import { routePermission } from 'utils/permission';
|
||||
|
||||
import routes, {
|
||||
@@ -76,6 +77,8 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
|
||||
const { t } = useTranslation(['common']);
|
||||
|
||||
const isCloudUserVal = isCloudUser();
|
||||
|
||||
const localStorageUserAuthToken = getInitialUserTokenRefreshToken();
|
||||
|
||||
const dispatch = useDispatch<Dispatch<AppActions>>();
|
||||
@@ -143,6 +146,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
const handleRedirectForOrgOnboarding = (key: string): void => {
|
||||
if (
|
||||
isLoggedInState &&
|
||||
isCloudUserVal &&
|
||||
!isFetchingOrgPreferences &&
|
||||
!isLoadingOrgUsers &&
|
||||
!isEmpty(orgUsers?.payload) &&
|
||||
@@ -158,6 +162,10 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
history.push(ROUTES.ONBOARDING);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isCloudUserVal && key === 'ONBOARDING') {
|
||||
history.push(ROUTES.APPLICATION);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserLoginIfTokenPresent = async (
|
||||
@@ -250,7 +258,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
const handleRouting = (): void => {
|
||||
const showOrgOnboarding = shouldShowOnboarding();
|
||||
|
||||
if (showOrgOnboarding && !isOnboardingComplete) {
|
||||
if (showOrgOnboarding && !isOnboardingComplete && isCloudUserVal) {
|
||||
history.push(ROUTES.ONBOARDING);
|
||||
} else {
|
||||
history.push(ROUTES.APPLICATION);
|
||||
|
||||
@@ -119,3 +119,42 @@
|
||||
color: var(--bg-slate-400) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.date-time-popover-footer {
|
||||
border-top: 1px solid var(--bg-ink-200);
|
||||
padding: 8px 14px;
|
||||
.timezone-container {
|
||||
&,
|
||||
.timezone {
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--bg-vanilla-400);
|
||||
gap: 6px;
|
||||
.timezone {
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
color: var(--bg-vanilla-100);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.timezone-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(171, 189, 255, 0.04);
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
letter-spacing: -0.06px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -15,11 +15,14 @@ import { isValidTimeFormat } from 'lib/getMinMax';
|
||||
import { defaultTo, isFunction, noop } from 'lodash-es';
|
||||
import debounce from 'lodash-es/debounce';
|
||||
import { CheckCircle, ChevronDown, Clock } from 'lucide-react';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import {
|
||||
ChangeEvent,
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
@@ -28,6 +31,8 @@ import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import CustomTimePickerPopoverContent from './CustomTimePickerPopoverContent';
|
||||
|
||||
const maxAllowedMinTimeInMonths = 6;
|
||||
type ViewType = 'datetime' | 'timezone';
|
||||
const DEFAULT_VIEW: ViewType = 'datetime';
|
||||
|
||||
interface CustomTimePickerProps {
|
||||
onSelect: (value: string) => void;
|
||||
@@ -81,6 +86,25 @@ function CustomTimePicker({
|
||||
const location = useLocation();
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
|
||||
const [activeView, setActiveView] = useState<ViewType>(DEFAULT_VIEW);
|
||||
|
||||
const { timezone, browserTimezone } = useTimezone();
|
||||
const activeTimezoneOffset = timezone?.offset;
|
||||
const isTimezoneOverridden = useMemo(
|
||||
() => timezone?.offset !== browserTimezone.offset,
|
||||
[timezone, browserTimezone],
|
||||
);
|
||||
|
||||
const handleViewChange = useCallback(
|
||||
(newView: 'timezone' | 'datetime'): void => {
|
||||
if (activeView !== newView) {
|
||||
setActiveView(newView);
|
||||
}
|
||||
setOpen(!open);
|
||||
},
|
||||
[activeView, open, setOpen],
|
||||
);
|
||||
|
||||
const getSelectedTimeRangeLabel = (
|
||||
selectedTime: string,
|
||||
selectedTimeValue: string,
|
||||
@@ -132,6 +156,7 @@ function CustomTimePicker({
|
||||
setOpen(newOpen);
|
||||
if (!newOpen) {
|
||||
setCustomDTPickerVisible?.(false);
|
||||
setActiveView('datetime');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -281,6 +306,8 @@ function CustomTimePicker({
|
||||
handleGoLive={defaultTo(handleGoLive, noop)}
|
||||
options={items}
|
||||
selectedTime={selectedTime}
|
||||
activeView={activeView}
|
||||
setActiveView={setActiveView}
|
||||
/>
|
||||
) : (
|
||||
content
|
||||
@@ -317,12 +344,23 @@ function CustomTimePicker({
|
||||
)
|
||||
}
|
||||
suffix={
|
||||
<ChevronDown
|
||||
size={14}
|
||||
onClick={(): void => {
|
||||
setOpen(!open);
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
{!!isTimezoneOverridden && activeTimezoneOffset && (
|
||||
<div
|
||||
className="timezone-badge"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
handleViewChange('timezone');
|
||||
}}
|
||||
>
|
||||
<span>{activeTimezoneOffset}</span>
|
||||
</div>
|
||||
)}
|
||||
<ChevronDown
|
||||
size={14}
|
||||
onClick={(): void => handleViewChange('datetime')}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Popover>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import './CustomTimePicker.styles.scss';
|
||||
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -9,10 +10,13 @@ import {
|
||||
Option,
|
||||
RelativeDurationSuggestionOptions,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/config';
|
||||
import { Clock } from 'lucide-react';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { Dispatch, SetStateAction, useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import RangePickerModal from './RangePickerModal';
|
||||
import TimezonePicker from './TimezonePicker';
|
||||
|
||||
interface CustomTimePickerPopoverContentProps {
|
||||
options: any[];
|
||||
@@ -26,8 +30,11 @@ interface CustomTimePickerPopoverContentProps {
|
||||
onSelectHandler: (label: string, value: string) => void;
|
||||
handleGoLive: () => void;
|
||||
selectedTime: string;
|
||||
activeView: 'datetime' | 'timezone';
|
||||
setActiveView: Dispatch<SetStateAction<'datetime' | 'timezone'>>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function CustomTimePickerPopoverContent({
|
||||
options,
|
||||
setIsOpen,
|
||||
@@ -37,12 +44,16 @@ function CustomTimePickerPopoverContent({
|
||||
onSelectHandler,
|
||||
handleGoLive,
|
||||
selectedTime,
|
||||
activeView,
|
||||
setActiveView,
|
||||
}: CustomTimePickerPopoverContentProps): JSX.Element {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const isLogsExplorerPage = useMemo(() => pathname === ROUTES.LOGS_EXPLORER, [
|
||||
pathname,
|
||||
]);
|
||||
const { timezone } = useTimezone();
|
||||
const activeTimezoneOffset = timezone?.offset;
|
||||
|
||||
function getTimeChips(options: Option[]): JSX.Element {
|
||||
return (
|
||||
@@ -63,55 +74,75 @@ function CustomTimePickerPopoverContent({
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="date-time-popover">
|
||||
<div className="date-time-options">
|
||||
{isLogsExplorerPage && (
|
||||
<Button className="data-time-live" type="text" onClick={handleGoLive}>
|
||||
Live
|
||||
</Button>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
type="text"
|
||||
key={option.label + option.value}
|
||||
onClick={(): void => {
|
||||
onSelectHandler(option.label, option.value);
|
||||
}}
|
||||
className={cx(
|
||||
'date-time-options-btn',
|
||||
customDateTimeVisible
|
||||
? option.value === 'custom' && 'active'
|
||||
: selectedTime === option.value && 'active',
|
||||
)}
|
||||
return activeView === 'datetime' ? (
|
||||
<div>
|
||||
<div className="date-time-popover">
|
||||
<div className="date-time-options">
|
||||
{isLogsExplorerPage && (
|
||||
<Button className="data-time-live" type="text" onClick={handleGoLive}>
|
||||
Live
|
||||
</Button>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
type="text"
|
||||
key={option.label + option.value}
|
||||
onClick={(): void => {
|
||||
onSelectHandler(option.label, option.value);
|
||||
}}
|
||||
className={cx(
|
||||
'date-time-options-btn',
|
||||
customDateTimeVisible
|
||||
? option.value === 'custom' && 'active'
|
||||
: selectedTime === option.value && 'active',
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
'relative-date-time',
|
||||
selectedTime === 'custom' || customDateTimeVisible
|
||||
? 'date-picker'
|
||||
: 'relative-times',
|
||||
)}
|
||||
>
|
||||
{selectedTime === 'custom' || customDateTimeVisible ? (
|
||||
<RangePickerModal
|
||||
setCustomDTPickerVisible={setCustomDTPickerVisible}
|
||||
setIsOpen={setIsOpen}
|
||||
onCustomDateHandler={onCustomDateHandler}
|
||||
selectedTime={selectedTime}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative-times-container">
|
||||
<div className="time-heading">RELATIVE TIMES</div>
|
||||
<div>{getTimeChips(RelativeDurationSuggestionOptions)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="date-time-popover-footer">
|
||||
<div className="timezone-container">
|
||||
<Clock color={Color.BG_VANILLA_400} height={12} width={12} />
|
||||
<span className="timezone-text">You are at</span>
|
||||
<button
|
||||
type="button"
|
||||
className="timezone"
|
||||
onClick={(): void => setActiveView('timezone')}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
'relative-date-time',
|
||||
selectedTime === 'custom' || customDateTimeVisible
|
||||
? 'date-picker'
|
||||
: 'relative-times',
|
||||
)}
|
||||
>
|
||||
{selectedTime === 'custom' || customDateTimeVisible ? (
|
||||
<RangePickerModal
|
||||
setCustomDTPickerVisible={setCustomDTPickerVisible}
|
||||
setIsOpen={setIsOpen}
|
||||
onCustomDateHandler={onCustomDateHandler}
|
||||
selectedTime={selectedTime}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative-times-container">
|
||||
<div className="time-heading">RELATIVE TIMES</div>
|
||||
<div>{getTimeChips(RelativeDurationSuggestionOptions)}</div>
|
||||
</div>
|
||||
)}
|
||||
{activeTimezoneOffset}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="date-time-popover">
|
||||
<TimezonePicker setActiveView={setActiveView} setIsOpen={setIsOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { DatePicker } from 'antd';
|
||||
import { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal';
|
||||
import { LexicalContext } from 'container/TopNav/DateTimeSelectionV2/config';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
@@ -49,6 +50,8 @@ function RangePickerModal(props: RangePickerModalProps): JSX.Element {
|
||||
}
|
||||
onCustomDateHandler(date_time, LexicalContext.CUSTOM_DATE_PICKER);
|
||||
};
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
return (
|
||||
<div className="custom-date-picker">
|
||||
<RangePicker
|
||||
@@ -58,7 +61,10 @@ function RangePickerModal(props: RangePickerModalProps): JSX.Element {
|
||||
onOk={onModalOkHandler}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...(selectedTime === 'custom' && {
|
||||
defaultValue: [dayjs(minTime / 1000000), dayjs(maxTime / 1000000)],
|
||||
defaultValue: [
|
||||
dayjs(minTime / 1000000).tz(timezone.value),
|
||||
dayjs(maxTime / 1000000).tz(timezone.value),
|
||||
],
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// Variables
|
||||
$font-family: 'Inter';
|
||||
$border-color: var(--bg-slate-400);
|
||||
$item-spacing: 8px;
|
||||
|
||||
// Mixins
|
||||
@mixin text-style-base {
|
||||
font-family: $font-family;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@mixin flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.timezone-picker {
|
||||
width: 532px;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: $font-family;
|
||||
|
||||
&__search {
|
||||
@include flex-center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
}
|
||||
|
||||
&__input-container {
|
||||
@include flex-center;
|
||||
gap: 6px;
|
||||
width: -webkit-fill-available;
|
||||
}
|
||||
|
||||
&__input {
|
||||
@include text-style-base;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
padding: 0;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
|
||||
&__esc-key {
|
||||
@include text-style-base;
|
||||
font-size: 8px;
|
||||
color: var(--bg-vanilla-400);
|
||||
letter-spacing: -0.04px;
|
||||
border-radius: 2.286px;
|
||||
border: 1.143px solid var(--bg-ink-200);
|
||||
border-bottom-width: 2.286px;
|
||||
background: var(--bg-ink-400);
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
&__list {
|
||||
max-height: 310px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&__item {
|
||||
@include flex-center;
|
||||
justify-content: space-between;
|
||||
padding: 7.5px 6px 7.5px $item-spacing;
|
||||
margin: 4px $item-spacing;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: -webkit-fill-available;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: $font-family;
|
||||
|
||||
&:hover,
|
||||
&.selected {
|
||||
border-radius: 2px;
|
||||
background: rgba(171, 189, 255, 0.04);
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
&.has-divider {
|
||||
position: relative;
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: -$item-spacing;
|
||||
right: -$item-spacing;
|
||||
border-bottom: 1px solid $border-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__name {
|
||||
@include text-style-base;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
&__offset {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
}
|
||||
|
||||
.timezone-name-wrapper {
|
||||
@include flex-center;
|
||||
gap: 6px;
|
||||
|
||||
&__selected-icon {
|
||||
height: 15px;
|
||||
width: 15px;
|
||||
}
|
||||
}
|
||||
156
frontend/src/components/CustomTimePicker/TimezonePicker.tsx
Normal file
156
frontend/src/components/CustomTimePicker/TimezonePicker.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import './TimezonePicker.styles.scss';
|
||||
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import cx from 'classnames';
|
||||
import { TimezonePickerShortcuts } from 'constants/shortcuts/TimezonePickerShortcuts';
|
||||
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
|
||||
import { Check, Search } from 'lucide-react';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { Timezone, TIMEZONE_DATA } from './timezoneUtils';
|
||||
|
||||
interface SearchBarProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
interface TimezoneItemProps {
|
||||
timezone: Timezone;
|
||||
isSelected?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const ICON_SIZE = 14;
|
||||
|
||||
function SearchBar({ value, onChange }: SearchBarProps): JSX.Element {
|
||||
return (
|
||||
<div className="timezone-picker__search">
|
||||
<div className="timezone-picker__input-container">
|
||||
<Search color={Color.BG_VANILLA_400} height={ICON_SIZE} width={ICON_SIZE} />
|
||||
<input
|
||||
type="text"
|
||||
className="timezone-picker__input"
|
||||
placeholder="Search timezones..."
|
||||
value={value}
|
||||
onChange={(e): void => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<kbd className="timezone-picker__esc-key">esc</kbd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimezoneItem({
|
||||
timezone,
|
||||
isSelected = false,
|
||||
onClick,
|
||||
}: TimezoneItemProps): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cx('timezone-picker__item', {
|
||||
selected: isSelected,
|
||||
'has-divider': timezone.hasDivider,
|
||||
})}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="timezone-name-wrapper">
|
||||
<div className="timezone-name-wrapper__selected-icon">
|
||||
{isSelected && (
|
||||
<Check
|
||||
color={Color.BG_VANILLA_100}
|
||||
height={ICON_SIZE}
|
||||
width={ICON_SIZE}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="timezone-picker__name">{timezone.name}</div>
|
||||
</div>
|
||||
<div className="timezone-picker__offset">{timezone.offset}</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
TimezoneItem.defaultProps = {
|
||||
isSelected: false,
|
||||
onClick: undefined,
|
||||
};
|
||||
|
||||
interface TimezonePickerProps {
|
||||
setActiveView: Dispatch<SetStateAction<'datetime' | 'timezone'>>;
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
function TimezonePicker({
|
||||
setActiveView,
|
||||
setIsOpen,
|
||||
}: TimezonePickerProps): JSX.Element {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const { timezone, updateTimezone } = useTimezone();
|
||||
const [selectedTimezone, setSelectedTimezone] = useState<string>(
|
||||
timezone?.name ?? TIMEZONE_DATA[0].name,
|
||||
);
|
||||
|
||||
const getFilteredTimezones = useCallback((searchTerm: string): Timezone[] => {
|
||||
const normalizedSearch = searchTerm.toLowerCase();
|
||||
return TIMEZONE_DATA.filter(
|
||||
(tz) =>
|
||||
tz.name.toLowerCase().includes(normalizedSearch) ||
|
||||
tz.offset.toLowerCase().includes(normalizedSearch) ||
|
||||
tz.searchIndex.toLowerCase().includes(normalizedSearch),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleCloseTimezonePicker = useCallback(() => {
|
||||
setActiveView('datetime');
|
||||
}, [setActiveView]);
|
||||
|
||||
const handleTimezoneSelect = useCallback(
|
||||
(timezone: Timezone) => {
|
||||
setSelectedTimezone(timezone.name);
|
||||
updateTimezone(timezone);
|
||||
handleCloseTimezonePicker();
|
||||
setIsOpen(false);
|
||||
},
|
||||
[handleCloseTimezonePicker, setIsOpen, updateTimezone],
|
||||
);
|
||||
|
||||
// Register keyboard shortcuts
|
||||
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
|
||||
|
||||
useEffect(() => {
|
||||
registerShortcut(
|
||||
TimezonePickerShortcuts.CloseTimezonePicker,
|
||||
handleCloseTimezonePicker,
|
||||
);
|
||||
|
||||
return (): void => {
|
||||
deregisterShortcut(TimezonePickerShortcuts.CloseTimezonePicker);
|
||||
};
|
||||
}, [deregisterShortcut, handleCloseTimezonePicker, registerShortcut]);
|
||||
|
||||
return (
|
||||
<div className="timezone-picker">
|
||||
<SearchBar value={searchTerm} onChange={setSearchTerm} />
|
||||
<div className="timezone-picker__list">
|
||||
{getFilteredTimezones(searchTerm).map((timezone) => (
|
||||
<TimezoneItem
|
||||
key={timezone.value}
|
||||
timezone={timezone}
|
||||
isSelected={timezone.name === selectedTimezone}
|
||||
onClick={(): void => handleTimezoneSelect(timezone)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimezonePicker;
|
||||
142
frontend/src/components/CustomTimePicker/timezoneUtils.ts
Normal file
142
frontend/src/components/CustomTimePicker/timezoneUtils.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { getTimeZones } from '@vvo/tzdb';
|
||||
import dayjs from 'dayjs';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
export interface Timezone {
|
||||
name: string;
|
||||
value: string;
|
||||
offset: string;
|
||||
searchIndex: string;
|
||||
hasDivider?: boolean;
|
||||
}
|
||||
|
||||
// Constants
|
||||
const TIMEZONE_TYPES = {
|
||||
BROWSER: 'BROWSER',
|
||||
UTC: 'UTC',
|
||||
STANDARD: 'STANDARD',
|
||||
} as const;
|
||||
|
||||
type TimezoneType = typeof TIMEZONE_TYPES[keyof typeof TIMEZONE_TYPES];
|
||||
|
||||
const UTC_TIMEZONE: Timezone = {
|
||||
name: 'Coordinated Universal Time — UTC, GMT',
|
||||
value: 'UTC',
|
||||
offset: 'UTC',
|
||||
searchIndex: 'UTC',
|
||||
hasDivider: true,
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
const isValidTimezone = (tzName: string): boolean => {
|
||||
try {
|
||||
dayjs.tz(dayjs(), tzName);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const formatOffset = (offsetMinutes: number): string => {
|
||||
if (offsetMinutes === 0) return 'UTC';
|
||||
|
||||
const hours = Math.floor(Math.abs(offsetMinutes) / 60);
|
||||
const minutes = Math.abs(offsetMinutes) % 60;
|
||||
const sign = offsetMinutes > 0 ? '+' : '-';
|
||||
|
||||
return `UTC ${sign} ${hours}${
|
||||
minutes ? `:${minutes.toString().padStart(2, '0')}` : ':00'
|
||||
}`;
|
||||
};
|
||||
|
||||
const createTimezoneEntry = (
|
||||
name: string,
|
||||
offsetMinutes: number,
|
||||
type: TimezoneType = TIMEZONE_TYPES.STANDARD,
|
||||
hasDivider = false,
|
||||
): Timezone => {
|
||||
const offset = formatOffset(offsetMinutes);
|
||||
let value = name;
|
||||
let displayName = name;
|
||||
|
||||
switch (type) {
|
||||
case TIMEZONE_TYPES.BROWSER:
|
||||
displayName = `Browser time — ${name}`;
|
||||
value = name;
|
||||
break;
|
||||
case TIMEZONE_TYPES.UTC:
|
||||
displayName = 'Coordinated Universal Time — UTC, GMT';
|
||||
value = 'UTC';
|
||||
break;
|
||||
case TIMEZONE_TYPES.STANDARD:
|
||||
displayName = name;
|
||||
value = name;
|
||||
break;
|
||||
default:
|
||||
console.error(`Invalid timezone type: ${type}`);
|
||||
}
|
||||
|
||||
return {
|
||||
name: displayName,
|
||||
value,
|
||||
offset,
|
||||
searchIndex: offset.replace(/ /g, ''),
|
||||
...(hasDivider && { hasDivider }),
|
||||
};
|
||||
};
|
||||
|
||||
const getOffsetByTimezone = (timezone: string): number => {
|
||||
const dayjsTimezone = dayjs().tz(timezone);
|
||||
return dayjsTimezone.utcOffset();
|
||||
};
|
||||
|
||||
export const getBrowserTimezone = (): Timezone => {
|
||||
const browserTz = dayjs.tz.guess();
|
||||
const browserOffset = getOffsetByTimezone(browserTz);
|
||||
return createTimezoneEntry(browserTz, browserOffset, TIMEZONE_TYPES.BROWSER);
|
||||
};
|
||||
|
||||
const filterAndSortTimezones = (
|
||||
allTimezones: ReturnType<typeof getTimeZones>,
|
||||
browserTzName?: string,
|
||||
): Timezone[] =>
|
||||
allTimezones
|
||||
.filter(
|
||||
(tz) =>
|
||||
!tz.name.startsWith('Etc/') &&
|
||||
isValidTimezone(tz.name) &&
|
||||
tz.name !== browserTzName,
|
||||
)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((tz) => createTimezoneEntry(tz.name, tz.rawOffsetInMinutes));
|
||||
|
||||
const generateTimezoneData = (): Timezone[] => {
|
||||
const allTimezones = getTimeZones();
|
||||
const timezones: Timezone[] = [];
|
||||
|
||||
// Add browser timezone
|
||||
const browserTzObject = getBrowserTimezone();
|
||||
timezones.push(browserTzObject);
|
||||
|
||||
// Add UTC timezone with divider
|
||||
timezones.push(UTC_TIMEZONE);
|
||||
|
||||
// Add remaining timezones
|
||||
timezones.push(...filterAndSortTimezones(allTimezones, browserTzObject.value));
|
||||
|
||||
return timezones;
|
||||
};
|
||||
|
||||
export const getTimezoneObjectByTimezoneString = (
|
||||
timezone: string,
|
||||
): Timezone => {
|
||||
const utcOffset = getOffsetByTimezone(timezone);
|
||||
|
||||
return createTimezoneEntry(timezone, utcOffset);
|
||||
};
|
||||
|
||||
export const TIMEZONE_DATA = generateTimezoneData();
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
_adapters,
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
@@ -18,8 +19,10 @@ import {
|
||||
} from 'chart.js';
|
||||
import annotationPlugin from 'chartjs-plugin-annotation';
|
||||
import { generateGridTitle } from 'container/GridPanelSwitch/utils';
|
||||
import dayjs from 'dayjs';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
@@ -62,6 +65,17 @@ Chart.register(
|
||||
|
||||
Tooltip.positioners.custom = TooltipPositionHandler;
|
||||
|
||||
// Map of Chart.js time formats to dayjs format strings
|
||||
const formatMap = {
|
||||
'HH:mm:ss': 'HH:mm:ss',
|
||||
'HH:mm': 'HH:mm',
|
||||
'MM/DD HH:mm': 'MM/DD HH:mm',
|
||||
'MM/dd HH:mm': 'MM/DD HH:mm',
|
||||
'MM/DD': 'MM/DD',
|
||||
'YY-MM': 'YY-MM',
|
||||
YY: 'YY',
|
||||
};
|
||||
|
||||
const Graph = forwardRef<ToggleGraphProps | undefined, GraphProps>(
|
||||
(
|
||||
{
|
||||
@@ -80,11 +94,13 @@ const Graph = forwardRef<ToggleGraphProps | undefined, GraphProps>(
|
||||
dragSelectColor,
|
||||
},
|
||||
ref,
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
): JSX.Element => {
|
||||
const nearestDatasetIndex = useRef<null | number>(null);
|
||||
const chartRef = useRef<HTMLCanvasElement>(null);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const gridTitle = useMemo(() => generateGridTitle(title), [title]);
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const currentTheme = isDarkMode ? 'dark' : 'light';
|
||||
const xAxisTimeUnit = useXAxisTimeUnit(data); // Computes the relevant time unit for x axis by analyzing the time stamp data
|
||||
@@ -112,6 +128,22 @@ const Graph = forwardRef<ToggleGraphProps | undefined, GraphProps>(
|
||||
return 'rgba(231,233,237,0.8)';
|
||||
}, [currentTheme]);
|
||||
|
||||
// Override Chart.js date adapter to use dayjs with timezone support
|
||||
useEffect(() => {
|
||||
_adapters._date.override({
|
||||
format(time: number | Date, fmt: string) {
|
||||
const dayjsTime = dayjs(time).tz(timezone?.value);
|
||||
const format = formatMap[fmt as keyof typeof formatMap];
|
||||
if (!format) {
|
||||
console.warn(`Missing datetime format for ${fmt}`);
|
||||
return dayjsTime.format('YYYY-MM-DD HH:mm:ss'); // fallback format
|
||||
}
|
||||
|
||||
return dayjsTime.format(format);
|
||||
},
|
||||
});
|
||||
}, [timezone]);
|
||||
|
||||
const buildChart = useCallback(() => {
|
||||
if (lineChartRef.current !== undefined) {
|
||||
lineChartRef.current.destroy();
|
||||
@@ -132,6 +164,7 @@ const Graph = forwardRef<ToggleGraphProps | undefined, GraphProps>(
|
||||
isStacked,
|
||||
onClickHandler,
|
||||
data,
|
||||
timezone,
|
||||
);
|
||||
|
||||
const chartHasData = hasData(data);
|
||||
@@ -166,6 +199,7 @@ const Graph = forwardRef<ToggleGraphProps | undefined, GraphProps>(
|
||||
isStacked,
|
||||
onClickHandler,
|
||||
data,
|
||||
timezone,
|
||||
name,
|
||||
type,
|
||||
]);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Chart, ChartConfiguration, ChartData, Color } from 'chart.js';
|
||||
import * as chartjsAdapter from 'chartjs-adapter-date-fns';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import dayjs from 'dayjs';
|
||||
import { MutableRefObject } from 'react';
|
||||
|
||||
@@ -50,6 +51,7 @@ export const getGraphOptions = (
|
||||
isStacked: boolean | undefined,
|
||||
onClickHandler: GraphOnClickHandler | undefined,
|
||||
data: ChartData,
|
||||
timezone: Timezone,
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
): CustomChartOptions => ({
|
||||
animation: {
|
||||
@@ -97,7 +99,7 @@ export const getGraphOptions = (
|
||||
callbacks: {
|
||||
title(context): string | string[] {
|
||||
const date = dayjs(context[0].parsed.x);
|
||||
return date.format('MMM DD, YYYY, HH:mm:ss');
|
||||
return date.tz(timezone?.value).format('MMM DD, YYYY, HH:mm:ss');
|
||||
},
|
||||
label(context): string | string[] {
|
||||
let label = context.dataset.label || '';
|
||||
|
||||
@@ -8,13 +8,13 @@ import LogDetail from 'components/LogDetail';
|
||||
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
||||
import { unescapeString } from 'container/LogDetailedView/utils';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import dayjs from 'dayjs';
|
||||
import dompurify from 'dompurify';
|
||||
import { useActiveLog } from 'hooks/logs/useActiveLog';
|
||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
// utils
|
||||
import { FlatLogData } from 'lib/logs/flatLogData';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
// interfaces
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
@@ -174,12 +174,20 @@ function ListLogView({
|
||||
[selectedFields],
|
||||
);
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const timestampValue = useMemo(
|
||||
() =>
|
||||
typeof flattenLogData.timestamp === 'string'
|
||||
? dayjs(flattenLogData.timestamp).format('YYYY-MM-DD HH:mm:ss.SSS')
|
||||
: dayjs(flattenLogData.timestamp / 1e6).format('YYYY-MM-DD HH:mm:ss.SSS'),
|
||||
[flattenLogData.timestamp],
|
||||
? formatTimezoneAdjustedTimestamp(
|
||||
flattenLogData.timestamp,
|
||||
'YYYY-MM-DD HH:mm:ss.SSS',
|
||||
)
|
||||
: formatTimezoneAdjustedTimestamp(
|
||||
flattenLogData.timestamp / 1e6,
|
||||
'YYYY-MM-DD HH:mm:ss.SSS',
|
||||
),
|
||||
[flattenLogData.timestamp, formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
const logType = getLogIndicatorType(logData);
|
||||
|
||||
@@ -6,7 +6,6 @@ import LogDetail from 'components/LogDetail';
|
||||
import { VIEW_TYPES, VIEWS } from 'components/LogDetail/constants';
|
||||
import { unescapeString } from 'container/LogDetailedView/utils';
|
||||
import LogsExplorerContext from 'container/LogsExplorerContext';
|
||||
import dayjs from 'dayjs';
|
||||
import dompurify from 'dompurify';
|
||||
import { useActiveLog } from 'hooks/logs/useActiveLog';
|
||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
@@ -14,6 +13,7 @@ import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { FlatLogData } from 'lib/logs/flatLogData';
|
||||
import { isEmpty, isNumber, isUndefined } from 'lodash-es';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import {
|
||||
KeyboardEvent,
|
||||
MouseEvent,
|
||||
@@ -89,16 +89,24 @@ function RawLogView({
|
||||
attributesText += ' | ';
|
||||
}
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const text = useMemo(() => {
|
||||
const date =
|
||||
typeof data.timestamp === 'string'
|
||||
? dayjs(data.timestamp)
|
||||
: dayjs(data.timestamp / 1e6);
|
||||
? formatTimezoneAdjustedTimestamp(data.timestamp, 'YYYY-MM-DD HH:mm:ss.SSS')
|
||||
: formatTimezoneAdjustedTimestamp(
|
||||
data.timestamp / 1e6,
|
||||
'YYYY-MM-DD HH:mm:ss.SSS',
|
||||
);
|
||||
|
||||
return `${date.format('YYYY-MM-DD HH:mm:ss.SSS')} | ${attributesText} ${
|
||||
data.body
|
||||
}`;
|
||||
}, [data.timestamp, data.body, attributesText]);
|
||||
return `${date} | ${attributesText} ${data.body}`;
|
||||
}, [
|
||||
data.timestamp,
|
||||
data.body,
|
||||
attributesText,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
]);
|
||||
|
||||
const handleClickExpand = useCallback(() => {
|
||||
if (activeContextLog || isReadOnly) return;
|
||||
|
||||
@@ -5,10 +5,10 @@ import { Typography } from 'antd';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
import cx from 'classnames';
|
||||
import { unescapeString } from 'container/LogDetailedView/utils';
|
||||
import dayjs from 'dayjs';
|
||||
import dompurify from 'dompurify';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { FlatLogData } from 'lib/logs/flatLogData';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { useMemo } from 'react';
|
||||
import { FORBID_DOM_PURIFY_TAGS } from 'utils/app';
|
||||
|
||||
@@ -44,6 +44,8 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
|
||||
logs,
|
||||
]);
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const columns: ColumnsType<Record<string, unknown>> = useMemo(() => {
|
||||
const fieldColumns: ColumnsType<Record<string, unknown>> = fields
|
||||
.filter((e) => e.name !== 'id')
|
||||
@@ -81,8 +83,11 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
|
||||
render: (field, item): ColumnTypeRender<Record<string, unknown>> => {
|
||||
const date =
|
||||
typeof field === 'string'
|
||||
? dayjs(field).format('YYYY-MM-DD HH:mm:ss.SSS')
|
||||
: dayjs(field / 1e6).format('YYYY-MM-DD HH:mm:ss.SSS');
|
||||
? formatTimezoneAdjustedTimestamp(field, 'YYYY-MM-DD HH:mm:ss.SSS')
|
||||
: formatTimezoneAdjustedTimestamp(
|
||||
field / 1e6,
|
||||
'YYYY-MM-DD HH:mm:ss.SSS',
|
||||
);
|
||||
return {
|
||||
children: (
|
||||
<div className="table-timestamp">
|
||||
@@ -125,7 +130,15 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
|
||||
},
|
||||
...(appendTo === 'end' ? fieldColumns : []),
|
||||
];
|
||||
}, [fields, isListViewPanel, appendTo, isDarkMode, linesPerRow, fontSize]);
|
||||
}, [
|
||||
fields,
|
||||
isListViewPanel,
|
||||
appendTo,
|
||||
isDarkMode,
|
||||
linesPerRow,
|
||||
fontSize,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
]);
|
||||
|
||||
return { columns, dataSource: flattenLogData };
|
||||
};
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Typography } from 'antd';
|
||||
import convertDateToAmAndPm from 'lib/convertDateToAmAndPm';
|
||||
import getFormattedDate from 'lib/getFormatedDate';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
function Time({ CreatedOrUpdateTime }: DateProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const time = new Date(CreatedOrUpdateTime);
|
||||
const date = getFormattedDate(time);
|
||||
const timeString = `${date} ${convertDateToAmAndPm(time)}`;
|
||||
const timeString = formatTimezoneAdjustedTimestamp(
|
||||
time,
|
||||
'MM/DD/YYYY hh:mm:ss A (UTC Z)',
|
||||
);
|
||||
return <Typography>{timeString}</Typography>;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,4 +21,5 @@ export enum LOCALSTORAGE {
|
||||
THEME_ANALYTICS_V1 = 'THEME_ANALYTICS_V1',
|
||||
LAST_USED_SAVED_VIEWS = 'LAST_USED_SAVED_VIEWS',
|
||||
SHOW_LOGS_QUICK_FILTERS = 'SHOW_LOGS_QUICK_FILTERS',
|
||||
PREFERRED_TIMEZONE = 'PREFERRED_TIMEZONE',
|
||||
}
|
||||
|
||||
@@ -18,4 +18,5 @@ export const REACT_QUERY_KEY = {
|
||||
GET_ALL_ALLERTS: 'GET_ALL_ALLERTS',
|
||||
REMOVE_ALERT_RULE: 'REMOVE_ALERT_RULE',
|
||||
DUPLICATE_ALERT_RULE: 'DUPLICATE_ALERT_RULE',
|
||||
UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const TimezonePickerShortcuts = {
|
||||
CloseTimezonePicker: 'escape',
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import history from 'lib/history';
|
||||
import heatmapPlugin from 'lib/uPlotLib/plugins/heatmapPlugin';
|
||||
import timelinePlugin from 'lib/uPlotLib/plugins/timelinePlugin';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
@@ -48,6 +49,7 @@ function HorizontalTimelineGraph({
|
||||
|
||||
const urlQuery = useUrlQuery();
|
||||
const dispatch = useDispatch();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const options: uPlot.Options = useMemo(
|
||||
() => ({
|
||||
@@ -116,8 +118,18 @@ function HorizontalTimelineGraph({
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
|
||||
tzDate: (timestamp: number): Date =>
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone?.value),
|
||||
}),
|
||||
[width, isDarkMode, transformedData.length, urlQuery, dispatch],
|
||||
[
|
||||
width,
|
||||
isDarkMode,
|
||||
transformedData.length,
|
||||
urlQuery,
|
||||
dispatch,
|
||||
timezone?.value,
|
||||
],
|
||||
);
|
||||
return <Uplot data={transformedData} options={options} />;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useGetAlertRuleDetailsTimelineTable,
|
||||
useTimelineTable,
|
||||
} from 'pages/AlertDetails/hooks';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
@@ -39,6 +40,8 @@ function TimelineTable(): JSX.Element {
|
||||
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
if (isError || !isValidRuleId || !ruleId) {
|
||||
return <div>{t('something_went_wrong')}</div>;
|
||||
}
|
||||
@@ -51,6 +54,7 @@ function TimelineTable(): JSX.Element {
|
||||
filters,
|
||||
labels: labels ?? {},
|
||||
setFilters,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
})}
|
||||
dataSource={timelineData}
|
||||
pagination={paginationConfig}
|
||||
|
||||
@@ -8,6 +8,7 @@ import ClientSideQBSearch, {
|
||||
import { ConditionalAlertPopover } from 'container/AlertHistory/AlertPopover/AlertPopover';
|
||||
import { transformKeyValuesToAttributeValuesMap } from 'container/QueryBuilder/filters/utils';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
|
||||
import { Search } from 'lucide-react';
|
||||
import AlertLabels, {
|
||||
AlertLabelsProps,
|
||||
@@ -16,7 +17,6 @@ import AlertState from 'pages/AlertDetails/AlertHeader/AlertState/AlertState';
|
||||
import { useMemo } from 'react';
|
||||
import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { formatEpochTimestamp } from 'utils/timeUtils';
|
||||
|
||||
const transformLabelsToQbKeys = (
|
||||
labels: AlertRuleTimelineTableResponse['labels'],
|
||||
@@ -74,10 +74,15 @@ export const timelineTableColumns = ({
|
||||
filters,
|
||||
labels,
|
||||
setFilters,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
}: {
|
||||
filters: TagFilter;
|
||||
labels: AlertLabelsProps['labels'];
|
||||
setFilters: (filters: TagFilter) => void;
|
||||
formatTimezoneAdjustedTimestamp: (
|
||||
input: TimestampInput,
|
||||
format?: string,
|
||||
) => string;
|
||||
}): ColumnsType<AlertRuleTimelineTableResponse> => [
|
||||
{
|
||||
title: 'STATE',
|
||||
@@ -106,7 +111,9 @@ export const timelineTableColumns = ({
|
||||
dataIndex: 'unixMilli',
|
||||
width: 200,
|
||||
render: (value): JSX.Element => (
|
||||
<div className="alert-rule__created-at">{formatEpochTimestamp(value)}</div>
|
||||
<div className="alert-rule__created-at">
|
||||
{formatTimezoneAdjustedTimestamp(value, 'MMM D, YYYY ⎯ HH:mm:ss')}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -17,14 +17,15 @@ import getAll from 'api/errors/getAll';
|
||||
import getErrorCounts from 'api/errors/getErrorCounts';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import ROUTES from 'constants/routes';
|
||||
import dayjs from 'dayjs';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import useResourceAttribute from 'hooks/useResourceAttribute';
|
||||
import { convertRawQueriesToTraceSelectedTags } from 'hooks/useResourceAttribute/utils';
|
||||
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import history from 'lib/history';
|
||||
import { isUndefined } from 'lodash-es';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueries } from 'react-query';
|
||||
@@ -155,8 +156,16 @@ function AllErrors(): JSX.Element {
|
||||
}
|
||||
}, [data?.error, data?.payload, t, notifications]);
|
||||
|
||||
const getDateValue = (value: string): JSX.Element => (
|
||||
<Typography>{dayjs(value).format('DD/MM/YYYY HH:mm:ss A')}</Typography>
|
||||
const getDateValue = (
|
||||
value: string,
|
||||
formatTimezoneAdjustedTimestamp: (
|
||||
input: TimestampInput,
|
||||
format?: string,
|
||||
) => string,
|
||||
): JSX.Element => (
|
||||
<Typography>
|
||||
{formatTimezoneAdjustedTimestamp(value, 'DD/MM/YYYY hh:mm:ss A')}
|
||||
</Typography>
|
||||
);
|
||||
|
||||
const filterIcon = useCallback(() => <SearchOutlined />, []);
|
||||
@@ -283,6 +292,8 @@ function AllErrors(): JSX.Element {
|
||||
[filterIcon, filterDropdownWrapper],
|
||||
);
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const columns: ColumnsType<Exception> = [
|
||||
{
|
||||
title: 'Exception Type',
|
||||
@@ -342,7 +353,8 @@ function AllErrors(): JSX.Element {
|
||||
dataIndex: 'lastSeen',
|
||||
width: 80,
|
||||
key: 'lastSeen',
|
||||
render: getDateValue,
|
||||
render: (value): JSX.Element =>
|
||||
getDateValue(value, formatTimezoneAdjustedTimestamp),
|
||||
sorter: true,
|
||||
defaultSortOrder: getDefaultOrder(
|
||||
getUpdatedParams,
|
||||
@@ -355,7 +367,8 @@ function AllErrors(): JSX.Element {
|
||||
dataIndex: 'firstSeen',
|
||||
width: 80,
|
||||
key: 'firstSeen',
|
||||
render: getDateValue,
|
||||
render: (value): JSX.Element =>
|
||||
getDateValue(value, formatTimezoneAdjustedTimestamp),
|
||||
sorter: true,
|
||||
defaultSortOrder: getDefaultOrder(
|
||||
getUpdatedParams,
|
||||
|
||||
@@ -10,6 +10,7 @@ import getAxes from 'lib/uPlotLib/utils/getAxes';
|
||||
import { getUplotChartDataForAnomalyDetection } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
import { getYAxisScaleForAnomalyDetection } from 'lib/uPlotLib/utils/getYAxisScale';
|
||||
import { LineChart } from 'lucide-react';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
@@ -148,10 +149,12 @@ function AnomalyAlertEvaluationView({
|
||||
]
|
||||
: [];
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const options = {
|
||||
width: dimensions.width,
|
||||
height: dimensions.height - 36,
|
||||
plugins: [bandsPlugin, tooltipPlugin(isDarkMode)],
|
||||
plugins: [bandsPlugin, tooltipPlugin(isDarkMode, timezone?.value)],
|
||||
focus: {
|
||||
alpha: 0.3,
|
||||
},
|
||||
@@ -256,6 +259,8 @@ function AnomalyAlertEvaluationView({
|
||||
show: true,
|
||||
},
|
||||
axes: getAxes(isDarkMode, yAxisUnit),
|
||||
tzDate: (timestamp: number): Date =>
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone?.value),
|
||||
};
|
||||
|
||||
const handleSearch = (searchText: string): void => {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { themeColors } from 'constants/theme';
|
||||
import dayjs from 'dayjs';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
|
||||
const tooltipPlugin = (
|
||||
isDarkMode: boolean,
|
||||
timezone: string,
|
||||
): { hooks: { init: (u: any) => void } } => {
|
||||
let tooltip: HTMLDivElement;
|
||||
const tooltipLeftOffset = 10;
|
||||
@@ -17,7 +19,7 @@ const tooltipPlugin = (
|
||||
return value.toFixed(3);
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toLocaleString();
|
||||
return dayjs(value).tz(timezone).format('MM/DD/YYYY, h:mm:ss A');
|
||||
}
|
||||
if (value == null) {
|
||||
return 'N/A';
|
||||
|
||||
@@ -57,6 +57,7 @@ export const alertDefaults: AlertDef = {
|
||||
},
|
||||
annotations: defaultAnnotations,
|
||||
evalWindow: defaultEvalWindow,
|
||||
alert: '',
|
||||
};
|
||||
|
||||
export const anamolyAlertDefaults: AlertDef = {
|
||||
@@ -101,6 +102,7 @@ export const anamolyAlertDefaults: AlertDef = {
|
||||
},
|
||||
annotations: defaultAnnotations,
|
||||
evalWindow: defaultEvalWindow,
|
||||
alert: '',
|
||||
};
|
||||
|
||||
export const logAlertDefaults: AlertDef = {
|
||||
@@ -132,6 +134,7 @@ export const logAlertDefaults: AlertDef = {
|
||||
},
|
||||
annotations: defaultAnnotations,
|
||||
evalWindow: defaultEvalWindow,
|
||||
alert: '',
|
||||
};
|
||||
|
||||
export const traceAlertDefaults: AlertDef = {
|
||||
@@ -163,6 +166,7 @@ export const traceAlertDefaults: AlertDef = {
|
||||
},
|
||||
annotations: defaultAnnotations,
|
||||
evalWindow: defaultEvalWindow,
|
||||
alert: '',
|
||||
};
|
||||
|
||||
export const exceptionAlertDefaults: AlertDef = {
|
||||
@@ -194,6 +198,7 @@ export const exceptionAlertDefaults: AlertDef = {
|
||||
},
|
||||
annotations: defaultAnnotations,
|
||||
evalWindow: defaultEvalWindow,
|
||||
alert: '',
|
||||
};
|
||||
|
||||
export const ALERTS_VALUES_MAP: Record<AlertTypes, AlertDef> = {
|
||||
|
||||
@@ -6,12 +6,12 @@ import getNextPrevId from 'api/errors/getNextPrevId';
|
||||
import Editor from 'components/Editor';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { getNanoSeconds } from 'container/AllError/utils';
|
||||
import dayjs from 'dayjs';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import history from 'lib/history';
|
||||
import { isUndefined } from 'lodash-es';
|
||||
import { urlKey } from 'pages/ErrorDetails/utils';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from 'react-query';
|
||||
@@ -103,8 +103,6 @@ function ErrorDetails(props: ErrorDetailsProps): JSX.Element {
|
||||
}
|
||||
};
|
||||
|
||||
const timeStamp = dayjs(errorDetail.timestamp);
|
||||
|
||||
const data: { key: string; value: string }[] = Object.keys(errorDetail)
|
||||
.filter((e) => !keyToExclude.includes(e))
|
||||
.map((key) => ({
|
||||
@@ -136,6 +134,8 @@ function ErrorDetails(props: ErrorDetailsProps): JSX.Element {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data]);
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography>{errorDetail.exceptionType}</Typography>
|
||||
@@ -145,7 +145,12 @@ function ErrorDetails(props: ErrorDetailsProps): JSX.Element {
|
||||
<EventContainer>
|
||||
<div>
|
||||
<Typography>Event {errorDetail.errorId}</Typography>
|
||||
<Typography>{timeStamp.format('MMM DD YYYY hh:mm:ss A')}</Typography>
|
||||
<Typography>
|
||||
{formatTimezoneAdjustedTimestamp(
|
||||
errorDetail.timestamp,
|
||||
'DD/MM/YYYY hh:mm:ss A (UTC Z)',
|
||||
)}
|
||||
</Typography>
|
||||
</div>
|
||||
<div>
|
||||
<Space align="end" direction="horizontal">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Color, ColorType } from '@signozhq/design-tokens';
|
||||
import { showErrorNotification } from 'components/ExplorerCard/utils';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
@@ -8,7 +8,7 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SaveNewViewHandlerProps } from './types';
|
||||
|
||||
export const getRandomColor = (): Color => {
|
||||
export const getRandomColor = (): ColorType => {
|
||||
const colorKeys = Object.keys(Color) as (keyof typeof Color)[];
|
||||
const randomKey = colorKeys[Math.floor(Math.random() * colorKeys.length)];
|
||||
return Color[randomKey];
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
|
||||
import ROUTES from 'constants/routes';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
@@ -83,16 +83,22 @@ function BasicInfo({
|
||||
window.open(ROUTES.CHANNELS_NEW, '_blank');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
const hasLoggedEvent = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!channels.loading && isNewRule) {
|
||||
if (!channels.loading && isNewRule && !hasLoggedEvent.current) {
|
||||
logEvent('Alert: New alert creation page visited', {
|
||||
dataSource: ALERTS_DATA_SOURCE_MAP[alertDef?.alertType as AlertTypes],
|
||||
numberOfChannels: channels?.payload?.length,
|
||||
});
|
||||
hasLoggedEvent.current = true;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [channels.payload, channels.loading]);
|
||||
}, [channels.loading]);
|
||||
|
||||
const refetchChannels = async (): Promise<void> => {
|
||||
await channels.refetch();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -197,7 +203,7 @@ function BasicInfo({
|
||||
{!shouldBroadCastToAllChannels && (
|
||||
<Tooltip
|
||||
title={
|
||||
noChannels
|
||||
noChannels && !addNewChannelPermission
|
||||
? 'No channels. Ask an admin to create a notification channel'
|
||||
: undefined
|
||||
}
|
||||
@@ -212,10 +218,10 @@ function BasicInfo({
|
||||
]}
|
||||
>
|
||||
<ChannelSelect
|
||||
disabled={
|
||||
shouldBroadCastToAllChannels || noChannels || !!channels.loading
|
||||
}
|
||||
onDropdownOpen={refetchChannels}
|
||||
disabled={shouldBroadCastToAllChannels}
|
||||
currentValue={alertDef.preferredChannels}
|
||||
handleCreateNewChannels={handleCreateNewChannels}
|
||||
channels={channels}
|
||||
onSelectChannels={(preferredChannels): void => {
|
||||
setAlertDef({
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
import { Select } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { Select, Spin } from 'antd';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { State } from 'hooks/useFetch';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { PayloadProps } from 'types/api/channels/getAll';
|
||||
import AppReducer from 'types/reducer/app';
|
||||
|
||||
import { StyledSelect } from './styles';
|
||||
import { StyledCreateChannelOption, StyledSelect } from './styles';
|
||||
|
||||
export interface ChannelSelectProps {
|
||||
disabled?: boolean;
|
||||
currentValue?: string[];
|
||||
onSelectChannels: (s: string[]) => void;
|
||||
onDropdownOpen: () => void;
|
||||
channels: State<PayloadProps | undefined>;
|
||||
handleCreateNewChannels: () => void;
|
||||
}
|
||||
|
||||
function ChannelSelect({
|
||||
disabled,
|
||||
currentValue,
|
||||
onSelectChannels,
|
||||
onDropdownOpen,
|
||||
channels,
|
||||
handleCreateNewChannels,
|
||||
}: ChannelSelectProps): JSX.Element | null {
|
||||
// init namespace for translations
|
||||
const { t } = useTranslation('alerts');
|
||||
@@ -26,6 +35,10 @@ function ChannelSelect({
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const handleChange = (value: string[]): void => {
|
||||
if (value.includes('add-new-channel')) {
|
||||
handleCreateNewChannels();
|
||||
return;
|
||||
}
|
||||
onSelectChannels(value);
|
||||
};
|
||||
|
||||
@@ -35,9 +48,27 @@ function ChannelSelect({
|
||||
description: channels.errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
const { role } = useSelector<AppState, AppReducer>((state) => state.app);
|
||||
const [addNewChannelPermission] = useComponentPermission(
|
||||
['add_new_channel'],
|
||||
role,
|
||||
);
|
||||
|
||||
const renderOptions = (): ReactNode[] => {
|
||||
const children: ReactNode[] = [];
|
||||
|
||||
if (!channels.loading && addNewChannelPermission) {
|
||||
children.push(
|
||||
<Select.Option key="add-new-channel" value="add-new-channel">
|
||||
<StyledCreateChannelOption>
|
||||
<PlusOutlined />
|
||||
Create a new channel
|
||||
</StyledCreateChannelOption>
|
||||
</Select.Option>,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
channels.loading ||
|
||||
channels.payload === undefined ||
|
||||
@@ -56,6 +87,7 @@ function ChannelSelect({
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledSelect
|
||||
disabled={disabled}
|
||||
@@ -65,6 +97,12 @@ function ChannelSelect({
|
||||
placeholder={t('placeholder_channel_select')}
|
||||
data-testid="alert-channel-select"
|
||||
value={currentValue}
|
||||
notFoundContent={channels.loading && <Spin size="small" />}
|
||||
onDropdownVisibleChange={(open): void => {
|
||||
if (open) {
|
||||
onDropdownOpen();
|
||||
}
|
||||
}}
|
||||
onChange={(value): void => {
|
||||
handleChange(value as string[]);
|
||||
}}
|
||||
|
||||
@@ -4,3 +4,10 @@ import styled from 'styled-components';
|
||||
export const StyledSelect = styled(Select)`
|
||||
border-radius: 4px;
|
||||
`;
|
||||
|
||||
export const StyledCreateChannelOption = styled.div`
|
||||
color: var(--bg-robin-500);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
@@ -25,6 +25,7 @@ import getTimeString from 'lib/getTimeString';
|
||||
import history from 'lib/history';
|
||||
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
|
||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
@@ -35,6 +36,7 @@ import { AlertDef } from 'types/api/alerts/def';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import uPlot from 'uplot';
|
||||
import { getGraphType } from 'utils/getGraphType';
|
||||
import { getSortedSeriesData } from 'utils/getSortedSeriesData';
|
||||
import { getTimeRange } from 'utils/getTimeRange';
|
||||
@@ -201,6 +203,8 @@ function ChartPreview({
|
||||
[dispatch, location.pathname, urlQuery],
|
||||
);
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
getUPlotChartOptions({
|
||||
@@ -236,6 +240,9 @@ function ChartPreview({
|
||||
softMax: null,
|
||||
softMin: null,
|
||||
panelType: graphType,
|
||||
tzDate: (timestamp: number) =>
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone?.value),
|
||||
timezone: timezone?.value,
|
||||
}),
|
||||
[
|
||||
yAxisUnit,
|
||||
@@ -250,6 +257,7 @@ function ChartPreview({
|
||||
optionName,
|
||||
alertDef?.condition.targetUnit,
|
||||
graphType,
|
||||
timezone?.value,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -102,9 +102,9 @@ function RuleOptions({
|
||||
<Select.Option value="4">{t('option_notequal')}</Select.Option>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* the value 5 and 6 are reserved for above or equal and below or equal */}
|
||||
{ruleType === 'anomaly_rule' && (
|
||||
<Select.Option value="5">{t('option_above_below')}</Select.Option>
|
||||
<Select.Option value="7">{t('option_above_below')}</Select.Option>
|
||||
)}
|
||||
</InlineSelect>
|
||||
);
|
||||
@@ -181,18 +181,8 @@ function RuleOptions({
|
||||
<Select.Option value="10m0s">{t('option_10min')}</Select.Option>
|
||||
<Select.Option value="15m0s">{t('option_15min')}</Select.Option>
|
||||
<Select.Option value="1h0m0s">{t('option_60min')}</Select.Option>
|
||||
<Select.Option value="2h0m0s">{t('option_2hours')}</Select.Option>
|
||||
<Select.Option value="3h0m0s">{t('option_3hours')}</Select.Option>
|
||||
<Select.Option value="4h0m0s">{t('option_4hours')}</Select.Option>
|
||||
<Select.Option value="5h0m0s">{t('option_5hours')}</Select.Option>
|
||||
<Select.Option value="6h0m0s">{t('option_6hours')}</Select.Option>
|
||||
<Select.Option value="8h0m0s">{t('option_8hours')}</Select.Option>
|
||||
<Select.Option value="10h0m0s">{t('option_10hours')}</Select.Option>
|
||||
<Select.Option value="12h0m0s">{t('option_12hours')}</Select.Option>
|
||||
<Select.Option value="24h0m0s">{t('option_24hours')}</Select.Option>
|
||||
<Select.Option value="48h0m0s">{t('option_48hours')}</Select.Option>
|
||||
<Select.Option value="72h0m0s">{t('option_72hours')}</Select.Option>
|
||||
<Select.Option value="168h0m0s">{t('option_1week')}</Select.Option>
|
||||
</InlineSelect>
|
||||
);
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
QueryFunctionProps,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import BasicInfo from './BasicInfo';
|
||||
@@ -105,6 +106,11 @@ function FormAlertRules({
|
||||
const location = useLocation();
|
||||
const queryParams = new URLSearchParams(location.search);
|
||||
|
||||
const dataSource = useMemo(
|
||||
() => urlQuery.get(QueryParams.alertType) as DataSource,
|
||||
[urlQuery],
|
||||
);
|
||||
|
||||
// In case of alert the panel types should always be "Graph" only
|
||||
const panelType = PANEL_TYPES.TIME_SERIES;
|
||||
|
||||
@@ -114,13 +120,12 @@ function FormAlertRules({
|
||||
handleSetQueryData,
|
||||
handleRunQuery,
|
||||
handleSetConfig,
|
||||
initialDataSource,
|
||||
redirectWithQueryBuilderData,
|
||||
} = useQueryBuilder();
|
||||
|
||||
useEffect(() => {
|
||||
handleSetConfig(panelType || PANEL_TYPES.TIME_SERIES, initialDataSource);
|
||||
}, [handleSetConfig, initialDataSource, panelType]);
|
||||
handleSetConfig(panelType || PANEL_TYPES.TIME_SERIES, dataSource);
|
||||
}, [handleSetConfig, dataSource, panelType]);
|
||||
|
||||
// use query client
|
||||
const ruleCache = useQueryClient();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Popover, Typography } from 'antd';
|
||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||
import dayjs from 'dayjs';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { useEffect } from 'react';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
@@ -32,13 +33,17 @@ function Span(props: SpanLengthProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { time, timeUnitName } = convertTimeToRelevantUnit(inMsCount);
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.scrollTop = document.documentElement.clientHeight;
|
||||
document.documentElement.scrollLeft = document.documentElement.clientWidth;
|
||||
}, []);
|
||||
|
||||
const getContent = (): JSX.Element => {
|
||||
const timeStamp = dayjs(startTime).format('h:mm:ss:SSS A');
|
||||
const timeStamp = dayjs(startTime)
|
||||
.tz(timezone.value)
|
||||
.format('h:mm:ss:SSS A (UTC Z)');
|
||||
const startTimeInMs = startTime - globalStart;
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -18,8 +18,13 @@
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 28px;
|
||||
/* 155.556% */
|
||||
letter-spacing: -0.09px;
|
||||
|
||||
width: 72%; // arbitrary number to match input width
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
@@ -356,6 +361,8 @@
|
||||
flex: 1;
|
||||
|
||||
.heading {
|
||||
margin-bottom: 8px;
|
||||
|
||||
.title {
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -370,6 +377,18 @@
|
||||
.ant-input-number {
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.no-limit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
margin-bottom: 24px;
|
||||
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
color: var(--bg-forest-400);
|
||||
}
|
||||
}
|
||||
|
||||
.signal-limit-view-mode {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Modal,
|
||||
Row,
|
||||
Select,
|
||||
Switch,
|
||||
Table,
|
||||
TablePaginationConfig,
|
||||
TableProps as AntDTableProps,
|
||||
@@ -30,11 +31,11 @@ import { AxiosError } from 'axios';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import Tags from 'components/Tags/Tags';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import dayjs from 'dayjs';
|
||||
import { useGetAllIngestionsKeys } from 'hooks/IngestionKeys/useGetAllIngestionKeys';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { isNil } from 'lodash-es';
|
||||
import { isNil, isUndefined } from 'lodash-es';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
CalendarClock,
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { ChangeEvent, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation } from 'react-query';
|
||||
@@ -69,7 +71,10 @@ const { Option } = Select;
|
||||
|
||||
const BYTES = 1073741824;
|
||||
|
||||
export const disabledDate = (current: Dayjs): boolean =>
|
||||
// Using any type here because antd's DatePicker expects its own internal Dayjs type
|
||||
// which conflicts with our project's Dayjs type that has additional plugins (tz, utc etc).
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
|
||||
export const disabledDate = (current: any): boolean =>
|
||||
// Disable all dates before today
|
||||
current && current < dayjs().endOf('day');
|
||||
|
||||
@@ -392,86 +397,11 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
|
||||
const gbToBytes = (gb: number): number => Math.round(gb * 1024 ** 3);
|
||||
|
||||
const getFormattedTime = (date: string): string =>
|
||||
dayjs(date).format('MMM DD,YYYY, hh:mm a');
|
||||
|
||||
const handleAddLimit = (
|
||||
APIKey: IngestionKeyProps,
|
||||
signalName: string,
|
||||
): void => {
|
||||
setActiveSignal({
|
||||
id: signalName,
|
||||
signal: signalName,
|
||||
config: {},
|
||||
});
|
||||
|
||||
const { dailyLimit, secondsLimit } = addEditLimitForm.getFieldsValue();
|
||||
|
||||
const payload = {
|
||||
keyID: APIKey.id,
|
||||
signal: signalName,
|
||||
config: {
|
||||
day: {
|
||||
size: gbToBytes(dailyLimit),
|
||||
},
|
||||
second: {
|
||||
size: gbToBytes(secondsLimit),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
createLimitForIngestionKey(payload);
|
||||
};
|
||||
|
||||
const handleUpdateLimit = (
|
||||
APIKey: IngestionKeyProps,
|
||||
signal: LimitProps,
|
||||
): void => {
|
||||
setActiveSignal(signal);
|
||||
const { dailyLimit, secondsLimit } = addEditLimitForm.getFieldsValue();
|
||||
const payload = {
|
||||
limitID: signal.id,
|
||||
signal: signal.signal,
|
||||
config: {
|
||||
day: {
|
||||
size: gbToBytes(dailyLimit),
|
||||
},
|
||||
second: {
|
||||
size: gbToBytes(secondsLimit),
|
||||
},
|
||||
},
|
||||
};
|
||||
updateLimitForIngestionKey(payload);
|
||||
};
|
||||
|
||||
const bytesToGb = (size: number | undefined): number => {
|
||||
if (!size) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return size / BYTES;
|
||||
};
|
||||
|
||||
const enableEditLimitMode = (
|
||||
APIKey: IngestionKeyProps,
|
||||
signal: LimitProps,
|
||||
): void => {
|
||||
setActiveAPIKey(APIKey);
|
||||
setActiveSignal(signal);
|
||||
|
||||
addEditLimitForm.setFieldsValue({
|
||||
dailyLimit: bytesToGb(signal?.config?.day?.size || 0),
|
||||
secondsLimit: bytesToGb(signal?.config?.second?.size || 0),
|
||||
});
|
||||
|
||||
setIsEditAddLimitOpen(true);
|
||||
};
|
||||
|
||||
const onDeleteLimitHandler = (): void => {
|
||||
if (activeSignal && activeSignal?.id) {
|
||||
deleteLimitForKey(activeSignal.id);
|
||||
}
|
||||
};
|
||||
const getFormattedTime = (
|
||||
date: string,
|
||||
formatTimezoneAdjustedTimestamp: (date: string, format: string) => string,
|
||||
): string =>
|
||||
formatTimezoneAdjustedTimestamp(date, 'MMM DD,YYYY, hh:mm a (UTC Z)');
|
||||
|
||||
const showDeleteLimitModal = (
|
||||
APIKey: IngestionKeyProps,
|
||||
@@ -496,17 +426,152 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
addEditLimitForm.resetFields();
|
||||
};
|
||||
|
||||
const handleAddLimit = (
|
||||
APIKey: IngestionKeyProps,
|
||||
signalName: string,
|
||||
): void => {
|
||||
const { dailyLimit, secondsLimit } = addEditLimitForm.getFieldsValue();
|
||||
|
||||
const payload = {
|
||||
keyID: APIKey.id,
|
||||
signal: signalName,
|
||||
config: {},
|
||||
};
|
||||
|
||||
if (!isUndefined(dailyLimit)) {
|
||||
payload.config = {
|
||||
day: {
|
||||
size: gbToBytes(dailyLimit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!isUndefined(secondsLimit)) {
|
||||
payload.config = {
|
||||
...payload.config,
|
||||
second: {
|
||||
size: gbToBytes(secondsLimit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (isUndefined(dailyLimit) && isUndefined(secondsLimit)) {
|
||||
// No need to save as no limit is provided, close the edit view and reset active signal and api key
|
||||
setActiveSignal(null);
|
||||
setActiveAPIKey(null);
|
||||
setIsEditAddLimitOpen(false);
|
||||
setUpdatedTags([]);
|
||||
hideAddViewModal();
|
||||
setHasCreateLimitForIngestionKeyError(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
createLimitForIngestionKey(payload);
|
||||
};
|
||||
|
||||
const handleUpdateLimit = (
|
||||
APIKey: IngestionKeyProps,
|
||||
signal: LimitProps,
|
||||
): void => {
|
||||
const { dailyLimit, secondsLimit } = addEditLimitForm.getFieldsValue();
|
||||
const payload = {
|
||||
limitID: signal.id,
|
||||
signal: signal.signal,
|
||||
config: {},
|
||||
};
|
||||
|
||||
if (isUndefined(dailyLimit) && isUndefined(secondsLimit)) {
|
||||
showDeleteLimitModal(APIKey, signal);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isUndefined(dailyLimit)) {
|
||||
payload.config = {
|
||||
day: {
|
||||
size: gbToBytes(dailyLimit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!isUndefined(secondsLimit)) {
|
||||
payload.config = {
|
||||
...payload.config,
|
||||
second: {
|
||||
size: gbToBytes(secondsLimit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
updateLimitForIngestionKey(payload);
|
||||
};
|
||||
|
||||
const bytesToGb = (size: number | undefined): number => {
|
||||
if (!size) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return size / BYTES;
|
||||
};
|
||||
|
||||
const enableEditLimitMode = (
|
||||
APIKey: IngestionKeyProps,
|
||||
signal: LimitProps,
|
||||
): void => {
|
||||
setActiveAPIKey(APIKey);
|
||||
setActiveSignal({
|
||||
...signal,
|
||||
config: {
|
||||
...signal.config,
|
||||
day: {
|
||||
...signal.config?.day,
|
||||
enabled: !isNil(signal?.config?.day?.size),
|
||||
},
|
||||
second: {
|
||||
...signal.config?.second,
|
||||
enabled: !isNil(signal?.config?.second?.size),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
addEditLimitForm.setFieldsValue({
|
||||
dailyLimit: bytesToGb(signal?.config?.day?.size || 0),
|
||||
secondsLimit: bytesToGb(signal?.config?.second?.size || 0),
|
||||
enableDailyLimit: !isNil(signal?.config?.day?.size),
|
||||
enableSecondLimit: !isNil(signal?.config?.second?.size),
|
||||
});
|
||||
|
||||
setIsEditAddLimitOpen(true);
|
||||
};
|
||||
|
||||
const onDeleteLimitHandler = (): void => {
|
||||
if (activeSignal && activeSignal?.id) {
|
||||
deleteLimitForKey(activeSignal.id);
|
||||
}
|
||||
};
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const columns: AntDTableProps<IngestionKeyProps>['columns'] = [
|
||||
{
|
||||
title: 'Ingestion Key',
|
||||
key: 'ingestion-key',
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
render: (APIKey: IngestionKeyProps): JSX.Element => {
|
||||
const createdOn = getFormattedTime(APIKey.created_at);
|
||||
const createdOn = getFormattedTime(
|
||||
APIKey.created_at,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
);
|
||||
const formattedDateAndTime =
|
||||
APIKey && APIKey?.expires_at && getFormattedTime(APIKey?.expires_at);
|
||||
APIKey &&
|
||||
APIKey?.expires_at &&
|
||||
getFormattedTime(APIKey?.expires_at, formatTimezoneAdjustedTimestamp);
|
||||
|
||||
const updatedOn = getFormattedTime(APIKey?.updated_at);
|
||||
const updatedOn = getFormattedTime(
|
||||
APIKey?.updated_at,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
);
|
||||
|
||||
const limits: { [key: string]: LimitProps } = {};
|
||||
|
||||
@@ -684,50 +749,108 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
<div className="signal-limit-edit-mode">
|
||||
<div className="daily-limit">
|
||||
<div className="heading">
|
||||
<div className="title"> Daily limit </div>
|
||||
<div className="title">
|
||||
Daily limit
|
||||
<div className="limit-enable-disable-toggle">
|
||||
<Form.Item name="enableDailyLimit">
|
||||
<Switch
|
||||
size="small"
|
||||
checked={activeSignal?.config?.day?.enabled}
|
||||
onChange={(value): void => {
|
||||
setActiveSignal({
|
||||
...activeSignal,
|
||||
config: {
|
||||
...activeSignal.config,
|
||||
day: {
|
||||
...activeSignal.config?.day,
|
||||
enabled: value,
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
<div className="subtitle">
|
||||
Add a limit for data ingested daily{' '}
|
||||
Add a limit for data ingested daily
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="size">
|
||||
<Form.Item name="dailyLimit">
|
||||
<InputNumber
|
||||
addonAfter={
|
||||
<Select defaultValue="GiB" disabled>
|
||||
<Option value="TiB"> TiB</Option>
|
||||
<Option value="GiB"> GiB</Option>
|
||||
<Option value="MiB"> MiB </Option>
|
||||
<Option value="KiB"> KiB </Option>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
{activeSignal?.config?.day?.enabled ? (
|
||||
<Form.Item name="dailyLimit" key="dailyLimit">
|
||||
<InputNumber
|
||||
disabled={!activeSignal?.config?.day?.enabled}
|
||||
key="dailyLimit"
|
||||
addonAfter={
|
||||
<Select defaultValue="GiB" disabled>
|
||||
<Option value="TiB"> TiB</Option>
|
||||
<Option value="GiB"> GiB</Option>
|
||||
<Option value="MiB"> MiB </Option>
|
||||
<Option value="KiB"> KiB </Option>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="no-limit">
|
||||
<Infinity size={16} /> NO LIMIT
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="second-limit">
|
||||
<div className="heading">
|
||||
<div className="title"> Per Second limit </div>
|
||||
<div className="title">
|
||||
Per Second limit{' '}
|
||||
<div className="limit-enable-disable-toggle">
|
||||
<Form.Item name="enableSecondLimit">
|
||||
<Switch
|
||||
size="small"
|
||||
checked={activeSignal?.config?.second?.enabled}
|
||||
onChange={(value): void => {
|
||||
setActiveSignal({
|
||||
...activeSignal,
|
||||
config: {
|
||||
...activeSignal.config,
|
||||
second: {
|
||||
...activeSignal.config?.second,
|
||||
enabled: value,
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
<div className="subtitle">
|
||||
{' '}
|
||||
Add a limit for data ingested every second{' '}
|
||||
Add a limit for data ingested every second
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="size">
|
||||
<Form.Item name="secondsLimit">
|
||||
<InputNumber
|
||||
addonAfter={
|
||||
<Select defaultValue="GiB" disabled>
|
||||
<Option value="TiB"> TiB</Option>
|
||||
<Option value="GiB"> GiB</Option>
|
||||
<Option value="MiB"> MiB </Option>
|
||||
<Option value="KiB"> KiB </Option>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
{activeSignal?.config?.second?.enabled ? (
|
||||
<Form.Item name="secondsLimit" key="secondsLimit">
|
||||
<InputNumber
|
||||
key="secondsLimit"
|
||||
disabled={!activeSignal?.config?.second?.enabled}
|
||||
addonAfter={
|
||||
<Select defaultValue="GiB" disabled>
|
||||
<Option value="TiB"> TiB</Option>
|
||||
<Option value="GiB"> GiB</Option>
|
||||
<Option value="MiB"> MiB </Option>
|
||||
<Option value="KiB"> KiB </Option>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="no-limit">
|
||||
<Infinity size={16} /> NO LIMIT
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import { Typography } from 'antd';
|
||||
import { ColumnsType } from 'antd/lib/table';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { License } from 'types/api/licenses/def';
|
||||
|
||||
function ValidityColumn({ value }: { value: string }): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
return (
|
||||
<Typography>
|
||||
{formatTimezoneAdjustedTimestamp(value, 'YYYY-MM-DD HH:mm:ss (UTC Z)')}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
function ListLicenses({ licenses }: ListLicensesProps): JSX.Element {
|
||||
const { t } = useTranslation(['licenses']);
|
||||
|
||||
@@ -23,12 +35,14 @@ function ListLicenses({ licenses }: ListLicensesProps): JSX.Element {
|
||||
title: t('column_valid_from'),
|
||||
dataIndex: 'ValidFrom',
|
||||
key: 'valid from',
|
||||
render: (value: string): JSX.Element => ValidityColumn({ value }),
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: t('column_valid_until'),
|
||||
dataIndex: 'ValidUntil',
|
||||
key: 'valid until',
|
||||
render: (value: string): JSX.Element => ValidityColumn({ value }),
|
||||
width: 80,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
// see more: https://github.com/lucide-icons/lucide/issues/94
|
||||
import { handleContactSupport } from 'pages/Integrations/utils';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import {
|
||||
ChangeEvent,
|
||||
Key,
|
||||
@@ -343,31 +344,13 @@ function DashboardsList(): JSX.Element {
|
||||
}
|
||||
}, [state.error, state.value, t, notifications]);
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
function getFormattedTime(dashboard: Dashboard, option: string): string {
|
||||
const timeOptions: Intl.DateTimeFormatOptions = {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
};
|
||||
const formattedTime = new Date(get(dashboard, option, '')).toLocaleTimeString(
|
||||
'en-US',
|
||||
timeOptions,
|
||||
return formatTimezoneAdjustedTimestamp(
|
||||
get(dashboard, option, ''),
|
||||
'MMM D, YYYY ⎯ HH:mm:ss',
|
||||
);
|
||||
|
||||
const dateOptions: Intl.DateTimeFormatOptions = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
};
|
||||
|
||||
const formattedDate = new Date(get(dashboard, option, '')).toLocaleDateString(
|
||||
'en-US',
|
||||
dateOptions,
|
||||
);
|
||||
|
||||
// Combine time and date
|
||||
return `${formattedDate} ⎯ ${formattedTime}`;
|
||||
}
|
||||
|
||||
const onLastUpdated = (time: string): string => {
|
||||
@@ -410,31 +393,11 @@ function DashboardsList(): JSX.Element {
|
||||
title: 'Dashboards',
|
||||
key: 'dashboard',
|
||||
render: (dashboard: Data, _, index): JSX.Element => {
|
||||
const timeOptions: Intl.DateTimeFormatOptions = {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
};
|
||||
const formattedTime = new Date(dashboard.createdAt).toLocaleTimeString(
|
||||
'en-US',
|
||||
timeOptions,
|
||||
const formattedDateAndTime = formatTimezoneAdjustedTimestamp(
|
||||
dashboard.createdAt,
|
||||
'MMM D, YYYY ⎯ HH:mm:ss',
|
||||
);
|
||||
|
||||
const dateOptions: Intl.DateTimeFormatOptions = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
};
|
||||
|
||||
const formattedDate = new Date(dashboard.createdAt).toLocaleDateString(
|
||||
'en-US',
|
||||
dateOptions,
|
||||
);
|
||||
|
||||
// Combine time and date
|
||||
const formattedDateAndTime = `${formattedDate} ⎯ ${formattedTime}`;
|
||||
|
||||
const getLink = (): string => `${ROUTES.ALL_DASHBOARD}/${dashboard.id}`;
|
||||
|
||||
const onClickHandler = (event: React.MouseEvent<HTMLElement>): void => {
|
||||
|
||||
@@ -82,9 +82,8 @@ function ImportJSON({
|
||||
|
||||
const dashboardData = JSON.parse(editorValue) as DashboardData;
|
||||
|
||||
// Add validation for uuid
|
||||
if (dashboardData.uuid !== undefined && dashboardData.uuid.trim() === '') {
|
||||
// silently remove uuid if it is empty
|
||||
// Remove uuid from the dashboard data, in all cases - empty, duplicate or any valid not duplicate uuid
|
||||
if (dashboardData.uuid !== undefined) {
|
||||
delete dashboardData.uuid;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,10 +8,12 @@ import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
|
||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useQueries, UseQueryResult } from 'react-query';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import {
|
||||
getHostQueryPayload,
|
||||
@@ -73,6 +75,8 @@ function NodeMetrics({
|
||||
[queries],
|
||||
);
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
queries.map(({ data }, idx) =>
|
||||
@@ -86,6 +90,9 @@ function NodeMetrics({
|
||||
minTimeScale: start,
|
||||
maxTimeScale: end,
|
||||
verticalLineTimestamp,
|
||||
tzDate: (timestamp: number) =>
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone?.value),
|
||||
timezone: timezone?.value,
|
||||
}),
|
||||
),
|
||||
[
|
||||
@@ -96,6 +103,7 @@ function NodeMetrics({
|
||||
start,
|
||||
verticalLineTimestamp,
|
||||
end,
|
||||
timezone?.value,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -8,10 +8,12 @@ import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
|
||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useQueries, UseQueryResult } from 'react-query';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { getPodQueryPayload, podWidgetInfo } from './constants';
|
||||
|
||||
@@ -60,6 +62,7 @@ function PodMetrics({
|
||||
() => queries.map(({ data }) => getUPlotChartData(data?.payload)),
|
||||
[queries],
|
||||
);
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
@@ -74,9 +77,20 @@ function PodMetrics({
|
||||
minTimeScale: start,
|
||||
maxTimeScale: end,
|
||||
verticalLineTimestamp,
|
||||
tzDate: (timestamp: number) =>
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone?.value),
|
||||
timezone: timezone?.value,
|
||||
}),
|
||||
),
|
||||
[queries, isDarkMode, dimensions, start, verticalLineTimestamp, end],
|
||||
[
|
||||
queries,
|
||||
isDarkMode,
|
||||
dimensions,
|
||||
start,
|
||||
end,
|
||||
verticalLineTimestamp,
|
||||
timezone?.value,
|
||||
],
|
||||
);
|
||||
|
||||
const renderCardContent = (
|
||||
|
||||
@@ -11,7 +11,8 @@ import ROUTES from 'constants/routes';
|
||||
import dompurify from 'dompurify';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { ArrowDownToDot, ArrowUpFromDot, Ellipsis } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { FORBID_DOM_PURIFY_TAGS } from 'utils/app';
|
||||
@@ -68,6 +69,8 @@ export function TableViewActions(
|
||||
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
if (record.field === 'body') {
|
||||
const parsedBody = recursiveParseJSON(fieldData.value);
|
||||
if (!isEmpty(parsedBody)) {
|
||||
@@ -100,33 +103,44 @@ export function TableViewActions(
|
||||
);
|
||||
}
|
||||
|
||||
let cleanTimestamp: string;
|
||||
if (record.field === 'timestamp') {
|
||||
cleanTimestamp = fieldData.value.replace(/^["']|["']$/g, '');
|
||||
}
|
||||
|
||||
const renderFieldContent = (): JSX.Element => {
|
||||
const commonStyles: React.CSSProperties = {
|
||||
color: Color.BG_SIENNA_400,
|
||||
whiteSpace: 'pre-wrap',
|
||||
tabSize: 4,
|
||||
};
|
||||
|
||||
switch (record.field) {
|
||||
case 'body':
|
||||
return <span style={commonStyles} dangerouslySetInnerHTML={bodyHtml} />;
|
||||
|
||||
case 'timestamp':
|
||||
return (
|
||||
<span style={commonStyles}>
|
||||
{formatTimezoneAdjustedTimestamp(
|
||||
cleanTimestamp,
|
||||
'MM/DD/YYYY, HH:mm:ss.SSS (UTC Z)',
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<span style={commonStyles}>{removeEscapeCharacters(fieldData.value)}</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cx('value-field', isOpen ? 'open-popover' : '')}>
|
||||
{record.field === 'body' ? (
|
||||
<CopyClipboardHOC entityKey={fieldFilterKey} textToCopy={textToCopy}>
|
||||
<span
|
||||
style={{
|
||||
color: Color.BG_SIENNA_400,
|
||||
whiteSpace: 'pre-wrap',
|
||||
tabSize: 4,
|
||||
}}
|
||||
dangerouslySetInnerHTML={bodyHtml}
|
||||
/>
|
||||
</CopyClipboardHOC>
|
||||
) : (
|
||||
<CopyClipboardHOC entityKey={fieldFilterKey} textToCopy={textToCopy}>
|
||||
<span
|
||||
style={{
|
||||
color: Color.BG_SIENNA_400,
|
||||
whiteSpace: 'pre-wrap',
|
||||
tabSize: 4,
|
||||
}}
|
||||
>
|
||||
{removeEscapeCharacters(fieldData.value)}
|
||||
</span>
|
||||
</CopyClipboardHOC>
|
||||
)}
|
||||
|
||||
<CopyClipboardHOC entityKey={fieldFilterKey} textToCopy={textToCopy}>
|
||||
{renderFieldContent()}
|
||||
</CopyClipboardHOC>
|
||||
{!isListViewPanel && (
|
||||
<span className="action-btn">
|
||||
<Tooltip title="Filter for value">
|
||||
|
||||
@@ -202,6 +202,7 @@ function LogsExplorerViews({
|
||||
id: 'severity_text--string----true',
|
||||
},
|
||||
],
|
||||
legend: '{{severity_text}}',
|
||||
};
|
||||
|
||||
const modifiedQuery: Query = {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useLogsData } from 'hooks/useLogsData';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { FlatLogData } from 'lib/logs/flatLogData';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import {
|
||||
Dispatch,
|
||||
HTMLAttributes,
|
||||
@@ -76,7 +77,12 @@ function LogsPanelComponent({
|
||||
});
|
||||
};
|
||||
|
||||
const columns = getLogPanelColumnsList(widget.selectedLogFields);
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const columns = getLogPanelColumnsList(
|
||||
widget.selectedLogFields,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
);
|
||||
|
||||
const dataLength =
|
||||
queryResponse.data?.payload?.data?.newResult?.data?.result[0]?.list?.length;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
import { Typography } from 'antd/lib';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
|
||||
// import Typography from 'antd/es/typography/Typography';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { ReactNode } from 'react';
|
||||
@@ -13,18 +14,31 @@ import { v4 as uuid } from 'uuid';
|
||||
|
||||
export const getLogPanelColumnsList = (
|
||||
selectedLogFields: Widgets['selectedLogFields'],
|
||||
formatTimezoneAdjustedTimestamp: (
|
||||
input: TimestampInput,
|
||||
format?: string,
|
||||
) => string,
|
||||
): ColumnsType<RowData> => {
|
||||
const initialColumns: ColumnsType<RowData> = [];
|
||||
|
||||
const columns: ColumnsType<RowData> =
|
||||
selectedLogFields?.map((field: IField) => {
|
||||
const { name } = field;
|
||||
|
||||
return {
|
||||
title: name,
|
||||
dataIndex: name,
|
||||
key: name,
|
||||
width: name === 'body' ? 350 : 100,
|
||||
render: (value: ReactNode): JSX.Element => {
|
||||
if (name === 'timestamp') {
|
||||
return (
|
||||
<Typography.Text>
|
||||
{formatTimezoneAdjustedTimestamp(value as string)}
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'body') {
|
||||
return (
|
||||
<Typography.Paragraph ellipsis={{ rows: 1 }} data-testid={name}>
|
||||
|
||||
@@ -58,12 +58,17 @@ export const databaseCallsRPS = ({
|
||||
const legends = [legend];
|
||||
const dataSource = DataSource.METRICS;
|
||||
|
||||
const timeAggregateOperators = [MetricAggregateOperator.RATE];
|
||||
const spaceAggregateOperators = [MetricAggregateOperator.SUM];
|
||||
|
||||
return getQueryBuilderQueries({
|
||||
autocompleteData,
|
||||
groupBy,
|
||||
legends,
|
||||
filterItems,
|
||||
dataSource,
|
||||
timeAggregateOperators,
|
||||
spaceAggregateOperators,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -213,12 +213,17 @@ export const externalCallRpsByAddress = ({
|
||||
const legends = [legend];
|
||||
const dataSource = DataSource.METRICS;
|
||||
|
||||
const timeAggregateOperators = [MetricAggregateOperator.RATE];
|
||||
const spaceAggregateOperators = [MetricAggregateOperator.SUM];
|
||||
|
||||
return getQueryBuilderQueries({
|
||||
autocompleteData,
|
||||
groupBy,
|
||||
legends,
|
||||
filterItems,
|
||||
dataSource,
|
||||
timeAggregateOperators,
|
||||
spaceAggregateOperators,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ export const getQueryBuilderQueries = ({
|
||||
aggregateOperator,
|
||||
dataSource,
|
||||
queryNameAndExpression,
|
||||
timeAggregateOperators,
|
||||
spaceAggregateOperators,
|
||||
}: BuilderQueriesProps): QueryBuilderData => ({
|
||||
queryFormulas: [],
|
||||
queryData: autocompleteData.map((item, index) => {
|
||||
@@ -50,6 +52,8 @@ export const getQueryBuilderQueries = ({
|
||||
op: 'AND',
|
||||
},
|
||||
reduceTo: 'avg',
|
||||
spaceAggregation: spaceAggregateOperators[index],
|
||||
timeAggregation: timeAggregateOperators[index],
|
||||
dataSource,
|
||||
};
|
||||
|
||||
|
||||
@@ -83,6 +83,17 @@ export const latency = ({
|
||||
const dataSource = isSpanMetricEnable ? DataSource.METRICS : DataSource.TRACES;
|
||||
const queryNameAndExpression = QUERYNAME_AND_EXPRESSION;
|
||||
|
||||
const timeAggregateOperators = [
|
||||
MetricAggregateOperator.EMPTY,
|
||||
MetricAggregateOperator.EMPTY,
|
||||
MetricAggregateOperator.EMPTY,
|
||||
];
|
||||
const spaceAggregateOperators = [
|
||||
MetricAggregateOperator.P50,
|
||||
MetricAggregateOperator.P90,
|
||||
MetricAggregateOperator.P99,
|
||||
];
|
||||
|
||||
return getQueryBuilderQueries({
|
||||
autocompleteData,
|
||||
legends,
|
||||
@@ -90,6 +101,8 @@ export const latency = ({
|
||||
aggregateOperator,
|
||||
dataSource,
|
||||
queryNameAndExpression,
|
||||
timeAggregateOperators,
|
||||
spaceAggregateOperators,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -510,11 +523,16 @@ export const operationPerSec = ({
|
||||
const legends = OPERATION_LEGENDS;
|
||||
const dataSource = DataSource.METRICS;
|
||||
|
||||
const timeAggregateOperators = [MetricAggregateOperator.RATE];
|
||||
const spaceAggregateOperators = [MetricAggregateOperator.SUM];
|
||||
|
||||
return getQueryBuilderQueries({
|
||||
autocompleteData,
|
||||
legends,
|
||||
filterItems,
|
||||
dataSource,
|
||||
timeAggregateOperators,
|
||||
spaceAggregateOperators,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface BuilderQueriesProps {
|
||||
aggregateOperator?: string[];
|
||||
dataSource: DataSource;
|
||||
queryNameAndExpression?: string[];
|
||||
timeAggregateOperators: MetricAggregateOperator[];
|
||||
spaceAggregateOperators: MetricAggregateOperator[];
|
||||
}
|
||||
|
||||
export interface BuilderQuerieswithFormulaProps {
|
||||
|
||||
@@ -2,18 +2,27 @@
|
||||
|
||||
import { DownloadOptions } from 'container/Download/Download.types';
|
||||
import { MenuItemKeys } from 'container/GridCardLayout/WidgetHeader/contants';
|
||||
import {
|
||||
MetricAggregateOperator,
|
||||
TracesAggregatorOperator,
|
||||
} from 'types/common/queryBuilder';
|
||||
|
||||
export const legend = {
|
||||
address: '{{address}}',
|
||||
};
|
||||
|
||||
export const QUERYNAME_AND_EXPRESSION = ['A', 'B', 'C'];
|
||||
export const LATENCY_AGGREGATEOPERATOR = ['p50', 'p90', 'p99'];
|
||||
export const LATENCY_AGGREGATEOPERATOR_SPAN_METRICS = [
|
||||
'hist_quantile_50',
|
||||
'hist_quantile_90',
|
||||
'hist_quantile_99',
|
||||
export const LATENCY_AGGREGATEOPERATOR = [
|
||||
TracesAggregatorOperator.P50,
|
||||
TracesAggregatorOperator.P90,
|
||||
TracesAggregatorOperator.P99,
|
||||
];
|
||||
export const LATENCY_AGGREGATEOPERATOR_SPAN_METRICS = [
|
||||
MetricAggregateOperator.P50,
|
||||
MetricAggregateOperator.P90,
|
||||
MetricAggregateOperator.P99,
|
||||
];
|
||||
|
||||
export const OPERATION_LEGENDS = ['Operations'];
|
||||
|
||||
export const MENU_ITEMS = [MenuItemKeys.View, MenuItemKeys.CreateAlerts];
|
||||
@@ -21,8 +30,21 @@ export const MENU_ITEMS = [MenuItemKeys.View, MenuItemKeys.CreateAlerts];
|
||||
export enum FORMULA {
|
||||
ERROR_PERCENTAGE = 'A*100/B',
|
||||
DATABASE_CALLS_AVG_DURATION = 'A/B',
|
||||
// The apdex formula is (satisfied_count + 0.5 * tolerating_count + 0 * frustating_count) / total_count
|
||||
// The satisfied_count is B, tolerating_count is C, total_count is A
|
||||
// But why do we have (B+C)/2 instead of B + C/2?
|
||||
// The way we issue the query is latency <= threshold, which means we over count i.e
|
||||
// query B => durationNano <= 500ms
|
||||
// query C => durationNano <= 2000ms
|
||||
// Since <= 2000ms includes <= 500ms, we over count, to correct we subtract B/2
|
||||
// so the full expression would be (B + C/2) - B/2 = (B+C)/2
|
||||
APDEX_TRACES = '((B + C)/2)/A',
|
||||
APDEX_DELTA_SPAN_METRICS = '((B + C)/2)/A',
|
||||
// Does the same not apply for delta span metrics?
|
||||
// No, because the delta metrics store the counts just for the current bucket
|
||||
// so we don't need to subtract anything
|
||||
APDEX_DELTA_SPAN_METRICS = '(B + C)/A',
|
||||
// Cumulative span metrics store the counts for all buckets
|
||||
// so we need to subtract B/2 to correct the over counting
|
||||
APDEX_CUMULATIVE_SPAN_METRICS = '((B + C)/2)/A',
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
.timezone-adaption {
|
||||
padding: 16px;
|
||||
background: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-ink-500);
|
||||
border-radius: 4px;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
color: var(--bg-vanilla-300);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__description {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
&__note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 7.5px 12px;
|
||||
background: rgba(78, 116, 248, 0.1);
|
||||
border: 1px solid rgba(78, 116, 248, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&__bullet {
|
||||
color: var(--bg-robin-400);
|
||||
font-size: 16px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&__note-text-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
&__note-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--bg-robin-400);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
&__note-text-overridden {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 2px;
|
||||
background: rgba(171, 189, 255, 0.04);
|
||||
border-radius: 2px;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
&__clear-override {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--bg-robin-300);
|
||||
font-size: 12px;
|
||||
line-height: 16px; /* 133.333% */
|
||||
letter-spacing: 0.12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import './TimezoneAdaptation.styles.scss';
|
||||
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Switch } from 'antd';
|
||||
import { Delete } from 'lucide-react';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
function TimezoneAdaptation(): JSX.Element {
|
||||
const { timezone, browserTimezone, updateTimezone } = useTimezone();
|
||||
|
||||
const isTimezoneOverridden = useMemo(
|
||||
() => timezone?.offset !== browserTimezone.offset,
|
||||
[timezone, browserTimezone],
|
||||
);
|
||||
|
||||
const [isAdaptationEnabled, setIsAdaptationEnabled] = useState(true);
|
||||
|
||||
const getSwitchStyles = (): React.CSSProperties => ({
|
||||
backgroundColor:
|
||||
isAdaptationEnabled && isTimezoneOverridden ? Color.BG_AMBER_400 : undefined,
|
||||
});
|
||||
|
||||
const handleOverrideClear = (): void => {
|
||||
updateTimezone(browserTimezone);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="timezone-adaption">
|
||||
<div className="timezone-adaption__header">
|
||||
<h2 className="timezone-adaption__title">Adapt to my timezone</h2>
|
||||
<Switch
|
||||
checked={isAdaptationEnabled}
|
||||
onChange={setIsAdaptationEnabled}
|
||||
style={getSwitchStyles()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="timezone-adaption__description">
|
||||
Adapt the timestamps shown in the SigNoz console to my active timezone.
|
||||
</p>
|
||||
|
||||
<div className="timezone-adaption__note">
|
||||
<div className="timezone-adaption__note-text-container">
|
||||
<span className="timezone-adaption__bullet">•</span>
|
||||
<span className="timezone-adaption__note-text">
|
||||
{isTimezoneOverridden ? (
|
||||
<>
|
||||
Your current timezone is overridden to
|
||||
<span className="timezone-adaption__note-text-overridden">
|
||||
{timezone?.offset}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
You can override the timezone adaption for any view with the time
|
||||
picker.
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!!isTimezoneOverridden && (
|
||||
<button
|
||||
type="button"
|
||||
className="timezone-adaption__clear-override"
|
||||
onClick={handleOverrideClear}
|
||||
>
|
||||
<Delete height={12} width={12} color={Color.BG_ROBIN_300} />
|
||||
Clear override
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimezoneAdaptation;
|
||||
@@ -7,6 +7,7 @@ import { LogOut, Moon, Sun } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import Password from './Password';
|
||||
import TimezoneAdaptation from './TimezoneAdaptation/TimezoneAdaptation';
|
||||
import UserInfo from './UserInfo';
|
||||
|
||||
function MySettings(): JSX.Element {
|
||||
@@ -78,6 +79,8 @@ function MySettings(): JSX.Element {
|
||||
<Password />
|
||||
</div>
|
||||
|
||||
<TimezoneAdaptation />
|
||||
|
||||
<Button
|
||||
className="flexBtn"
|
||||
onClick={(): void => Logout()}
|
||||
|
||||
@@ -14,7 +14,9 @@ import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
import { cloneDeep, isEqual, isUndefined } from 'lodash-es';
|
||||
import _noop from 'lodash-es/noop';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import uPlot from 'uplot';
|
||||
import { getSortedSeriesData } from 'utils/getSortedSeriesData';
|
||||
import { getTimeRange } from 'utils/getTimeRange';
|
||||
|
||||
@@ -105,6 +107,8 @@ function UplotPanelWrapper({
|
||||
}
|
||||
}, [graphVisibility, hiddenGraph, widget.panelTypes, widget?.stackedBarChart]);
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
getUPlotChartOptions({
|
||||
@@ -128,6 +132,9 @@ function UplotPanelWrapper({
|
||||
hiddenGraph,
|
||||
setHiddenGraph,
|
||||
customTooltipElement,
|
||||
tzDate: (timestamp: number) =>
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone?.value),
|
||||
timezone: timezone?.value,
|
||||
}),
|
||||
[
|
||||
widget?.id,
|
||||
@@ -150,6 +157,7 @@ function UplotPanelWrapper({
|
||||
currentQuery,
|
||||
hiddenGraph,
|
||||
customTooltipElement,
|
||||
timezone?.value,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
function DeploymentTime(deployTime: string): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
return (
|
||||
<span>{dayjs(deployTime).locale('en').format('MMMM DD, YYYY hh:mm A')}</span>
|
||||
<span>
|
||||
{formatTimezoneAdjustedTimestamp(
|
||||
deployTime,
|
||||
'MMMM DD, YYYY hh:mm A (UTC Z)',
|
||||
)}{' '}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import './styles.scss';
|
||||
import { ExpandAltOutlined } from '@ant-design/icons';
|
||||
import LogDetail from 'components/LogDetail';
|
||||
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
||||
import dayjs from 'dayjs';
|
||||
import { useActiveLog } from 'hooks/logs/useActiveLog';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
function LogsList({ logs }: LogsListProps): JSX.Element {
|
||||
@@ -18,12 +18,17 @@ function LogsList({ logs }: LogsListProps): JSX.Element {
|
||||
|
||||
const makeLogDetailsHandler = (log: ILog) => (): void => onSetActiveLog(log);
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
return (
|
||||
<div className="logs-preview-list-container">
|
||||
{logs.map((log) => (
|
||||
<div key={log.id} className="logs-preview-list-item">
|
||||
<div className="logs-preview-list-item-timestamp">
|
||||
{dayjs(log.timestamp).format('MMM DD HH:mm:ss.SSS')}
|
||||
{formatTimezoneAdjustedTimestamp(
|
||||
log.timestamp,
|
||||
'MMM DD HH:mm:ss.SSS (UTC Z)',
|
||||
)}
|
||||
</div>
|
||||
<div className="logs-preview-list-item-body">{log.body}</div>
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import React from 'react';
|
||||
import { PipelineData, ProcessorData } from 'types/api/pipeline/def';
|
||||
|
||||
@@ -6,13 +6,18 @@ import { PipelineIndexIcon } from '../AddNewProcessor/styles';
|
||||
import { ColumnDataStyle, ListDataStyle, ProcessorIndexIcon } from '../styles';
|
||||
import PipelineFilterSummary from './PipelineFilterSummary';
|
||||
|
||||
function CreatedAtComponent({ record }: { record: Record }): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
return (
|
||||
<ColumnDataStyle>
|
||||
{formatTimezoneAdjustedTimestamp(record, 'MMMM DD, YYYY hh:mm A (UTC Z)')}
|
||||
</ColumnDataStyle>
|
||||
);
|
||||
}
|
||||
|
||||
const componentMap: ComponentMap = {
|
||||
orderId: ({ record }) => <PipelineIndexIcon>{record}</PipelineIndexIcon>,
|
||||
createdAt: ({ record }) => (
|
||||
<ColumnDataStyle>
|
||||
{dayjs(record).locale('en').format('MMMM DD, YYYY hh:mm A')}
|
||||
</ColumnDataStyle>
|
||||
),
|
||||
createdAt: ({ record }) => <CreatedAtComponent record={record} />,
|
||||
id: ({ record }) => <ProcessorIndexIcon>{record}</ProcessorIndexIcon>,
|
||||
name: ({ record }) => <ListDataStyle>{record}</ListDataStyle>,
|
||||
filter: ({ record }) => <PipelineFilterSummary filter={record} />,
|
||||
|
||||
@@ -17,6 +17,7 @@ import history from 'lib/history';
|
||||
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
|
||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
@@ -26,6 +27,7 @@ import { SuccessResponse } from 'types/api';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import uPlot from 'uplot';
|
||||
import { getTimeRange } from 'utils/getTimeRange';
|
||||
|
||||
import { Container } from './styles';
|
||||
@@ -118,6 +120,8 @@ function TimeSeriesView({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const chartOptions = getUPlotChartOptions({
|
||||
onDragSelect,
|
||||
yAxisUnit: yAxisUnit || '',
|
||||
@@ -131,6 +135,9 @@ function TimeSeriesView({
|
||||
maxTimeScale,
|
||||
softMax: null,
|
||||
softMin: null,
|
||||
tzDate: (timestamp: number) =>
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone?.value),
|
||||
timezone: timezone?.value,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -28,6 +28,7 @@ import getTimeString from 'lib/getTimeString';
|
||||
import history from 'lib/history';
|
||||
import { isObject } from 'lodash-es';
|
||||
import { Check, Copy, Info, Send, Undo } from 'lucide-react';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { connect, useSelector } from 'react-redux';
|
||||
@@ -613,6 +614,8 @@ function DateTimeSelection({
|
||||
);
|
||||
};
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
return (
|
||||
<div className="date-time-selector">
|
||||
{showResetButton && selectedTime !== defaultRelativeTime && (
|
||||
@@ -664,8 +667,8 @@ function DateTimeSelection({
|
||||
setIsValidteRelativeTime(isValid);
|
||||
}}
|
||||
selectedValue={getInputLabel(
|
||||
dayjs(minTime / 1000000),
|
||||
dayjs(maxTime / 1000000),
|
||||
dayjs(minTime / 1000000).tz(timezone.value),
|
||||
dayjs(maxTime / 1000000).tz(timezone.value),
|
||||
selectedTime,
|
||||
)}
|
||||
data-testid="dropDown"
|
||||
|
||||
@@ -24,6 +24,7 @@ import history from 'lib/history';
|
||||
import { map } from 'lodash-es';
|
||||
import { PanelRight } from 'lucide-react';
|
||||
import { SPAN_DETAILS_LEFT_COL_WIDTH } from 'pages/TraceDetail/constants';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ITraceForest, PayloadProps } from 'types/api/trace/getTraceItem';
|
||||
import { getSpanTreeMetadata } from 'utils/getSpanTreeMetadata';
|
||||
@@ -139,6 +140,8 @@ function TraceDetail({ response }: TraceDetailProps): JSX.Element {
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
return (
|
||||
<StyledRow styledclass={[Flex({ flex: 1 })]}>
|
||||
<StyledCol flex="auto" styledclass={styles.leftContainer}>
|
||||
@@ -195,7 +198,9 @@ function TraceDetail({ response }: TraceDetailProps): JSX.Element {
|
||||
{isGlobalTimeVisible && (
|
||||
<styles.TimeStampContainer flex={`${SPAN_DETAILS_LEFT_COL_WIDTH}px`}>
|
||||
<Typography>
|
||||
{dayjs(traceMetaData.globalStart).format('hh:mm:ss a MM/DD')}
|
||||
{dayjs(traceMetaData.globalStart)
|
||||
.tz(timezone.value)
|
||||
.format('hh:mm:ss a (UTC Z) MM/DD')}
|
||||
</Typography>
|
||||
</styles.TimeStampContainer>
|
||||
)}
|
||||
|
||||
@@ -15,6 +15,7 @@ import useDragColumns from 'hooks/useDragColumns';
|
||||
import { getDraggedColumns } from 'hooks/useDragColumns/utils';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
@@ -97,10 +98,15 @@ function ListView({ isFilterApplied }: ListViewProps): JSX.Element {
|
||||
queryTableDataResult,
|
||||
]);
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const updatedColumns = getListColumns(options?.selectColumns || []);
|
||||
const updatedColumns = getListColumns(
|
||||
options?.selectColumns || [],
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
);
|
||||
return getDraggedColumns(updatedColumns, draggedColumns);
|
||||
}, [options?.selectColumns, draggedColumns]);
|
||||
}, [options?.selectColumns, formatTimezoneAdjustedTimestamp, draggedColumns]);
|
||||
|
||||
const transformedQueryTableData = useMemo(
|
||||
() => transformDataWithDate(queryTableData) || [],
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ColumnsType } from 'antd/es/table';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
|
||||
import { formUrlParams } from 'container/TraceDetail/utils';
|
||||
import dayjs from 'dayjs';
|
||||
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
@@ -40,6 +40,10 @@ export const getTraceLink = (record: RowData): string =>
|
||||
|
||||
export const getListColumns = (
|
||||
selectedColumns: BaseAutocompleteData[],
|
||||
formatTimezoneAdjustedTimestamp: (
|
||||
input: TimestampInput,
|
||||
format?: string,
|
||||
) => string | number,
|
||||
): ColumnsType<RowData> => {
|
||||
const initialColumns: ColumnsType<RowData> = [
|
||||
{
|
||||
@@ -50,8 +54,8 @@ export const getListColumns = (
|
||||
render: (value, item): JSX.Element => {
|
||||
const date =
|
||||
typeof value === 'string'
|
||||
? dayjs(value).format('YYYY-MM-DD HH:mm:ss.SSS')
|
||||
: dayjs(value / 1e6).format('YYYY-MM-DD HH:mm:ss.SSS');
|
||||
? formatTimezoneAdjustedTimestamp(value, 'YYYY-MM-DD HH:mm:ss.SSS')
|
||||
: formatTimezoneAdjustedTimestamp(value / 1e6, 'YYYY-MM-DD HH:mm:ss.SSS');
|
||||
return (
|
||||
<BlockLink to={getTraceLink(item)}>
|
||||
<Typography.Text>{date}</Typography.Text>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Pagination } from 'hooks/queryPagination';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import history from 'lib/history';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import {
|
||||
Dispatch,
|
||||
HTMLAttributes,
|
||||
@@ -49,7 +50,12 @@ function TracesTableComponent({
|
||||
}));
|
||||
}, [pagination, setRequestData]);
|
||||
|
||||
const columns = getListColumns(widget.selectedTracesFields || []);
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const columns = getListColumns(
|
||||
widget.selectedTracesFields || [],
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
);
|
||||
|
||||
const dataLength =
|
||||
queryResponse.data?.payload?.data?.newResult?.data?.result[0]?.list?.length;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Tag, Typography } from 'antd';
|
||||
import convertDateToAmAndPm from 'lib/convertDateToAmAndPm';
|
||||
import getFormattedDate from 'lib/getFormatedDate';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { Alerts } from 'types/api/alerts/getTriggered';
|
||||
|
||||
import Status from '../TableComponents/AlertStatus';
|
||||
import { TableCell, TableRow } from './styles';
|
||||
|
||||
function ExapandableRow({ allAlerts }: ExapandableRowProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
return (
|
||||
<>
|
||||
{allAlerts.map((alert) => {
|
||||
@@ -40,8 +40,9 @@ function ExapandableRow({ allAlerts }: ExapandableRowProps): JSX.Element {
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Typography>{`${getFormattedDate(formatedDate)} ${convertDateToAmAndPm(
|
||||
<Typography>{`${formatTimezoneAdjustedTimestamp(
|
||||
formatedDate,
|
||||
'MM/DD/YYYY hh:mm:ss A (UTC Z)',
|
||||
)}`}</Typography>
|
||||
</TableCell>
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@ import { ColumnsType } from 'antd/lib/table';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import LabelColumn from 'components/TableRenderer/LabelColumn';
|
||||
import AlertStatus from 'container/TriggeredAlerts/TableComponents/AlertStatus';
|
||||
import convertDateToAmAndPm from 'lib/convertDateToAmAndPm';
|
||||
import getFormattedDate from 'lib/getFormatedDate';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { Alerts } from 'types/api/alerts/getTriggered';
|
||||
|
||||
import { Value } from './Filter';
|
||||
@@ -16,6 +15,7 @@ function NoFilterTable({
|
||||
selectedFilter,
|
||||
}: NoFilterTableProps): JSX.Element {
|
||||
const filteredAlerts = FilterAlerts(allAlerts, selectedFilter);
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
// need to add the filter
|
||||
const columns: ColumnsType<Alerts> = [
|
||||
@@ -83,15 +83,12 @@ function NoFilterTable({
|
||||
width: 100,
|
||||
sorter: (a, b): number =>
|
||||
new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime(),
|
||||
render: (date): JSX.Element => {
|
||||
const formatedDate = new Date(date);
|
||||
|
||||
return (
|
||||
<Typography>{`${getFormattedDate(formatedDate)} ${convertDateToAmAndPm(
|
||||
formatedDate,
|
||||
)}`}</Typography>
|
||||
);
|
||||
},
|
||||
render: (date): JSX.Element => (
|
||||
<Typography>{`${formatTimezoneAdjustedTimestamp(
|
||||
date,
|
||||
'MM/DD/YYYY hh:mm:ss A (UTC Z)',
|
||||
)}`}</Typography>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
|
||||
function useFetch<PayloadProps, FunctionParams>(
|
||||
@@ -10,7 +10,7 @@ function useFetch<PayloadProps, FunctionParams>(
|
||||
(arg0: any): Promise<SuccessResponse<PayloadProps> | ErrorResponse>;
|
||||
},
|
||||
param?: FunctionParams,
|
||||
): State<PayloadProps | undefined> {
|
||||
): State<PayloadProps | undefined> & { refetch: () => Promise<void> } {
|
||||
const [state, setStates] = useState<State<PayloadProps | undefined>>({
|
||||
loading: true,
|
||||
success: null,
|
||||
@@ -19,37 +19,28 @@ function useFetch<PayloadProps, FunctionParams>(
|
||||
payload: undefined,
|
||||
});
|
||||
|
||||
const loadingRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = useCallback(async (): Promise<void> => {
|
||||
setStates((prev) => ({ ...prev, loading: true }));
|
||||
try {
|
||||
(async (): Promise<void> => {
|
||||
if (state.loading) {
|
||||
const response = await functions(param);
|
||||
const response = await functions(param);
|
||||
|
||||
if (loadingRef.current === 0) {
|
||||
loadingRef.current = 1;
|
||||
|
||||
if (response.statusCode === 200) {
|
||||
setStates({
|
||||
loading: false,
|
||||
error: false,
|
||||
success: true,
|
||||
payload: response.payload,
|
||||
errorMessage: '',
|
||||
});
|
||||
} else {
|
||||
setStates({
|
||||
loading: false,
|
||||
error: true,
|
||||
success: false,
|
||||
payload: undefined,
|
||||
errorMessage: response.error as string,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
if (response.statusCode === 200) {
|
||||
setStates({
|
||||
loading: false,
|
||||
error: false,
|
||||
success: true,
|
||||
payload: response.payload,
|
||||
errorMessage: '',
|
||||
});
|
||||
} else {
|
||||
setStates({
|
||||
loading: false,
|
||||
error: true,
|
||||
success: false,
|
||||
payload: undefined,
|
||||
errorMessage: response.error as string,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setStates({
|
||||
payload: undefined,
|
||||
@@ -59,13 +50,16 @@ function useFetch<PayloadProps, FunctionParams>(
|
||||
errorMessage: error as string,
|
||||
});
|
||||
}
|
||||
return (): void => {
|
||||
loadingRef.current = 1;
|
||||
};
|
||||
}, [functions, param, state.loading]);
|
||||
}, [functions, param]);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
refetch: fetchData,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
103
frontend/src/hooks/useTimezoneFormatter/useTimezoneFormatter.ts
Normal file
103
frontend/src/hooks/useTimezoneFormatter/useTimezoneFormatter.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import dayjs from 'dayjs';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
|
||||
// Initialize dayjs plugins
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
// Types
|
||||
export type TimestampInput = string | number | Date;
|
||||
interface CacheEntry {
|
||||
value: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
// Constants
|
||||
const CACHE_SIZE_LIMIT = 1000;
|
||||
const CACHE_CLEANUP_PERCENTAGE = 0.5; // Remove 50% when limit is reached
|
||||
|
||||
function useTimezoneFormatter({
|
||||
userTimezone,
|
||||
}: {
|
||||
userTimezone: Timezone;
|
||||
}): {
|
||||
formatTimezoneAdjustedTimestamp: (
|
||||
input: TimestampInput,
|
||||
format?: string,
|
||||
) => string;
|
||||
} {
|
||||
// Initialize cache using useMemo to persist between renders
|
||||
const cache = useMemo(() => new Map<string, CacheEntry>(), []);
|
||||
|
||||
// Clear cache when timezone changes
|
||||
useEffect(() => {
|
||||
cache.clear();
|
||||
}, [cache, userTimezone]);
|
||||
|
||||
const clearCacheEntries = useCallback(() => {
|
||||
if (cache.size <= CACHE_SIZE_LIMIT) return;
|
||||
|
||||
// Sort entries by timestamp (oldest first)
|
||||
const sortedEntries = Array.from(cache.entries()).sort(
|
||||
(a, b) => a[1].timestamp - b[1].timestamp,
|
||||
);
|
||||
|
||||
// Calculate how many entries to remove (50% or overflow, whichever is larger)
|
||||
const entriesToRemove = Math.max(
|
||||
Math.floor(cache.size * CACHE_CLEANUP_PERCENTAGE),
|
||||
cache.size - CACHE_SIZE_LIMIT,
|
||||
);
|
||||
|
||||
// Remove oldest entries
|
||||
sortedEntries.slice(0, entriesToRemove).forEach(([key]) => cache.delete(key));
|
||||
}, [cache]);
|
||||
|
||||
/**
|
||||
* Formats a timestamp with the user's timezone and caches the result
|
||||
* @param {TimestampInput} input - The timestamp to format (string, number, or Date)
|
||||
* @param {string} [format='YYYY-MM-DD HH:mm:ss'] - The desired output format
|
||||
* @returns {string} The formatted timestamp string in the user's timezone
|
||||
* @example
|
||||
* // Input: UTC timestamp
|
||||
* // User timezone: 'UTC - 4'
|
||||
* // Returns: "2024-03-14 15:30:00"
|
||||
* formatTimezoneAdjustedTimestamp('2024-03-14T19:30:00Z')
|
||||
*/
|
||||
const formatTimezoneAdjustedTimestamp = useCallback(
|
||||
(input: TimestampInput, format = 'YYYY-MM-DD HH:mm:ss'): string => {
|
||||
const timestamp = dayjs(input).valueOf();
|
||||
const cacheKey = `${timestamp}_${userTimezone?.value}`;
|
||||
|
||||
// Check cache first
|
||||
const cachedValue = cache.get(cacheKey);
|
||||
if (cachedValue) {
|
||||
return cachedValue.value;
|
||||
}
|
||||
// Format timestamp
|
||||
const formattedValue = dayjs(input).tz(userTimezone?.value).format(format);
|
||||
|
||||
// Update cache
|
||||
cache.set(cacheKey, {
|
||||
value: formattedValue,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Clear expired entries and enforce size limit
|
||||
if (cache.size > CACHE_SIZE_LIMIT) {
|
||||
clearCacheEntries();
|
||||
}
|
||||
|
||||
return formattedValue;
|
||||
},
|
||||
[cache, clearCacheEntries, userTimezone],
|
||||
);
|
||||
|
||||
return { formatTimezoneAdjustedTimestamp };
|
||||
}
|
||||
|
||||
export default useTimezoneFormatter;
|
||||
@@ -7,6 +7,7 @@ import { AxiosError } from 'axios';
|
||||
import { ThemeProvider } from 'hooks/useDarkMode';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import posthog from 'posthog-js';
|
||||
import TimezoneProvider from 'providers/Timezone';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { HelmetProvider } from 'react-helmet-async';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
@@ -69,14 +70,16 @@ if (container) {
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<HelmetProvider>
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<AppRoutes />
|
||||
</Provider>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
)}
|
||||
</QueryClientProvider>
|
||||
<TimezoneProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<AppRoutes />
|
||||
</Provider>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
)}
|
||||
</QueryClientProvider>
|
||||
</TimezoneProvider>
|
||||
</ThemeProvider>
|
||||
</HelmetProvider>
|
||||
</Sentry.ErrorBoundary>,
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface GetUPlotChartOptions {
|
||||
>;
|
||||
customTooltipElement?: HTMLDivElement;
|
||||
verticalLineTimestamp?: number;
|
||||
tzDate?: (timestamp: number) => Date;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
/** the function converts series A , series B , series C to
|
||||
@@ -158,6 +160,8 @@ export const getUPlotChartOptions = ({
|
||||
setHiddenGraph,
|
||||
customTooltipElement,
|
||||
verticalLineTimestamp,
|
||||
tzDate,
|
||||
timezone,
|
||||
}: GetUPlotChartOptions): uPlot.Options => {
|
||||
const timeScaleProps = getXAxisScale(minTimeScale, maxTimeScale);
|
||||
|
||||
@@ -196,6 +200,7 @@ export const getUPlotChartOptions = ({
|
||||
fill: (): string => '#fff',
|
||||
},
|
||||
},
|
||||
tzDate,
|
||||
padding: [16, 16, 8, 8],
|
||||
bands,
|
||||
scales: {
|
||||
@@ -222,6 +227,7 @@ export const getUPlotChartOptions = ({
|
||||
stackBarChart,
|
||||
isDarkMode,
|
||||
customTooltipElement,
|
||||
timezone,
|
||||
}),
|
||||
onClickPlugin({
|
||||
onClick: onClickHandler,
|
||||
|
||||
@@ -46,6 +46,7 @@ const generateTooltipContent = (
|
||||
isHistogramGraphs?: boolean,
|
||||
isMergedSeries?: boolean,
|
||||
stackBarChart?: boolean,
|
||||
timezone?: string,
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
): HTMLElement => {
|
||||
const container = document.createElement('div');
|
||||
@@ -69,9 +70,13 @@ const generateTooltipContent = (
|
||||
series.forEach((item, index) => {
|
||||
if (index === 0) {
|
||||
if (isBillingUsageGraphs) {
|
||||
tooltipTitle = dayjs(data[0][idx] * 1000).format('MMM DD YYYY');
|
||||
tooltipTitle = dayjs(data[0][idx] * 1000)
|
||||
.tz(timezone)
|
||||
.format('MMM DD YYYY');
|
||||
} else {
|
||||
tooltipTitle = dayjs(data[0][idx] * 1000).format('MMM DD YYYY HH:mm:ss');
|
||||
tooltipTitle = dayjs(data[0][idx] * 1000)
|
||||
.tz(timezone)
|
||||
.format('MMM DD YYYY h:mm:ss A');
|
||||
}
|
||||
} else if (item.show) {
|
||||
const {
|
||||
@@ -223,6 +228,7 @@ type ToolTipPluginProps = {
|
||||
stackBarChart?: boolean;
|
||||
isDarkMode: boolean;
|
||||
customTooltipElement?: HTMLDivElement;
|
||||
timezone?: string;
|
||||
};
|
||||
|
||||
const tooltipPlugin = ({
|
||||
@@ -234,6 +240,7 @@ const tooltipPlugin = ({
|
||||
stackBarChart,
|
||||
isDarkMode,
|
||||
customTooltipElement,
|
||||
timezone,
|
||||
}: // eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
ToolTipPluginProps): any => {
|
||||
let over: HTMLElement;
|
||||
@@ -300,6 +307,7 @@ ToolTipPluginProps): any => {
|
||||
isHistogramGraphs,
|
||||
isMergedSeries,
|
||||
stackBarChart,
|
||||
timezone,
|
||||
);
|
||||
if (customTooltipElement) {
|
||||
content.appendChild(customTooltipElement);
|
||||
|
||||
@@ -2,82 +2,90 @@ import './ActionButtons.styles.scss';
|
||||
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Divider, Dropdown, MenuProps, Switch, Tooltip } from 'antd';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import history from 'lib/history';
|
||||
import { Copy, Ellipsis, PenLine, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
useAlertRuleDelete,
|
||||
useAlertRuleDuplicate,
|
||||
useAlertRuleStatusToggle,
|
||||
useAlertRuleUpdate,
|
||||
} from 'pages/AlertDetails/hooks';
|
||||
import CopyToClipboard from 'periscope/components/CopyToClipboard';
|
||||
import { useAlertRule } from 'providers/Alert';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { CSSProperties } from 'styled-components';
|
||||
import { AlertDef } from 'types/api/alerts/def';
|
||||
|
||||
import { AlertHeaderProps } from '../AlertHeader';
|
||||
import RenameModal from './RenameModal';
|
||||
|
||||
const menuItemStyle: CSSProperties = {
|
||||
fontSize: '14px',
|
||||
letterSpacing: '0.14px',
|
||||
};
|
||||
|
||||
function AlertActionButtons({
|
||||
ruleId,
|
||||
alertDetails,
|
||||
setUpdatedName,
|
||||
}: {
|
||||
ruleId: string;
|
||||
alertDetails: AlertHeaderProps['alertDetails'];
|
||||
setUpdatedName: (name: string) => void;
|
||||
}): JSX.Element {
|
||||
const { alertRuleState, setAlertRuleState } = useAlertRule();
|
||||
const { handleAlertStateToggle } = useAlertRuleStatusToggle({ ruleId });
|
||||
const [intermediateName, setIntermediateName] = useState<string>(
|
||||
alertDetails.alert,
|
||||
);
|
||||
const [isRenameAlertOpen, setIsRenameAlertOpen] = useState<boolean>(false);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const { handleAlertStateToggle } = useAlertRuleStatusToggle({ ruleId });
|
||||
const { handleAlertDuplicate } = useAlertRuleDuplicate({
|
||||
alertDetails: (alertDetails as unknown) as AlertDef,
|
||||
});
|
||||
const { handleAlertDelete } = useAlertRuleDelete({ ruleId: Number(ruleId) });
|
||||
const { handleAlertUpdate, isLoading } = useAlertRuleUpdate({
|
||||
alertDetails: (alertDetails as unknown) as AlertDef,
|
||||
setUpdatedName,
|
||||
intermediateName,
|
||||
});
|
||||
|
||||
const params = useUrlQuery();
|
||||
const handleRename = useCallback(() => {
|
||||
setIsRenameAlertOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleRename = React.useCallback(() => {
|
||||
params.set(QueryParams.ruleId, String(ruleId));
|
||||
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
|
||||
}, [params, ruleId]);
|
||||
const onNameChangeHandler = useCallback(() => {
|
||||
handleAlertUpdate();
|
||||
setIsRenameAlertOpen(false);
|
||||
}, [handleAlertUpdate]);
|
||||
|
||||
const menu: MenuProps['items'] = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'rename-rule',
|
||||
label: 'Rename',
|
||||
icon: <PenLine size={16} color={Color.BG_VANILLA_400} />,
|
||||
onClick: (): void => handleRename(),
|
||||
style: menuItemStyle,
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'rename-rule',
|
||||
label: 'Rename',
|
||||
icon: <PenLine size={16} color={Color.BG_VANILLA_400} />,
|
||||
onClick: handleRename,
|
||||
style: menuItemStyle,
|
||||
},
|
||||
{
|
||||
key: 'duplicate-rule',
|
||||
label: 'Duplicate',
|
||||
icon: <Copy size={16} color={Color.BG_VANILLA_400} />,
|
||||
onClick: handleAlertDuplicate,
|
||||
style: menuItemStyle,
|
||||
},
|
||||
{
|
||||
key: 'delete-rule',
|
||||
label: 'Delete',
|
||||
icon: <Trash2 size={16} color={Color.BG_CHERRY_400} />,
|
||||
onClick: handleAlertDelete,
|
||||
style: {
|
||||
...menuItemStyle,
|
||||
color: Color.BG_CHERRY_400,
|
||||
},
|
||||
{
|
||||
key: 'duplicate-rule',
|
||||
label: 'Duplicate',
|
||||
icon: <Copy size={16} color={Color.BG_VANILLA_400} />,
|
||||
onClick: (): void => handleAlertDuplicate(),
|
||||
style: menuItemStyle,
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'delete-rule',
|
||||
label: 'Delete',
|
||||
icon: <Trash2 size={16} color={Color.BG_CHERRY_400} />,
|
||||
onClick: (): void => handleAlertDelete(),
|
||||
style: {
|
||||
...menuItemStyle,
|
||||
color: Color.BG_CHERRY_400,
|
||||
},
|
||||
},
|
||||
],
|
||||
[handleAlertDelete, handleAlertDuplicate, handleRename],
|
||||
);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
},
|
||||
];
|
||||
|
||||
// state for immediate UI feedback rather than waiting for onSuccess of handleAlertStateTiggle to updating the alertRuleState
|
||||
const [isAlertRuleDisabled, setIsAlertRuleDisabled] = useState<
|
||||
@@ -95,35 +103,48 @@ function AlertActionButtons({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => (): void => setAlertRuleState(undefined), []);
|
||||
|
||||
const toggleAlertRule = useCallback(() => {
|
||||
setIsAlertRuleDisabled((prev) => !prev);
|
||||
handleAlertStateToggle();
|
||||
}, [handleAlertStateToggle]);
|
||||
|
||||
return (
|
||||
<div className="alert-action-buttons">
|
||||
<Tooltip title={alertRuleState ? 'Enable alert' : 'Disable alert'}>
|
||||
{isAlertRuleDisabled !== undefined && (
|
||||
<Switch
|
||||
size="small"
|
||||
onChange={(): void => {
|
||||
setIsAlertRuleDisabled((prev) => !prev);
|
||||
handleAlertStateToggle();
|
||||
}}
|
||||
checked={!isAlertRuleDisabled}
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
<CopyToClipboard textToCopy={window.location.href} />
|
||||
|
||||
<Divider type="vertical" />
|
||||
|
||||
<Dropdown trigger={['click']} menu={{ items: menu }}>
|
||||
<Tooltip title="More options">
|
||||
<Ellipsis
|
||||
size={16}
|
||||
color={isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400}
|
||||
cursor="pointer"
|
||||
className="dropdown-icon"
|
||||
/>
|
||||
<>
|
||||
<div className="alert-action-buttons">
|
||||
<Tooltip title={alertRuleState ? 'Enable alert' : 'Disable alert'}>
|
||||
{isAlertRuleDisabled !== undefined && (
|
||||
<Switch
|
||||
size="small"
|
||||
onChange={toggleAlertRule}
|
||||
checked={!isAlertRuleDisabled}
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<CopyToClipboard textToCopy={window.location.href} />
|
||||
|
||||
<Divider type="vertical" />
|
||||
|
||||
<Dropdown trigger={['click']} menu={{ items: menuItems }}>
|
||||
<Tooltip title="More options">
|
||||
<Ellipsis
|
||||
size={16}
|
||||
color={isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400}
|
||||
cursor="pointer"
|
||||
className="dropdown-icon"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<RenameModal
|
||||
isOpen={isRenameAlertOpen}
|
||||
setIsOpen={setIsRenameAlertOpen}
|
||||
isLoading={isLoading}
|
||||
onNameChangeHandler={onNameChangeHandler}
|
||||
intermediateName={intermediateName}
|
||||
setIntermediateName={setIntermediateName}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
.rename-alert {
|
||||
.ant-modal-content {
|
||||
width: 384px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
background: var(--bg-ink-400);
|
||||
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
padding: 0px;
|
||||
|
||||
.ant-modal-header {
|
||||
height: 52px;
|
||||
padding: 16px;
|
||||
background: var(--bg-ink-400);
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
margin-bottom: 0px;
|
||||
.ant-modal-title {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
width: 349px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 16px;
|
||||
|
||||
.alert-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.name-text {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 142.857% */
|
||||
}
|
||||
|
||||
.alert-name-input {
|
||||
display: flex;
|
||||
padding: 6px 6px 6px 8px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
align-self: stretch;
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-footer {
|
||||
padding: 16px;
|
||||
margin-top: 0px;
|
||||
.alert-rename {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
gap: 12px;
|
||||
|
||||
.cancel-btn {
|
||||
display: flex;
|
||||
padding: 4px 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-slate-500);
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.rename-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
padding: 4px 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-robin-500);
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.rename-alert {
|
||||
.ant-modal-content {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-100);
|
||||
|
||||
.ant-modal-header {
|
||||
background: var(--bg-vanilla-100);
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.ant-modal-title {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
.alert-content {
|
||||
.name-text {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
.alert-name-input {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-footer {
|
||||
.alert-rename {
|
||||
.cancel-btn {
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import './RenameModal.styles.scss';
|
||||
|
||||
import { Button, Input, InputRef, Modal, Typography } from 'antd';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
onNameChangeHandler: () => void;
|
||||
isLoading: boolean;
|
||||
intermediateName: string;
|
||||
setIntermediateName: (name: string) => void;
|
||||
};
|
||||
|
||||
function RenameModal({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
onNameChangeHandler,
|
||||
isLoading,
|
||||
intermediateName,
|
||||
setIntermediateName,
|
||||
}: Props): JSX.Element {
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleClose = useCallback((): void => setIsOpen(false), [setIsOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
if (isOpen) {
|
||||
if (e.key === 'Enter') {
|
||||
onNameChangeHandler();
|
||||
} else if (e.key === 'Escape') {
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return (): void => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, onNameChangeHandler, handleClose]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
title="Rename Alert"
|
||||
onOk={onNameChangeHandler}
|
||||
onCancel={handleClose}
|
||||
rootClassName="rename-alert"
|
||||
footer={
|
||||
<div className="alert-rename">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Check size={14} />}
|
||||
className="rename-btn"
|
||||
onClick={onNameChangeHandler}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Rename Alert
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<X size={14} />}
|
||||
className="cancel-btn"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="alert-content">
|
||||
<Typography.Text className="name-text">Enter a new name</Typography.Text>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
data-testid="alert-name"
|
||||
className="alert-name-input"
|
||||
value={intermediateName}
|
||||
onChange={(e): void => setIntermediateName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default RenameModal;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user