Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
529a9e7009 | ||
|
|
b00687b43f | ||
|
|
8771919de6 | ||
|
|
497972f23c | ||
|
|
a9e30919d1 | ||
|
|
925c4c4a3d | ||
|
|
e66bfe5961 | ||
|
|
42943f72b7 | ||
|
|
7a72a209e5 | ||
|
|
44f00943a8 | ||
|
|
8867e1ef38 | ||
|
|
c08e520941 | ||
|
|
139cc4452d | ||
|
|
2f3baeb302 | ||
|
|
3d42b0058e | ||
|
|
ed70e3c5f5 | ||
|
|
7d6918f8b6 | ||
|
|
2885bc851e | ||
|
|
857258f8c3 | ||
|
|
ece5c2b7ad | ||
|
|
1078f98388 | ||
|
|
b4e2326f38 | ||
|
|
c79b154215 | ||
|
|
a59c0188cc | ||
|
|
3df426625a | ||
|
|
646f359f33 | ||
|
|
81167c6947 | ||
|
|
bc1295b93a | ||
|
|
3db0e1f66a | ||
|
|
d52b54aeb3 | ||
|
|
c8608c18ae | ||
|
|
cde99ba1a0 | ||
|
|
a7e9d442b7 | ||
|
|
0b0d622f6b | ||
|
|
127e760b00 | ||
|
|
63e333de0d | ||
|
|
af57d11b6a | ||
|
|
8d61ee338b | ||
|
|
5d9dc17645 | ||
|
|
5288022ffd | ||
|
|
cdc18af4a2 | ||
|
|
918a90e3c1 | ||
|
|
e8ce7b22f5 | ||
|
|
b752fdd30a | ||
|
|
d73b7fadab |
40
.github/CODEOWNERS
vendored
@@ -3,51 +3,11 @@
|
||||
# that they own.
|
||||
|
||||
/frontend/ @YounixM @aks07
|
||||
/frontend/src/container/MetricsApplication @srikanthccv
|
||||
/frontend/src/container/NewWidget/RightContainer/types.ts @srikanthccv
|
||||
|
||||
# Onboarding
|
||||
/frontend/src/container/OnboardingV2Container/onboarding-configs/onboarding-config-with-links.json @makeavish
|
||||
/frontend/src/container/OnboardingV2Container/AddDataSource/AddDataSource.tsx @makeavish
|
||||
|
||||
# Dashboard, Alert, Metrics, Service Map, Services
|
||||
/frontend/src/container/ListOfDashboard/ @srikanthccv
|
||||
/frontend/src/container/NewDashboard/ @srikanthccv
|
||||
/frontend/src/pages/DashboardsListPage/ @srikanthccv
|
||||
/frontend/src/pages/DashboardWidget/ @srikanthccv
|
||||
/frontend/src/pages/NewDashboard/ @srikanthccv
|
||||
/frontend/src/providers/Dashboard/ @srikanthccv
|
||||
|
||||
# Alerts
|
||||
/frontend/src/container/AlertHistory/ @srikanthccv
|
||||
/frontend/src/container/AllAlertChannels/ @srikanthccv
|
||||
/frontend/src/container/AnomalyAlertEvaluationView/ @srikanthccv
|
||||
/frontend/src/container/CreateAlertChannels/ @srikanthccv
|
||||
/frontend/src/container/CreateAlertRule/ @srikanthccv
|
||||
/frontend/src/container/EditAlertChannels/ @srikanthccv
|
||||
/frontend/src/container/FormAlertChannels/ @srikanthccv
|
||||
/frontend/src/container/FormAlertRules/ @srikanthccv
|
||||
/frontend/src/container/ListAlertRules/ @srikanthccv
|
||||
/frontend/src/container/TriggeredAlerts/ @srikanthccv
|
||||
/frontend/src/pages/AlertChannelCreate/ @srikanthccv
|
||||
/frontend/src/pages/AlertDetails/ @srikanthccv
|
||||
/frontend/src/pages/AlertHistory/ @srikanthccv
|
||||
/frontend/src/pages/AlertList/ @srikanthccv
|
||||
/frontend/src/pages/CreateAlert/ @srikanthccv
|
||||
/frontend/src/providers/Alert.tsx @srikanthccv
|
||||
|
||||
# Metrics
|
||||
/frontend/src/container/MetricsExplorer/ @srikanthccv
|
||||
/frontend/src/pages/MetricsApplication/ @srikanthccv
|
||||
/frontend/src/pages/MetricsExplorer/ @srikanthccv
|
||||
|
||||
# Services and Service Map
|
||||
/frontend/src/container/ServiceApplication/ @srikanthccv
|
||||
/frontend/src/container/ServiceTable/ @srikanthccv
|
||||
/frontend/src/pages/Services/ @srikanthccv
|
||||
/frontend/src/pages/ServiceTopLevelOperations/ @srikanthccv
|
||||
/frontend/src/container/Home/Services/ @srikanthccv
|
||||
|
||||
/deploy/ @SigNoz/devops
|
||||
.github @SigNoz/devops
|
||||
|
||||
|
||||
16
.github/workflows/goci.yaml
vendored
@@ -73,3 +73,19 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
make docker-build-enterprise
|
||||
openapi:
|
||||
if: |
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: self-checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
- name: generate-openapi
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate openapi
|
||||
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in openapi spec. Run go run cmd/enterprise/*.go generate openapi locally and commit."; exit 1)
|
||||
|
||||
@@ -13,6 +13,7 @@ func main() {
|
||||
|
||||
// register a list of commands to the root command
|
||||
registerServer(cmd.RootCmd, logger)
|
||||
cmd.RegisterGenerate(cmd.RootCmd, logger)
|
||||
|
||||
cmd.Execute(logger)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ func main() {
|
||||
|
||||
// register a list of commands to the root command
|
||||
registerServer(cmd.RootCmd, logger)
|
||||
cmd.RegisterGenerate(cmd.RootCmd, logger)
|
||||
|
||||
cmd.Execute(logger)
|
||||
}
|
||||
|
||||
21
cmd/generate.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func RegisterGenerate(parentCmd *cobra.Command, logger *slog.Logger) {
|
||||
var generateCmd = &cobra.Command{
|
||||
Use: "generate",
|
||||
Short: "Generate artifacts",
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true},
|
||||
}
|
||||
|
||||
registerGenerateOpenAPI(generateCmd)
|
||||
|
||||
parentCmd.AddCommand(generateCmd)
|
||||
}
|
||||
41
cmd/openapi.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func registerGenerateOpenAPI(parentCmd *cobra.Command) {
|
||||
openapiCmd := &cobra.Command{
|
||||
Use: "openapi",
|
||||
Short: "Generate OpenAPI schema for SigNoz",
|
||||
RunE: func(currCmd *cobra.Command, args []string) error {
|
||||
return runGenerateOpenAPI(currCmd.Context())
|
||||
},
|
||||
}
|
||||
|
||||
parentCmd.AddCommand(openapiCmd)
|
||||
}
|
||||
|
||||
func runGenerateOpenAPI(ctx context.Context) error {
|
||||
instrumentation, err := instrumentation.New(ctx, instrumentation.Config{Logs: instrumentation.LogsConfig{Level: slog.LevelInfo}}, version.Info, "signoz")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
openapi, err := signoz.NewOpenAPI(ctx, instrumentation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := openapi.CreateAndWrite("docs/api/openapi.yml"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -176,7 +176,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.103.0
|
||||
image: signoz/signoz:v0.104.0
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.103.0
|
||||
image: signoz/signoz:v0.104.0
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
|
||||
@@ -179,7 +179,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.103.0}
|
||||
image: signoz/signoz:${VERSION:-v0.104.0}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
|
||||
@@ -111,7 +111,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.103.0}
|
||||
image: signoz/signoz:${VERSION:-v0.104.0}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
|
||||
2293
docs/api/openapi.yml
Normal file
@@ -129,6 +129,12 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
|
||||
return authtypes.NewCallbackIdentity("", email, authDomain.StorableAuthDomain().OrgID, state), nil
|
||||
}
|
||||
|
||||
func (a *AuthN) ProviderInfo(ctx context.Context, authDomain *authtypes.AuthDomain) *authtypes.AuthNProviderInfo {
|
||||
return &authtypes.AuthNProviderInfo{
|
||||
RelayStatePath: nil,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AuthN) oidcProviderAndoauth2Config(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (*oidc.Provider, *oauth2.Config, error) {
|
||||
if authDomain.AuthDomainConfig().OIDC.IssuerAlias != "" {
|
||||
ctx = oidc.InsecureIssuerURLContext(ctx, authDomain.AuthDomainConfig().OIDC.IssuerAlias)
|
||||
|
||||
@@ -99,6 +99,14 @@ func (a *AuthN) HandleCallback(ctx context.Context, formValues url.Values) (*aut
|
||||
return authtypes.NewCallbackIdentity("", email, authDomain.StorableAuthDomain().OrgID, state), nil
|
||||
}
|
||||
|
||||
func (a *AuthN) ProviderInfo(ctx context.Context, authDomain *authtypes.AuthDomain) *authtypes.AuthNProviderInfo {
|
||||
state := authtypes.NewState(&url.URL{Path: "login"}, authDomain.StorableAuthDomain().ID).URL.String()
|
||||
|
||||
return &authtypes.AuthNProviderInfo{
|
||||
RelayStatePath: &state,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AuthN) serviceProvider(siteURL *url.URL, authDomain *authtypes.AuthDomain) (*saml2.SAMLServiceProvider, error) {
|
||||
certStore, err := a.getCertificateStore(authDomain)
|
||||
if err != nil {
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
|
||||
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
rules "github.com/SigNoz/signoz/pkg/query-service/rules"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
@@ -60,6 +61,7 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler,
|
||||
FieldsAPI: fields.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.TelemetryStore),
|
||||
Signoz: signoz,
|
||||
QuerierAPI: querierAPI.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.Querier, signoz.Analytics),
|
||||
QueryParserAPI: queryparser.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.QueryParser),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -92,10 +94,6 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
// routes available only in ee version
|
||||
router.HandleFunc("/api/v1/features", am.ViewAccess(ah.getFeatureFlags)).Methods(http.MethodGet)
|
||||
|
||||
// paid plans specific routes
|
||||
router.HandleFunc("/api/v1/complete/saml", am.OpenAccess(ah.Signoz.Handlers.Session.CreateSessionBySAMLCallback)).Methods(http.MethodPost)
|
||||
router.HandleFunc("/api/v1/complete/oidc", am.OpenAccess(ah.Signoz.Handlers.Session.CreateSessionByOIDCCallback)).Methods(http.MethodGet)
|
||||
|
||||
// base overrides
|
||||
router.HandleFunc("/api/v1/version", am.OpenAccess(ah.getVersion)).Methods(http.MethodGet)
|
||||
|
||||
|
||||
@@ -243,6 +243,11 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
||||
apiHandler.MetricExplorerRoutes(r, am)
|
||||
apiHandler.RegisterTraceFunnelsRoutes(r, am)
|
||||
|
||||
err := s.signoz.APIServer.AddToRouter(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c := cors.New(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "DELETE", "POST", "PUT", "PATCH", "OPTIONS"},
|
||||
@@ -253,7 +258,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
||||
|
||||
handler = handlers.CompressHandler(handler)
|
||||
|
||||
err := web.AddToRouter(r)
|
||||
err = web.AddToRouter(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
module.exports = {
|
||||
ignorePatterns: ['src/parser/*.ts'],
|
||||
ignorePatterns: ['src/parser/*.ts', 'scripts/update-registry.js'],
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"jest": "jest",
|
||||
"jest:coverage": "jest --coverage",
|
||||
"jest:watch": "jest --watch",
|
||||
"postinstall": "yarn i18n:generate-hash && (is-ci || yarn husky:configure)",
|
||||
"postinstall": "yarn i18n:generate-hash && (is-ci || yarn husky:configure) && node scripts/update-registry.js",
|
||||
"husky:configure": "cd .. && husky install frontend/.husky && cd frontend && chmod ug+x .husky/*",
|
||||
"commitlint": "commitlint --edit $1",
|
||||
"test": "jest",
|
||||
@@ -47,6 +47,8 @@
|
||||
"@signozhq/button": "0.0.2",
|
||||
"@signozhq/calendar": "0.0.0",
|
||||
"@signozhq/callout": "0.0.2",
|
||||
"@signozhq/checkbox": "0.0.2",
|
||||
"@signozhq/command": "0.0.0",
|
||||
"@signozhq/design-tokens": "1.1.4",
|
||||
"@signozhq/input": "0.0.2",
|
||||
"@signozhq/popover": "0.0.0",
|
||||
@@ -103,7 +105,6 @@
|
||||
"i18next-http-backend": "^1.3.2",
|
||||
"jest": "^27.5.1",
|
||||
"js-base64": "^3.7.2",
|
||||
"kbar": "0.1.0-beta.48",
|
||||
"less": "^4.1.2",
|
||||
"less-loader": "^10.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
1
frontend/public/Logos/amazon-bedrock.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" fill-rule="evenodd" style="flex:none;line-height:1" viewBox="0 0 24 24"><title>AWS</title><path d="M6.763 11.212q.002.446.088.71c.064.176.144.368.256.576.04.063.056.127.056.183q.002.12-.152.24l-.503.335a.4.4 0 0 1-.208.072q-.12-.002-.239-.112a2.5 2.5 0 0 1-.287-.375 6 6 0 0 1-.248-.471q-.934 1.101-2.347 1.101c-.67 0-1.205-.191-1.596-.574-.39-.384-.59-.894-.59-1.533 0-.678.24-1.23.726-1.644.487-.415 1.133-.623 1.955-.623.272 0 .551.024.846.064.296.04.6.104.918.176v-.583q-.001-.908-.375-1.277c-.255-.248-.686-.367-1.3-.367-.28 0-.568.031-.863.103s-.583.16-.862.272a2 2 0 0 1-.28.104.5.5 0 0 1-.127.023q-.168.002-.168-.247v-.391c0-.128.016-.224.056-.28a.6.6 0 0 1 .224-.167 4.6 4.6 0 0 1 1.005-.36 4.8 4.8 0 0 1 1.246-.151c.95 0 1.644.216 2.091.647q.661.646.662 1.963v2.586zm-3.24 1.214c.263 0 .534-.048.822-.144a1.8 1.8 0 0 0 .758-.51 1.3 1.3 0 0 0 .272-.512c.047-.191.08-.423.08-.694v-.335a7 7 0 0 0-.735-.136 6 6 0 0 0-.75-.048c-.535 0-.926.104-1.19.32-.263.215-.39.518-.39.917 0 .375.095.655.295.846.191.2.47.296.838.296m6.41.862c-.144 0-.24-.024-.304-.08-.064-.048-.12-.16-.168-.311L7.586 6.726a1.4 1.4 0 0 1-.072-.32c0-.128.064-.2.191-.2h.783q.227-.001.31.08c.065.048.113.16.16.312l1.342 5.284 1.245-5.284q.058-.24.151-.312a.55.55 0 0 1 .32-.08h.638c.152 0 .256.025.32.08.063.048.12.16.151.312l1.261 5.348 1.381-5.348q.074-.24.16-.312a.52.52 0 0 1 .311-.08h.743c.127 0 .2.065.2.2 0 .04-.009.08-.017.128a1 1 0 0 1-.056.2l-1.923 6.17q-.072.24-.168.311a.5.5 0 0 1-.303.08h-.687c-.15 0-.255-.024-.32-.08-.063-.056-.119-.16-.15-.32L12.32 7.747l-1.23 5.14c-.04.16-.087.264-.15.32-.065.056-.177.08-.32.08zm10.256.215c-.415 0-.83-.048-1.229-.143-.399-.096-.71-.2-.918-.32-.128-.071-.215-.151-.247-.223a.6.6 0 0 1-.048-.224v-.407c0-.167.064-.247.183-.247q.072 0 .144.024c.048.016.12.048.2.08q.408.181.878.279c.32.064.63.096.95.096.502 0 .894-.088 1.165-.264a.86.86 0 0 0 .415-.758.78.78 0 0 0-.215-.559c-.144-.151-.416-.287-.807-.415l-1.157-.36c-.583-.183-1.014-.454-1.277-.813a1.9 1.9 0 0 1-.4-1.158q0-.502.216-.886c.144-.255.335-.479.575-.654.24-.184.51-.32.83-.415.32-.096.655-.136 1.006-.136.175 0 .36.008.535.032.183.024.35.056.518.088q.24.058.455.127.216.072.336.144a.7.7 0 0 1 .24.2.43.43 0 0 1 .071.263v.375q-.002.254-.184.256a.8.8 0 0 1-.303-.096 3.65 3.65 0 0 0-1.532-.311c-.455 0-.815.071-1.062.223s-.375.383-.375.71c0 .224.08.416.24.567.16.152.454.304.877.44l1.134.358c.574.184.99.44 1.237.767s.367.702.367 1.117c0 .343-.072.655-.207.926a2.2 2.2 0 0 1-.583.703c-.248.2-.543.343-.886.447-.36.111-.734.167-1.142.167"/><path fill="#f90" d="M.378 15.475c3.384 1.963 7.56 3.153 11.877 3.153 2.914 0 6.114-.607 9.06-1.852.44-.2.814.287.383.607-2.626 1.94-6.442 2.969-9.722 2.969-4.598 0-8.74-1.7-11.87-4.526-.247-.223-.024-.527.272-.351m23.531-.2c.287.36-.08 2.826-1.485 4.007-.215.184-.423.088-.327-.151l.175-.439c.343-.88.802-2.198.52-2.555-.336-.43-2.22-.207-3.074-.103-.255.032-.295-.192-.063-.36 1.5-1.053 3.967-.75 4.254-.399"/></svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
1
frontend/public/Logos/autogen.svg
Normal file
|
After Width: | Height: | Size: 20 KiB |
1
frontend/public/Logos/azure-openai.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24"><title>Azure</title><path fill="url(#a)" d="M7.242 1.613A1.11 1.11 0 0 1 8.295.857h6.977L8.03 22.316a1.11 1.11 0 0 1-1.052.755h-5.43a1.11 1.11 0 0 1-1.053-1.466z"/><path fill="#0078d4" d="M18.397 15.296H7.4a.51.51 0 0 0-.347.882l7.066 6.595c.206.192.477.298.758.298h6.226z"/><path fill="url(#b)" d="M15.272.857H7.497L0 23.071h7.775l1.596-4.73 5.068 4.73h6.665l-2.707-7.775h-7.998z"/><path fill="url(#c)" d="M17.193 1.613a1.11 1.11 0 0 0-1.052-.756h-7.81.035c.477 0 .9.304 1.052.756l6.748 19.992a1.11 1.11 0 0 1-1.052 1.466h-.12 7.895a1.11 1.11 0 0 0 1.052-1.466z"/><defs><linearGradient id="a" x1="8.247" x2="1.002" y1="1.626" y2="23.03" gradientUnits="userSpaceOnUse"><stop stop-color="#114a8b"/><stop offset="1" stop-color="#0669bc"/></linearGradient><linearGradient id="b" x1="14.042" x2="12.324" y1="15.302" y2="15.888" gradientUnits="userSpaceOnUse"><stop stop-opacity=".3"/><stop offset=".071" stop-opacity=".2"/><stop offset=".321" stop-opacity=".1"/><stop offset=".623" stop-opacity=".05"/><stop offset="1" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="12.841" x2="20.793" y1="1.626" y2="22.814" gradientUnits="userSpaceOnUse"><stop stop-color="#3ccbf4"/><stop offset="1" stop-color="#2892df"/></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
frontend/public/Logos/crew-ai.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24"><title>CrewAI</title><path fill="#461816" d="M19.41 10.783a2.75 2.75 0 0 1 2.471 1.355c.483.806.622 1.772.385 2.68l-.136.522a10 10 0 0 1-3.156 5.058c-.605.517-1.283 1.062-2.083 1.524l-.028.017c-.402.232-.884.511-1.398.756-1.19.602-2.475.997-3.798 1.167-.854.111-1.716.155-2.577.132h-.018a8.6 8.6 0 0 1-5.046-1.87l-.012-.01-.012-.01A8.02 8.02 0 0 1 1.22 17.42a10.9 10.9 0 0 1-.102-3.779A15.6 15.6 0 0 1 2.88 8.4a21.8 21.8 0 0 1 2.432-3.678 15.4 15.4 0 0 1 3.56-3.182A10 10 0 0 1 12.44.104h.004l.003-.002c2.057-.384 3.743.374 5.024 1.26a8.3 8.3 0 0 1 2.395 2.513l.024.04.023.042a5.47 5.47 0 0 1 .508 4.012c-.239.97-.577 1.914-1.01 2.814z"/><path fill="#fff" d="M18.861 13.165a.748.748 0 0 1 1.256.031c.199.332.256.73.159 1.103l-.137.522a7.94 7.94 0 0 1-2.504 4.014c-.572.49-1.138.939-1.774 1.306-.427.247-.857.496-1.303.707a9.6 9.6 0 0 1-3.155.973 14.3 14.3 0 0 1-2.257.116 6.53 6.53 0 0 1-3.837-1.422 5.97 5.97 0 0 1-2.071-3.494 8.9 8.9 0 0 1-.085-3.08 13.6 13.6 0 0 1 1.54-4.568 19.7 19.7 0 0 1 2.212-3.348 13.4 13.4 0 0 1 3.088-2.76 7.9 7.9 0 0 1 2.832-1.14c1.307-.245 2.434.207 3.481.933a6.2 6.2 0 0 1 1.806 1.892c.423.767.536 1.668.314 2.515a12.4 12.4 0 0 1-.99 2.67l-.223.497q-.48 1.07-.97 2.137a.76.76 0 0 1-.97.467 3.39 3.39 0 0 1-2.283-2.49c-.095-.83.04-1.669.39-2.426.288-.746.61-1.477.933-2.208l.248-.563a.53.53 0 0 0-.204-.742 2.35 2.35 0 0 0-1.2.702 25 25 0 0 0-1.614 1.767 21.6 21.6 0 0 0-2.619 4.184 7.6 7.6 0 0 0-.816 2.753 7 7 0 0 0 .07 2.219 2.055 2.055 0 0 0 1.934 1.715c1.801.1 3.59-.363 5.116-1.328a19 19 0 0 0 1.675-1.294c.752-.71 1.376-1.519 1.958-2.36"/></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
1
frontend/public/Logos/dashboards.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="150" fill="none" viewBox="0 0 150 150"><circle cx="75" cy="75" r="70" fill="#f3f4f6"/><g transform="translate(25 25)"><rect width="60" height="55" fill="#fcd34d" rx="6"/><path stroke="#d97706" stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="m10 40 15-15 15 8 15-23"/><rect width="35" height="55" x="65" fill="#6ee7b7" rx="6"/><rect width="20" height="6" x="73" y="10" fill="#059669" rx="3"/><rect width="15" height="6" x="73" y="22" fill="#059669" opacity=".7" rx="3"/><rect width="18" height="6" x="73" y="34" fill="#059669" opacity=".7" rx="3"/><rect width="100" height="40" y="60" fill="#a5b4fc" rx="6"/><rect width="80" height="8" x="10" y="70" fill="#4f46e5" rx="4"/><rect width="50" height="8" x="20" y="83" fill="#6366f1" rx="4"/></g></svg>
|
||||
|
After Width: | Height: | Size: 826 B |
1
frontend/public/Logos/envoy.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill="#b31aab" d="m33.172 61.48.176 7.325 7.797 4.785-.176-7.324Zm19.031 30.504-.176-7.18-6.84-4.206c-.085-.059-.203-.145-.289-.203l.176 7.207Zm-24.355 9.688L10.012 90.715l-.438-18.367 8.758-3.746-.172-7.352-13.969 5.969c-1.074.46-1.714 1.441-1.687 2.566l.523 22.055c.032 1.125.73 2.25 1.836 2.941l21.383 13.149c.992.605 2.215.78 3.203.46a.8.8 0 0 0 .29-.113l13.124-5.593-7.129-4.383Zm0 0"/><path fill="#d163ce" d="M85.488 61.047c-.031-1.328-.843-2.625-2.125-3.43L57.38 41.672l-.813.344.176 7.726 20.57 12.63.493 20.648 7.86 4.812.433-.172ZM54.383 97.289 30.262 82.47l-.582-24.883 11-4.7-.203-8.562-17.082 7.293c-1.25.547-2.008 1.672-1.977 3l.668 29.18c.031 1.324.844 2.625 2.125 3.402l28.281 17.387c1.164.723 2.563.894 3.754.547.117-.028.234-.086.348-.145l16.703-7.12-8.32-5.102Zm0 0"/><path fill="#e13eaf" d="M122.234 40.633 85.98 18.343c-1.335-.808-2.937-1.038-4.304-.605-.145.028-.262.086-.406.145l-35.383 15.11c-1.426.605-2.297 1.902-2.27 3.429l.903 37.371c.03 1.527.96 2.996 2.445 3.89l36.254 22.262c1.336.805 2.937 1.035 4.277.606.145-.031.262-.09.406-.145l35.383-15.11c1.426-.605 2.297-1.933 2.27-3.433l-.875-37.367c-.028-1.473-.961-2.969-2.446-3.863M85.398 91.64 53.891 72.293l-.79-32.496 30.727-13.121 31.512 19.347.785 32.497Zm0 0"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
frontend/public/Logos/fly-io.svg
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
1
frontend/public/Logos/honeycomb.svg
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
1
frontend/public/Logos/litellm.svg
Normal file
|
After Width: | Height: | Size: 19 KiB |
1
frontend/public/Logos/logs.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="150" fill="none" viewBox="0 0 150 150"><circle cx="75" cy="75" r="70" fill="#f3f4f6"/><g transform="translate(25 40)"><rect width="12" height="12" fill="#059669" opacity=".8" rx="3"/><rect width="80" height="12" x="20" fill="#10b981" rx="3"/><rect width="12" height="12" y="22" fill="#059669" opacity=".8" rx="3"/><rect width="65" height="12" x="20" y="22" fill="#10b981" rx="3"/><rect width="12" height="12" y="44" fill="#059669" opacity=".8" rx="3"/><rect width="75" height="12" x="20" y="44" fill="#10b981" rx="3"/><rect width="12" height="12" y="66" fill="#059669" opacity=".8" rx="3"/><rect width="50" height="12" x="20" y="66" fill="#10b981" rx="3"/></g></svg>
|
||||
|
After Width: | Height: | Size: 726 B |
1
frontend/public/Logos/metrics.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="150" fill="none" viewBox="0 0 150 150"><circle cx="75" cy="75" r="70" fill="#f3f4f6"/><defs><linearGradient id="a" x1="0" x2="0" y1="0" y2="1"><stop offset="0%" stop-color="#f59e0b" stop-opacity=".5"/><stop offset="100%" stop-color="#f59e0b" stop-opacity=".05"/></linearGradient></defs><g transform="translate(25 35)"><path stroke="#d1d5db" stroke-linecap="round" stroke-width="2" d="M0 80h100M0 80V0"/><path fill="url(#a)" d="M2 78c18 0 28-28 48-23s30-35 48-40v63z"/><path stroke="#d97706" stroke-linecap="round" stroke-width="5" d="M2 78c18 0 28-28 48-23s30-35 48-40"/><circle cx="50" cy="55" r="4" fill="#f59e0b"/><circle cx="98" cy="15" r="4" fill="#f59e0b"/></g></svg>
|
||||
|
After Width: | Height: | Size: 733 B |
1
frontend/public/Logos/pydantic-ai.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24"><title>PydanticAI</title><path fill="#e72564" d="M13.223 22.86c-.605.83-1.844.83-2.448 0L5.74 15.944a1.514 1.514 0 0 1 .73-2.322l5.035-1.738c.32-.11.668-.11.988 0l5.035 1.738c.962.332 1.329 1.5.73 2.322zm-1.224-1.259 4.688-6.439-4.688-1.618-4.688 1.618L12 21.602z"/><path fill="#e723a0" d="M23.71 13.463c.604.832.221 2.01-.756 2.328l-8.133 2.652a1.514 1.514 0 0 1-1.983-1.412l-.097-5.326c-.006-.338.101-.67.305-.94l3.209-4.25a1.514 1.514 0 0 1 2.434.022l5.022 6.926zm-1.574.775L17.46 7.79l-2.988 3.958.09 4.959z"/><path fill="#e520e9" d="M18.016.591a1.514 1.514 0 0 1 1.98 1.44l.009 8.554a1.514 1.514 0 0 1-1.956 1.45l-5.095-1.554a1.5 1.5 0 0 1-.8-.58l-3.05-4.366a1.514 1.514 0 0 1 .774-2.308zm.25 1.738L10.69 4.783l2.841 4.065 4.744 1.446-.008-7.965z"/><path fill="#e520e9" d="M5.99.595a1.514 1.514 0 0 0-1.98 1.44L4 10.588a1.514 1.514 0 0 0 1.956 1.45l5.095-1.554c.323-.098.605-.303.799-.58l3.052-4.366a1.514 1.514 0 0 0-.775-2.308zm-.25 1.738 7.577 2.454-2.842 4.065-4.743 1.446.007-7.965z"/><path fill="#e723a0" d="M.29 13.461a1.514 1.514 0 0 0 .756 2.329l8.133 2.651a1.514 1.514 0 0 0 1.983-1.412l.097-5.325a1.5 1.5 0 0 0-.305-.94L7.745 6.513a1.514 1.514 0 0 0-2.434.023L.289 13.461zm1.574.776L6.54 7.788l2.988 3.959-.09 4.958z"/><path fill="#ff96d1" d="m16.942 17.751 1.316-1.806q.178-.248.245-.523l-2.63.858-1.627 2.235a1.5 1.5 0 0 0 .575-.072zm-4.196-5.78.033 1.842 1.742.602-.034-1.843-1.741-.6zm7.257-3.622-1.314-1.812a1.5 1.5 0 0 0-.419-.393l.003 2.767 1.624 2.24q.107-.261.108-.566zm-5.038 2.746-1.762-.537 1.11-1.471 1.762.537zm-2.961-1.41 1.056-1.51-1.056-1.51-1.056 1.51zM9.368 3.509c.145-.122.316-.219.51-.282l2.12-.686 2.13.69c.191.062.36.157.503.276l-2.634.853zm1.433 7.053L9.691 9.09l-1.762.537 1.11 1.47 1.762-.537zm-6.696.584L5.733 8.9l.003-2.763c-.16.1-.305.232-.425.398L4.003 8.339l-.002 2.25q.002.299.104.557m7.149.824-1.741.601-.034 1.843 1.742-.601zM9.75 18.513l-1.628-2.237-2.629-.857q.068.276.247.525l1.313 1.804 2.126.693c.192.062.385.085.571.072"/></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
1
frontend/public/Logos/traces.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="150" fill="none" viewBox="0 0 150 150"><circle cx="75" cy="75" r="70" fill="#f3f4f6"/><rect width="100" height="14" x="25" y="45" fill="#4f46e5" rx="4"/><rect width="60" height="14" x="40" y="68" fill="#6366f1" rx="4"/><rect width="20" height="14" x="105" y="91" fill="#818cf8" rx="4"/><path stroke="#c7d2fe" stroke-width="2" d="M35 59v16m5 0h-5M100 59v39m5 0h-5"/></svg>
|
||||
|
After Width: | Height: | Size: 431 B |
50
frontend/scripts/update-registry.js
Normal file
@@ -0,0 +1,50 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires, import/no-dynamic-require, simple-import-sort/imports, simple-import-sort/exports */
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// 1. Define paths
|
||||
const packageJsonPath = path.resolve(__dirname, '../package.json');
|
||||
const registryPath = path.resolve(
|
||||
__dirname,
|
||||
'../src/auto-import-registry.d.ts',
|
||||
);
|
||||
|
||||
// 2. Read package.json
|
||||
const packageJson = require(packageJsonPath);
|
||||
|
||||
// 3. Combine dependencies and devDependencies
|
||||
const allDeps = {
|
||||
...packageJson.dependencies,
|
||||
...packageJson.devDependencies,
|
||||
};
|
||||
|
||||
// 4. Filter for @signozhq packages
|
||||
const signozPackages = Object.keys(allDeps).filter((dep) =>
|
||||
dep.startsWith('@signozhq/'),
|
||||
);
|
||||
|
||||
// 5. Generate file content
|
||||
const fileContent = `// -------------------------------------------------------------------------
|
||||
// AUTO-GENERATED FILE
|
||||
// -------------------------------------------------------------------------
|
||||
// This file is generated by scripts/update-registry.js automatically
|
||||
// whenever you run 'yarn install' or 'npm install'.
|
||||
//
|
||||
// It forces VS Code to index these specific packages to fix auto-import
|
||||
// performance issues in TypeScript 4.x.
|
||||
//
|
||||
// PR for reference: https://github.com/SigNoz/signoz/pull/9694
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
${signozPackages.map((pkg) => `import '${pkg}';`).join('\n')}
|
||||
`;
|
||||
|
||||
// 6. Write the file
|
||||
try {
|
||||
fs.writeFileSync(registryPath, fileContent);
|
||||
console.log(
|
||||
`✅ Auto-import registry updated with ${signozPackages.length} @signozhq packages.`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('❌ Failed to update auto-import registry:', err);
|
||||
}
|
||||
@@ -245,6 +245,14 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
history.replace(newLocation);
|
||||
return;
|
||||
}
|
||||
|
||||
// if the current route is public dashboard then don't redirect to login
|
||||
const isPublicDashboard = currentRoute?.path === ROUTES.PUBLIC_DASHBOARD;
|
||||
|
||||
if (isPublicDashboard) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if the current route
|
||||
if (currentRoute) {
|
||||
const { isPrivate, key } = currentRoute;
|
||||
|
||||
@@ -4,7 +4,7 @@ import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import AppLoading from 'components/AppLoading/AppLoading';
|
||||
import KBarCommandPalette from 'components/KBarCommandPalette/KBarCommandPalette';
|
||||
import { CmdKPalette } from 'components/cmdKPalette/cmdKPalette';
|
||||
import NotFound from 'components/NotFound';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
@@ -22,12 +22,11 @@ import { StatusCodes } from 'http-status-codes';
|
||||
import history from 'lib/history';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import posthog from 'posthog-js';
|
||||
import AlertRuleProvider from 'providers/Alert';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { IUser } from 'providers/App/types';
|
||||
import { CmdKProvider } from 'providers/cmdKProvider';
|
||||
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
|
||||
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
|
||||
import { KBarCommandPaletteProvider } from 'providers/KBarCommandPaletteProvider';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
import { Suspense, useCallback, useEffect, useState } from 'react';
|
||||
@@ -214,7 +213,10 @@ function App(): JSX.Element {
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname === ROUTES.ONBOARDING) {
|
||||
if (
|
||||
pathname === ROUTES.ONBOARDING ||
|
||||
pathname.startsWith('/public/dashboard/')
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
window.Pylon('hideChatBubble');
|
||||
@@ -362,35 +364,33 @@ function App(): JSX.Element {
|
||||
<ConfigProvider theme={themeConfig}>
|
||||
<Router history={history}>
|
||||
<CompatRouter>
|
||||
<KBarCommandPaletteProvider>
|
||||
<KBarCommandPalette />
|
||||
<CmdKProvider>
|
||||
<NotificationProvider>
|
||||
<ErrorModalProvider>
|
||||
{isLoggedInState && <CmdKPalette userRole={user.role} />}
|
||||
<PrivateRoute>
|
||||
<ResourceProvider>
|
||||
<QueryBuilderProvider>
|
||||
<DashboardProvider>
|
||||
<KeyboardHotkeysProvider>
|
||||
<AlertRuleProvider>
|
||||
<AppLayout>
|
||||
<PreferenceContextProvider>
|
||||
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
|
||||
<Switch>
|
||||
{routes.map(({ path, component, exact }) => (
|
||||
<Route
|
||||
key={`${path}`}
|
||||
exact={exact}
|
||||
path={path}
|
||||
component={component}
|
||||
/>
|
||||
))}
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route path="*" component={NotFound} />
|
||||
</Switch>
|
||||
</Suspense>
|
||||
</PreferenceContextProvider>
|
||||
</AppLayout>
|
||||
</AlertRuleProvider>
|
||||
<AppLayout>
|
||||
<PreferenceContextProvider>
|
||||
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
|
||||
<Switch>
|
||||
{routes.map(({ path, component, exact }) => (
|
||||
<Route
|
||||
key={`${path}`}
|
||||
exact={exact}
|
||||
path={path}
|
||||
component={component}
|
||||
/>
|
||||
))}
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route path="*" component={NotFound} />
|
||||
</Switch>
|
||||
</Suspense>
|
||||
</PreferenceContextProvider>
|
||||
</AppLayout>
|
||||
</KeyboardHotkeysProvider>
|
||||
</DashboardProvider>
|
||||
</QueryBuilderProvider>
|
||||
@@ -398,7 +398,7 @@ function App(): JSX.Element {
|
||||
</PrivateRoute>
|
||||
</ErrorModalProvider>
|
||||
</NotificationProvider>
|
||||
</KBarCommandPaletteProvider>
|
||||
</CmdKProvider>
|
||||
</CompatRouter>
|
||||
</Router>
|
||||
</ConfigProvider>
|
||||
|
||||
@@ -295,3 +295,10 @@ export const MetricsExplorer = Loadable(
|
||||
export const ApiMonitoring = Loadable(
|
||||
() => import(/* webpackChunkName: "ApiMonitoring" */ 'pages/ApiMonitoring'),
|
||||
);
|
||||
|
||||
export const PublicDashboardPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "Public Dashboard Page" */ 'pages/PublicDashboard'
|
||||
),
|
||||
);
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
OrgOnboarding,
|
||||
PasswordReset,
|
||||
PipelinePage,
|
||||
PublicDashboardPage,
|
||||
ServiceMapPage,
|
||||
ServiceMetricsPage,
|
||||
ServicesTablePage,
|
||||
@@ -169,6 +170,13 @@ const routes: AppRoutes[] = [
|
||||
isPrivate: true,
|
||||
key: 'DASHBOARD',
|
||||
},
|
||||
{
|
||||
path: ROUTES.PUBLIC_DASHBOARD,
|
||||
exact: false,
|
||||
component: PublicDashboardPage,
|
||||
isPrivate: false,
|
||||
key: 'PUBLIC_DASHBOARD',
|
||||
},
|
||||
{
|
||||
path: ROUTES.DASHBOARD_WIDGET,
|
||||
exact: true,
|
||||
|
||||
28
frontend/src/api/dashboard/public/createPublicDashboard.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { CreatePublicDashboardProps } from 'types/api/dashboard/public/create';
|
||||
|
||||
const createPublicDashboard = async (
|
||||
props: CreatePublicDashboardProps,
|
||||
): Promise<SuccessResponseV2<CreatePublicDashboardProps>> => {
|
||||
|
||||
const { dashboardId, timeRangeEnabled = false, defaultTimeRange = '30m' } = props;
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`/dashboards/${dashboardId}/public`,
|
||||
{ timeRangeEnabled, defaultTimeRange },
|
||||
);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default createPublicDashboard;
|
||||
20
frontend/src/api/dashboard/public/getPublicDashboardData.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { GetPublicDashboardDataProps, PayloadProps,PublicDashboardDataProps } from 'types/api/dashboard/public/get';
|
||||
|
||||
const getPublicDashboardData = async (props: GetPublicDashboardDataProps): Promise<SuccessResponseV2<PublicDashboardDataProps>> => {
|
||||
try {
|
||||
const response = await axios.get<PayloadProps>(`/public/dashboards/${props.id}`);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default getPublicDashboardData;
|
||||
20
frontend/src/api/dashboard/public/getPublicDashboardMeta.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { GetPublicDashboardMetaProps, PayloadProps,PublicDashboardMetaProps } from 'types/api/dashboard/public/getMeta';
|
||||
|
||||
const getPublicDashboardMeta = async (props: GetPublicDashboardMetaProps): Promise<SuccessResponseV2<PublicDashboardMetaProps>> => {
|
||||
try {
|
||||
const response = await axios.get<PayloadProps>(`/dashboards/${props.id}/public`);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default getPublicDashboardMeta;
|
||||
@@ -0,0 +1,27 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { MetricRangePayloadV5 } from 'api/v5/v5';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { GetPublicDashboardWidgetDataProps } from 'types/api/dashboard/public/getWidgetData';
|
||||
|
||||
|
||||
const getPublicDashboardWidgetData = async (props: GetPublicDashboardWidgetDataProps): Promise<SuccessResponseV2<MetricRangePayloadV5>> => {
|
||||
try {
|
||||
const response = await axios.get(`/public/dashboards/${props.id}/widgets/${props.index}/query_range`, {
|
||||
params: {
|
||||
startTime: props.startTime,
|
||||
endTime: props.endTime,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default getPublicDashboardWidgetData;
|
||||
@@ -0,0 +1,22 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps,RevokePublicDashboardAccessProps } from 'types/api/dashboard/public/delete';
|
||||
|
||||
const revokePublicDashboardAccess = async (
|
||||
props: RevokePublicDashboardAccessProps,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
try {
|
||||
const response = await axios.delete<PayloadProps>(`/dashboards/${props.id}/public`);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default revokePublicDashboardAccess;
|
||||
28
frontend/src/api/dashboard/public/updatePublicDashboard.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { UpdatePublicDashboardProps } from 'types/api/dashboard/public/update';
|
||||
|
||||
const updatePublicDashboard = async (
|
||||
props: UpdatePublicDashboardProps,
|
||||
): Promise<SuccessResponseV2<UpdatePublicDashboardProps>> => {
|
||||
|
||||
const { dashboardId, timeRangeEnabled = false, defaultTimeRange = '30m' } = props;
|
||||
|
||||
try {
|
||||
const response = await axios.put(
|
||||
`/dashboards/${dashboardId}/public`,
|
||||
{ timeRangeEnabled, defaultTimeRange },
|
||||
);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default updatePublicDashboard;
|
||||
25
frontend/src/auto-import-registry.d.ts
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
// -------------------------------------------------------------------------
|
||||
// AUTO-GENERATED FILE
|
||||
// -------------------------------------------------------------------------
|
||||
// This file is generated by scripts/update-registry.js automatically
|
||||
// whenever you run 'yarn install' or 'npm install'.
|
||||
//
|
||||
// It forces VS Code to index these specific packages to fix auto-import
|
||||
// performance issues in TypeScript 4.x.
|
||||
//
|
||||
// PR for reference: https://github.com/SigNoz/signoz/pull/9694
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
import '@signozhq/badge';
|
||||
import '@signozhq/button';
|
||||
import '@signozhq/calendar';
|
||||
import '@signozhq/callout';
|
||||
import '@signozhq/checkbox';
|
||||
import '@signozhq/command';
|
||||
import '@signozhq/design-tokens';
|
||||
import '@signozhq/input';
|
||||
import '@signozhq/popover';
|
||||
import '@signozhq/resizable';
|
||||
import '@signozhq/sonner';
|
||||
import '@signozhq/table';
|
||||
import '@signozhq/tooltip';
|
||||
@@ -62,6 +62,8 @@ interface CustomTimePickerProps {
|
||||
showLiveLogs?: boolean;
|
||||
onGoLive?: () => void;
|
||||
onExitLiveLogs?: () => void;
|
||||
/** When false, hides the "Recently Used" time ranges section */
|
||||
showRecentlyUsed?: boolean;
|
||||
}
|
||||
|
||||
function CustomTimePicker({
|
||||
@@ -81,6 +83,7 @@ function CustomTimePicker({
|
||||
onGoLive,
|
||||
onExitLiveLogs,
|
||||
showLiveLogs,
|
||||
showRecentlyUsed = true,
|
||||
}: CustomTimePickerProps): JSX.Element {
|
||||
const [
|
||||
selectedTimePlaceholderValue,
|
||||
@@ -395,6 +398,7 @@ function CustomTimePicker({
|
||||
setActiveView={setActiveView}
|
||||
setIsOpenedFromFooter={setIsOpenedFromFooter}
|
||||
isOpenedFromFooter={isOpenedFromFooter}
|
||||
showRecentlyUsed={showRecentlyUsed}
|
||||
/>
|
||||
) : (
|
||||
content
|
||||
@@ -464,4 +468,5 @@ CustomTimePicker.defaultProps = {
|
||||
onCustomTimeStatusUpdate: noop,
|
||||
onExitLiveLogs: noop,
|
||||
showLiveLogs: false,
|
||||
showRecentlyUsed: true,
|
||||
};
|
||||
|
||||
@@ -47,6 +47,7 @@ interface CustomTimePickerPopoverContentProps {
|
||||
isOpenedFromFooter: boolean;
|
||||
setIsOpenedFromFooter: Dispatch<SetStateAction<boolean>>;
|
||||
onExitLiveLogs: () => void;
|
||||
showRecentlyUsed: boolean;
|
||||
}
|
||||
|
||||
interface RecentlyUsedDateTimeRange {
|
||||
@@ -72,6 +73,7 @@ function CustomTimePickerPopoverContent({
|
||||
isOpenedFromFooter,
|
||||
setIsOpenedFromFooter,
|
||||
onExitLiveLogs,
|
||||
showRecentlyUsed = true,
|
||||
}: CustomTimePickerPopoverContentProps): JSX.Element {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
@@ -224,33 +226,35 @@ function CustomTimePickerPopoverContent({
|
||||
<div>{getTimeChips(RelativeDurationSuggestionOptions)}</div>
|
||||
</div>
|
||||
|
||||
<div className="recently-used-container">
|
||||
<div className="time-heading">RECENTLY USED</div>
|
||||
<div className="recently-used-range">
|
||||
{recentlyUsedTimeRanges.map((range: RecentlyUsedDateTimeRange) => (
|
||||
<div
|
||||
className="recently-used-range-item"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
{showRecentlyUsed && (
|
||||
<div className="recently-used-container">
|
||||
<div className="time-heading">RECENTLY USED</div>
|
||||
<div className="recently-used-range">
|
||||
{recentlyUsedTimeRanges.map((range: RecentlyUsedDateTimeRange) => (
|
||||
<div
|
||||
className="recently-used-range-item"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
handleExitLiveLogs();
|
||||
onCustomDateHandler([dayjs(range.from), dayjs(range.to)]);
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
key={range.value}
|
||||
onClick={(): void => {
|
||||
handleExitLiveLogs();
|
||||
onCustomDateHandler([dayjs(range.from), dayjs(range.to)]);
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
key={range.value}
|
||||
onClick={(): void => {
|
||||
handleExitLiveLogs();
|
||||
onCustomDateHandler([dayjs(range.from), dayjs(range.to)]);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
{range.label}
|
||||
</div>
|
||||
))}
|
||||
}}
|
||||
>
|
||||
{range.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import { getYAxisFormattedValue, PrecisionOptionsEnum } from '../yAxisConfig';
|
||||
import { PrecisionOptionsEnum } from '../types';
|
||||
import { getYAxisFormattedValue } from '../yAxisConfig';
|
||||
|
||||
const testFullPrecisionGetYAxisFormattedValue = (
|
||||
value: string,
|
||||
|
||||
@@ -78,3 +78,18 @@ export interface ITimeRange {
|
||||
minTime: number | null;
|
||||
maxTime: number | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_SIGNIFICANT_DIGITS = 15;
|
||||
|
||||
// max decimals to keep should not exceed 15 decimal places to avoid floating point precision issues
|
||||
export const MAX_DECIMALS = 15;
|
||||
|
||||
export enum PrecisionOptionsEnum {
|
||||
ZERO = 0,
|
||||
ONE = 1,
|
||||
TWO = 2,
|
||||
THREE = 3,
|
||||
FOUR = 4,
|
||||
FULL = 'full',
|
||||
}
|
||||
export type PrecisionOption = 0 | 1 | 2 | 3 | 4 | PrecisionOptionsEnum.FULL;
|
||||
|
||||
@@ -16,8 +16,12 @@ import {
|
||||
} from './Plugin/IntersectionCursor';
|
||||
import {
|
||||
CustomChartOptions,
|
||||
DEFAULT_SIGNIFICANT_DIGITS,
|
||||
GraphOnClickHandler,
|
||||
IAxisTimeConfig,
|
||||
MAX_DECIMALS,
|
||||
PrecisionOption,
|
||||
PrecisionOptionsEnum,
|
||||
StaticLineProps,
|
||||
} from './types';
|
||||
import { getToolTipValue, getYAxisFormattedValue } from './yAxisConfig';
|
||||
@@ -242,3 +246,68 @@ declare module 'chart.js' {
|
||||
custom: TooltipPositionerFunction<ChartType>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a number for display, preserving leading zeros after the decimal point
|
||||
* and showing up to DEFAULT_SIGNIFICANT_DIGITS digits after the first non-zero decimal digit.
|
||||
* It avoids scientific notation and removes unnecessary trailing zeros.
|
||||
*
|
||||
* @example
|
||||
* formatDecimalWithLeadingZeros(1.2345); // "1.2345"
|
||||
* formatDecimalWithLeadingZeros(0.0012345); // "0.0012345"
|
||||
* formatDecimalWithLeadingZeros(5.0); // "5"
|
||||
*
|
||||
* @param value The number to format.
|
||||
* @returns The formatted string.
|
||||
*/
|
||||
export const formatDecimalWithLeadingZeros = (
|
||||
value: number,
|
||||
precision: PrecisionOption,
|
||||
): string => {
|
||||
if (value === 0) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
// Use toLocaleString to get a full decimal representation without scientific notation.
|
||||
const numStr = value.toLocaleString('en-US', {
|
||||
useGrouping: false,
|
||||
maximumFractionDigits: 20,
|
||||
});
|
||||
|
||||
const [integerPart, decimalPart = ''] = numStr.split('.');
|
||||
|
||||
// If there's no decimal part, the integer part is the result.
|
||||
if (!decimalPart) {
|
||||
return integerPart;
|
||||
}
|
||||
|
||||
// Find the index of the first non-zero digit in the decimal part.
|
||||
const firstNonZeroIndex = decimalPart.search(/[^0]/);
|
||||
|
||||
// If the decimal part consists only of zeros, return just the integer part.
|
||||
if (firstNonZeroIndex === -1) {
|
||||
return integerPart;
|
||||
}
|
||||
|
||||
// Determine the number of decimals to keep: leading zeros + up to N significant digits.
|
||||
const significantDigits =
|
||||
precision === PrecisionOptionsEnum.FULL
|
||||
? DEFAULT_SIGNIFICANT_DIGITS
|
||||
: precision;
|
||||
const decimalsToKeep = firstNonZeroIndex + (significantDigits || 0);
|
||||
|
||||
// max decimals to keep should not exceed 15 decimal places to avoid floating point precision issues
|
||||
const finalDecimalsToKeep = Math.min(decimalsToKeep, MAX_DECIMALS);
|
||||
const trimmedDecimalPart = decimalPart.substring(0, finalDecimalsToKeep);
|
||||
|
||||
// If precision is 0, we drop the decimal part entirely.
|
||||
if (precision === 0) {
|
||||
return integerPart;
|
||||
}
|
||||
|
||||
// Remove any trailing zeros from the result to keep it clean.
|
||||
const finalDecimalPart = trimmedDecimalPart.replace(/0+$/, '');
|
||||
|
||||
// Return the integer part, or the integer and decimal parts combined.
|
||||
return finalDecimalPart ? `${integerPart}.${finalDecimalPart}` : integerPart;
|
||||
};
|
||||
|
||||
@@ -1,86 +1,17 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { formattedValueToString, getValueFormat } from '@grafana/data';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
|
||||
import { isUniversalUnit } from 'components/YAxisUnitSelector/utils';
|
||||
import { isNaN } from 'lodash-es';
|
||||
|
||||
const DEFAULT_SIGNIFICANT_DIGITS = 15;
|
||||
// max decimals to keep should not exceed 15 decimal places to avoid floating point precision issues
|
||||
const MAX_DECIMALS = 15;
|
||||
|
||||
export enum PrecisionOptionsEnum {
|
||||
ZERO = 0,
|
||||
ONE = 1,
|
||||
TWO = 2,
|
||||
THREE = 3,
|
||||
FOUR = 4,
|
||||
FULL = 'full',
|
||||
}
|
||||
export type PrecisionOption = 0 | 1 | 2 | 3 | 4 | PrecisionOptionsEnum.FULL;
|
||||
|
||||
/**
|
||||
* Formats a number for display, preserving leading zeros after the decimal point
|
||||
* and showing up to DEFAULT_SIGNIFICANT_DIGITS digits after the first non-zero decimal digit.
|
||||
* It avoids scientific notation and removes unnecessary trailing zeros.
|
||||
*
|
||||
* @example
|
||||
* formatDecimalWithLeadingZeros(1.2345); // "1.2345"
|
||||
* formatDecimalWithLeadingZeros(0.0012345); // "0.0012345"
|
||||
* formatDecimalWithLeadingZeros(5.0); // "5"
|
||||
*
|
||||
* @param value The number to format.
|
||||
* @returns The formatted string.
|
||||
*/
|
||||
const formatDecimalWithLeadingZeros = (
|
||||
value: number,
|
||||
precision: PrecisionOption,
|
||||
): string => {
|
||||
if (value === 0) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
// Use toLocaleString to get a full decimal representation without scientific notation.
|
||||
const numStr = value.toLocaleString('en-US', {
|
||||
useGrouping: false,
|
||||
maximumFractionDigits: 20,
|
||||
});
|
||||
|
||||
const [integerPart, decimalPart = ''] = numStr.split('.');
|
||||
|
||||
// If there's no decimal part, the integer part is the result.
|
||||
if (!decimalPart) {
|
||||
return integerPart;
|
||||
}
|
||||
|
||||
// Find the index of the first non-zero digit in the decimal part.
|
||||
const firstNonZeroIndex = decimalPart.search(/[^0]/);
|
||||
|
||||
// If the decimal part consists only of zeros, return just the integer part.
|
||||
if (firstNonZeroIndex === -1) {
|
||||
return integerPart;
|
||||
}
|
||||
|
||||
// Determine the number of decimals to keep: leading zeros + up to N significant digits.
|
||||
const significantDigits =
|
||||
precision === PrecisionOptionsEnum.FULL
|
||||
? DEFAULT_SIGNIFICANT_DIGITS
|
||||
: precision;
|
||||
const decimalsToKeep = firstNonZeroIndex + (significantDigits || 0);
|
||||
|
||||
// max decimals to keep should not exceed 15 decimal places to avoid floating point precision issues
|
||||
const finalDecimalsToKeep = Math.min(decimalsToKeep, MAX_DECIMALS);
|
||||
const trimmedDecimalPart = decimalPart.substring(0, finalDecimalsToKeep);
|
||||
|
||||
// If precision is 0, we drop the decimal part entirely.
|
||||
if (precision === 0) {
|
||||
return integerPart;
|
||||
}
|
||||
|
||||
// Remove any trailing zeros from the result to keep it clean.
|
||||
const finalDecimalPart = trimmedDecimalPart.replace(/0+$/, '');
|
||||
|
||||
// Return the integer part, or the integer and decimal parts combined.
|
||||
return finalDecimalPart ? `${integerPart}.${finalDecimalPart}` : integerPart;
|
||||
};
|
||||
import { formatUniversalUnit } from '../YAxisUnitSelector/formatter';
|
||||
import {
|
||||
DEFAULT_SIGNIFICANT_DIGITS,
|
||||
PrecisionOption,
|
||||
PrecisionOptionsEnum,
|
||||
} from './types';
|
||||
import { formatDecimalWithLeadingZeros } from './utils';
|
||||
|
||||
/**
|
||||
* Formats a Y-axis value based on a given format string.
|
||||
@@ -126,6 +57,17 @@ export const getYAxisFormattedValue = (
|
||||
return formatDecimalWithLeadingZeros(numValue, precision);
|
||||
}
|
||||
|
||||
// Separate logic for universal units// Separate logic for universal units
|
||||
if (format && isUniversalUnit(format)) {
|
||||
const decimals = computeDecimals();
|
||||
return formatUniversalUnit(
|
||||
numValue,
|
||||
format as UniversalYAxisUnit,
|
||||
precision,
|
||||
decimals,
|
||||
);
|
||||
}
|
||||
|
||||
const formatter = getValueFormat(format);
|
||||
const formattedValue = formatter(numValue, computeDecimals(), undefined);
|
||||
if (formattedValue.text && formattedValue.text.includes('.')) {
|
||||
@@ -134,6 +76,7 @@ export const getYAxisFormattedValue = (
|
||||
precision,
|
||||
);
|
||||
}
|
||||
|
||||
return formattedValueToString(formattedValue);
|
||||
} catch (error) {
|
||||
Sentry.captureEvent({
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
.kbar-command-palette__positioner {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(6px);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.kbar-command-palette__animator {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.kbar-command-palette__card {
|
||||
background: var(--bg-ink-500);
|
||||
color: var(--text-vanilla-100);
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.kbar-command-palette__search {
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border-ink-200);
|
||||
color: var(--text-vanilla-100);
|
||||
outline: none;
|
||||
background-color: var(--bg-ink-500);
|
||||
}
|
||||
|
||||
.kbar-command-palette__section {
|
||||
padding: 8px 16px 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-robin-500);
|
||||
font-family: 'Inter', sans-serif;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.kbar-command-palette__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.kbar-command-palette__item:hover,
|
||||
.kbar-command-palette__item--active {
|
||||
background: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.kbar-command-palette__icon {
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.kbar-command-palette__shortcut {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.kbar-command-palette__key {
|
||||
padding: 2px 6px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-ink-300);
|
||||
color: var(--text-vanilla-300);
|
||||
text-transform: uppercase;
|
||||
font-family: 'Space Mono', monospace;
|
||||
}
|
||||
|
||||
.kbar-command-palette__results-container {
|
||||
div {
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.3rem;
|
||||
height: 0.3rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-slate-300);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--bg-slate-200);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.kbar-command-palette__positioner {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.kbar-command-palette__card {
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.kbar-command-palette__search {
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
color: var(--text-ink-500);
|
||||
background-color: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
.kbar-command-palette__item {
|
||||
color: var(--text-ink-500);
|
||||
}
|
||||
|
||||
.kbar-command-palette__item:hover,
|
||||
.kbar-command-palette__item--active {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.kbar-command-palette__icon {
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.kbar-command-palette__key {
|
||||
background: #eee;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.kbar-command-palette__results-container {
|
||||
div {
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import './KBarCommandPalette.scss';
|
||||
|
||||
import {
|
||||
KBarAnimator,
|
||||
KBarPortal,
|
||||
KBarPositioner,
|
||||
KBarResults,
|
||||
KBarSearch,
|
||||
useMatches,
|
||||
} from 'kbar';
|
||||
|
||||
function Results(): JSX.Element {
|
||||
const { results } = useMatches();
|
||||
|
||||
const renderResults = ({
|
||||
item,
|
||||
active,
|
||||
}: {
|
||||
item: any;
|
||||
active: boolean;
|
||||
}): JSX.Element =>
|
||||
typeof item === 'string' ? (
|
||||
<div className="kbar-command-palette__section">{item}</div>
|
||||
) : (
|
||||
<div
|
||||
className={`kbar-command-palette__item ${
|
||||
active ? 'kbar-command-palette__item--active' : ''
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.name}</span>
|
||||
{item.shortcut?.length ? (
|
||||
<span className="kbar-command-palette__shortcut">
|
||||
{item.shortcut.map((sc: string) => (
|
||||
<kbd key={sc} className="kbar-command-palette__key">
|
||||
{sc}
|
||||
</kbd>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="kbar-command-palette__results-container">
|
||||
<KBarResults items={results} onRender={renderResults} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KBarCommandPalette(): JSX.Element {
|
||||
return (
|
||||
<KBarPortal>
|
||||
<KBarPositioner className="kbar-command-palette__positioner">
|
||||
<KBarAnimator className="kbar-command-palette__animator">
|
||||
<div className="kbar-command-palette__card">
|
||||
<KBarSearch
|
||||
className="kbar-command-palette__search"
|
||||
placeholder="Search or type a command..."
|
||||
/>
|
||||
<Results />
|
||||
</div>
|
||||
</KBarAnimator>
|
||||
</KBarPositioner>
|
||||
</KBarPortal>
|
||||
);
|
||||
}
|
||||
|
||||
export default KBarCommandPalette;
|
||||
@@ -0,0 +1,16 @@
|
||||
.overflow-input {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.overflow-input-mirror {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
white-space: pre;
|
||||
pointer-events: none;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
|
||||
import OverflowInputToolTip from './OverflowInputToolTip';
|
||||
|
||||
const TOOLTIP_INNER_SELECTOR = '.ant-tooltip-inner';
|
||||
// Utility to mock overflow behaviour on inputs / elements.
|
||||
// Stubs HTMLElement.prototype.clientWidth, scrollWidth and offsetWidth used by component.
|
||||
function mockOverflow(clientWidth: number, scrollWidth: number): void {
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
|
||||
configurable: true,
|
||||
value: clientWidth,
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {
|
||||
configurable: true,
|
||||
value: scrollWidth,
|
||||
});
|
||||
// mirror.offsetWidth is used to compute mirrorWidth = offsetWidth + 24.
|
||||
// Use clientWidth so the mirror measurement aligns with the mocked client width in tests.
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
|
||||
configurable: true,
|
||||
value: clientWidth,
|
||||
});
|
||||
}
|
||||
|
||||
function queryTooltipInner(): HTMLElement | null {
|
||||
// find element that has role="tooltip" (could be the inner itself)
|
||||
const tooltip = document.querySelector<HTMLElement>('[role="tooltip"]');
|
||||
if (!tooltip) return document.querySelector(TOOLTIP_INNER_SELECTOR);
|
||||
|
||||
// if the role element is already the inner, return it; otherwise return its descendant
|
||||
if (tooltip.classList.contains('ant-tooltip-inner')) return tooltip;
|
||||
return (
|
||||
(tooltip.querySelector(TOOLTIP_INNER_SELECTOR) as HTMLElement) ??
|
||||
document.querySelector(TOOLTIP_INNER_SELECTOR)
|
||||
);
|
||||
}
|
||||
|
||||
describe('OverflowInputToolTip', () => {
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('shows tooltip when content overflows and input is clamped at maxAutoWidth', async () => {
|
||||
mockOverflow(150, 250); // clientWidth >= maxAutoWidth (150), scrollWidth > clientWidth
|
||||
|
||||
render(<OverflowInputToolTip value="Very long overflowing text" />);
|
||||
|
||||
await userEvent.hover(screen.getByRole('textbox'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryTooltipInner()).not.toBeNull();
|
||||
});
|
||||
|
||||
const tooltipInner = queryTooltipInner();
|
||||
if (!tooltipInner) throw new Error('Tooltip inner not found');
|
||||
expect(
|
||||
within(tooltipInner).getByText('Very long overflowing text'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does NOT show tooltip when content does not overflow', async () => {
|
||||
mockOverflow(150, 100); // content fits (scrollWidth <= clientWidth)
|
||||
|
||||
render(<OverflowInputToolTip value="Short text" />);
|
||||
|
||||
await userEvent.hover(screen.getByRole('textbox'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryTooltipInner()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('does NOT show tooltip when content overflows but input is NOT at maxAutoWidth', async () => {
|
||||
mockOverflow(100, 250); // clientWidth < maxAutoWidth (150), scrollWidth > clientWidth
|
||||
|
||||
render(<OverflowInputToolTip value="Long but input not clamped" />);
|
||||
|
||||
await userEvent.hover(screen.getByRole('textbox'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryTooltipInner()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('uncontrolled input allows typing', async () => {
|
||||
render(<OverflowInputToolTip defaultValue="Init" />);
|
||||
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
await userEvent.type(input, 'ABC');
|
||||
|
||||
expect(input).toHaveValue('InitABC');
|
||||
});
|
||||
|
||||
test('disabled input never shows tooltip even if overflowing', async () => {
|
||||
mockOverflow(150, 300);
|
||||
|
||||
render(<OverflowInputToolTip value="Overflowing!" disabled />);
|
||||
|
||||
await userEvent.hover(screen.getByRole('textbox'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryTooltipInner()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('renders mirror span and input correctly (structural assertions instead of snapshot)', () => {
|
||||
const { container } = render(<OverflowInputToolTip value="Snapshot" />);
|
||||
const mirror = container.querySelector('.overflow-input-mirror');
|
||||
const input = container.querySelector('input') as HTMLInputElement | null;
|
||||
|
||||
expect(mirror).toBeTruthy();
|
||||
expect(mirror?.textContent).toBe('Snapshot');
|
||||
expect(input).toBeTruthy();
|
||||
expect(input?.value).toBe('Snapshot');
|
||||
|
||||
// width should be set inline (component calculates width on mount)
|
||||
expect(input?.getAttribute('style')).toContain('width:');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/* eslint-disable react/require-default-props */
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
|
||||
import './OverflowInputToolTip.scss';
|
||||
|
||||
import { Input, InputProps, InputRef, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export interface OverflowTooltipInputProps extends InputProps {
|
||||
tooltipPlacement?: 'top' | 'bottom' | 'left' | 'right';
|
||||
minAutoWidth?: number;
|
||||
maxAutoWidth?: number;
|
||||
}
|
||||
|
||||
function OverflowInputToolTip({
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
disabled = false,
|
||||
tooltipPlacement = 'top',
|
||||
className,
|
||||
minAutoWidth = 70,
|
||||
maxAutoWidth = 150,
|
||||
...rest
|
||||
}: OverflowTooltipInputProps): JSX.Element {
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const mirrorRef = useRef<HTMLSpanElement | null>(null);
|
||||
const [isOverflowing, setIsOverflowing] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const input = inputRef.current?.input;
|
||||
const mirror = mirrorRef.current;
|
||||
if (!input || !mirror) {
|
||||
setIsOverflowing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
mirror.textContent = String(value ?? '') || ' ';
|
||||
const mirrorWidth = mirror.offsetWidth + 24;
|
||||
const newWidth = Math.min(maxAutoWidth, Math.max(minAutoWidth, mirrorWidth));
|
||||
input.style.width = `${newWidth}px`;
|
||||
|
||||
// consider clamped when mirrorWidth reaches maxAutoWidth (allow -5px tolerance)
|
||||
const isClamped = mirrorWidth >= maxAutoWidth - 5;
|
||||
const overflow = input.scrollWidth > input.clientWidth && isClamped;
|
||||
|
||||
setIsOverflowing(overflow);
|
||||
}, [value, disabled, minAutoWidth, maxAutoWidth]);
|
||||
|
||||
const tooltipTitle = !disabled && isOverflowing ? String(value ?? '') : '';
|
||||
|
||||
return (
|
||||
<>
|
||||
<span ref={mirrorRef} aria-hidden className="overflow-input-mirror" />
|
||||
<Tooltip title={tooltipTitle} placement={tooltipPlacement}>
|
||||
<Input
|
||||
{...rest}
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
ref={inputRef}
|
||||
className={cx('overflow-input', className)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
OverflowInputToolTip.displayName = 'OverflowInputToolTip';
|
||||
|
||||
export default OverflowInputToolTip;
|
||||
3
frontend/src/components/OverflowInputToolTip/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import OverflowInputToolTip from './OverflowInputToolTip';
|
||||
|
||||
export default OverflowInputToolTip;
|
||||
@@ -3,9 +3,9 @@ import './styles.scss';
|
||||
import { Select } from 'antd';
|
||||
import { DefaultOptionType } from 'antd/es/select';
|
||||
|
||||
import { UniversalYAxisUnitMappings, Y_AXIS_CATEGORIES } from './constants';
|
||||
import { UniversalYAxisUnitMappings } from './constants';
|
||||
import { UniversalYAxisUnit, YAxisUnitSelectorProps } from './types';
|
||||
import { mapMetricUnitToUniversalUnit } from './utils';
|
||||
import { getYAxisCategories, mapMetricUnitToUniversalUnit } from './utils';
|
||||
|
||||
function YAxisUnitSelector({
|
||||
value,
|
||||
@@ -13,6 +13,7 @@ function YAxisUnitSelector({
|
||||
placeholder = 'Please select a unit',
|
||||
loading = false,
|
||||
'data-testid': dataTestId,
|
||||
source,
|
||||
}: YAxisUnitSelectorProps): JSX.Element {
|
||||
const universalUnit = mapMetricUnitToUniversalUnit(value);
|
||||
|
||||
@@ -37,6 +38,8 @@ function YAxisUnitSelector({
|
||||
return aliases.some((alias) => alias.toLowerCase().includes(search));
|
||||
};
|
||||
|
||||
const categories = getYAxisCategories(source);
|
||||
|
||||
return (
|
||||
<div className="y-axis-unit-selector-component">
|
||||
<Select
|
||||
@@ -48,7 +51,7 @@ function YAxisUnitSelector({
|
||||
loading={loading}
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
{Y_AXIS_CATEGORIES.map((category) => (
|
||||
{categories.map((category) => (
|
||||
<Select.OptGroup key={category.name} label={category.name}>
|
||||
{category.units.map((unit) => (
|
||||
<Select.Option key={unit.id} value={unit.id}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import { YAxisSource } from '../types';
|
||||
import YAxisUnitSelector from '../YAxisUnitSelector';
|
||||
|
||||
describe('YAxisUnitSelector', () => {
|
||||
@@ -10,7 +11,13 @@ describe('YAxisUnitSelector', () => {
|
||||
});
|
||||
|
||||
it('renders with default placeholder', () => {
|
||||
render(<YAxisUnitSelector value="" onChange={mockOnChange} />);
|
||||
render(
|
||||
<YAxisUnitSelector
|
||||
value=""
|
||||
onChange={mockOnChange}
|
||||
source={YAxisSource.ALERTS}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Please select a unit')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -20,13 +27,20 @@ describe('YAxisUnitSelector', () => {
|
||||
value=""
|
||||
onChange={mockOnChange}
|
||||
placeholder="Custom placeholder"
|
||||
source={YAxisSource.ALERTS}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('Custom placeholder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onChange when a value is selected', () => {
|
||||
render(<YAxisUnitSelector value="" onChange={mockOnChange} />);
|
||||
render(
|
||||
<YAxisUnitSelector
|
||||
value=""
|
||||
onChange={mockOnChange}
|
||||
source={YAxisSource.ALERTS}
|
||||
/>,
|
||||
);
|
||||
const select = screen.getByRole('combobox');
|
||||
|
||||
fireEvent.mouseDown(select);
|
||||
@@ -41,18 +55,30 @@ describe('YAxisUnitSelector', () => {
|
||||
});
|
||||
|
||||
it('filters options based on search input', () => {
|
||||
render(<YAxisUnitSelector value="" onChange={mockOnChange} />);
|
||||
render(
|
||||
<YAxisUnitSelector
|
||||
value=""
|
||||
onChange={mockOnChange}
|
||||
source={YAxisSource.ALERTS}
|
||||
/>,
|
||||
);
|
||||
const select = screen.getByRole('combobox');
|
||||
|
||||
fireEvent.mouseDown(select);
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'byte' } });
|
||||
fireEvent.change(input, { target: { value: 'bytes/sec' } });
|
||||
|
||||
expect(screen.getByText('Bytes/sec')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows all categories and their units', () => {
|
||||
render(<YAxisUnitSelector value="" onChange={mockOnChange} />);
|
||||
render(
|
||||
<YAxisUnitSelector
|
||||
value=""
|
||||
onChange={mockOnChange}
|
||||
source={YAxisSource.ALERTS}
|
||||
/>,
|
||||
);
|
||||
const select = screen.getByRole('combobox');
|
||||
|
||||
fireEvent.mouseDown(select);
|
||||
|
||||
@@ -0,0 +1,951 @@
|
||||
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
|
||||
|
||||
import {
|
||||
AdditionalLabelsMappingForGrafanaUnits,
|
||||
UniversalUnitToGrafanaUnit,
|
||||
} from '../constants';
|
||||
import { formatUniversalUnit } from '../formatter';
|
||||
|
||||
describe('formatUniversalUnit', () => {
|
||||
describe('Time', () => {
|
||||
test.each([
|
||||
// Days
|
||||
[31, UniversalYAxisUnit.DAYS, '4.43 weeks'],
|
||||
[7, UniversalYAxisUnit.DAYS, '1 week'],
|
||||
[6, UniversalYAxisUnit.DAYS, '6 days'],
|
||||
[1, UniversalYAxisUnit.DAYS, '1 day'],
|
||||
// Hours
|
||||
[25, UniversalYAxisUnit.HOURS, '1.04 days'],
|
||||
[23, UniversalYAxisUnit.HOURS, '23 hour'],
|
||||
[1, UniversalYAxisUnit.HOURS, '1 hour'],
|
||||
// Minutes
|
||||
[61, UniversalYAxisUnit.MINUTES, '1.02 hours'],
|
||||
[60, UniversalYAxisUnit.MINUTES, '1 hour'],
|
||||
[45, UniversalYAxisUnit.MINUTES, '45 min'],
|
||||
[1, UniversalYAxisUnit.MINUTES, '1 min'],
|
||||
// Seconds
|
||||
[100000, UniversalYAxisUnit.SECONDS, '1.16 days'],
|
||||
[10065, UniversalYAxisUnit.SECONDS, '2.8 hours'],
|
||||
[61, UniversalYAxisUnit.SECONDS, '1.02 mins'],
|
||||
[60, UniversalYAxisUnit.SECONDS, '1 min'],
|
||||
[12, UniversalYAxisUnit.SECONDS, '12 s'],
|
||||
[1, UniversalYAxisUnit.SECONDS, '1 s'],
|
||||
// Milliseconds
|
||||
[1006, UniversalYAxisUnit.MILLISECONDS, '1.01 s'],
|
||||
[10000000, UniversalYAxisUnit.MILLISECONDS, '2.78 hours'],
|
||||
[100006, UniversalYAxisUnit.MICROSECONDS, '100 ms'],
|
||||
[1, UniversalYAxisUnit.MICROSECONDS, '1 µs'],
|
||||
[12, UniversalYAxisUnit.MICROSECONDS, '12 µs'],
|
||||
// Nanoseconds
|
||||
[10000000000, UniversalYAxisUnit.NANOSECONDS, '10 s'],
|
||||
[10000006, UniversalYAxisUnit.NANOSECONDS, '10 ms'],
|
||||
[1006, UniversalYAxisUnit.NANOSECONDS, '1.01 µs'],
|
||||
[1, UniversalYAxisUnit.NANOSECONDS, '1 ns'],
|
||||
[12, UniversalYAxisUnit.NANOSECONDS, '12 ns'],
|
||||
])('formats time value %s %s as %s', (value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data', () => {
|
||||
test.each([
|
||||
// Bytes
|
||||
[864, UniversalYAxisUnit.BYTES, '864 B'],
|
||||
[1000, UniversalYAxisUnit.BYTES, '1 kB'],
|
||||
[1020, UniversalYAxisUnit.BYTES, '1.02 kB'],
|
||||
// Kilobytes
|
||||
[512, UniversalYAxisUnit.KILOBYTES, '512 kB'],
|
||||
[1000, UniversalYAxisUnit.KILOBYTES, '1 MB'],
|
||||
[1023, UniversalYAxisUnit.KILOBYTES, '1.02 MB'],
|
||||
// Megabytes
|
||||
[777, UniversalYAxisUnit.MEGABYTES, '777 MB'],
|
||||
[1000, UniversalYAxisUnit.MEGABYTES, '1 GB'],
|
||||
[1023, UniversalYAxisUnit.MEGABYTES, '1.02 GB'],
|
||||
// Gigabytes
|
||||
[432, UniversalYAxisUnit.GIGABYTES, '432 GB'],
|
||||
[1000, UniversalYAxisUnit.GIGABYTES, '1 TB'],
|
||||
[1023, UniversalYAxisUnit.GIGABYTES, '1.02 TB'],
|
||||
// Terabytes
|
||||
[678, UniversalYAxisUnit.TERABYTES, '678 TB'],
|
||||
[1000, UniversalYAxisUnit.TERABYTES, '1 PB'],
|
||||
[1023, UniversalYAxisUnit.TERABYTES, '1.02 PB'],
|
||||
// Petabytes
|
||||
[845, UniversalYAxisUnit.PETABYTES, '845 PB'],
|
||||
[1000, UniversalYAxisUnit.PETABYTES, '1 EB'],
|
||||
[1023, UniversalYAxisUnit.PETABYTES, '1.02 EB'],
|
||||
// Exabytes
|
||||
[921, UniversalYAxisUnit.EXABYTES, '921 EB'],
|
||||
[1000, UniversalYAxisUnit.EXABYTES, '1 ZB'],
|
||||
[1023, UniversalYAxisUnit.EXABYTES, '1.02 ZB'],
|
||||
// Zettabytes
|
||||
[921, UniversalYAxisUnit.ZETTABYTES, '921 ZB'],
|
||||
[1000, UniversalYAxisUnit.ZETTABYTES, '1 YB'],
|
||||
[1023, UniversalYAxisUnit.ZETTABYTES, '1.02 YB'],
|
||||
// Yottabytes
|
||||
[921, UniversalYAxisUnit.YOTTABYTES, '921 YB'],
|
||||
[1000, UniversalYAxisUnit.YOTTABYTES, '1000 YB'],
|
||||
[1023, UniversalYAxisUnit.YOTTABYTES, '1023 YB'],
|
||||
])('formats data value %s %s as %s', (value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data rate', () => {
|
||||
test.each([
|
||||
// Bytes/second
|
||||
[864, UniversalYAxisUnit.BYTES_SECOND, '864 B/s'],
|
||||
[1000, UniversalYAxisUnit.BYTES_SECOND, '1 kB/s'],
|
||||
[1020, UniversalYAxisUnit.BYTES_SECOND, '1.02 kB/s'],
|
||||
// Kilobytes/second
|
||||
[512, UniversalYAxisUnit.KILOBYTES_SECOND, '512 kB/s'],
|
||||
[1000, UniversalYAxisUnit.KILOBYTES_SECOND, '1 MB/s'],
|
||||
[1023, UniversalYAxisUnit.KILOBYTES_SECOND, '1.02 MB/s'],
|
||||
// Megabytes/second
|
||||
[777, UniversalYAxisUnit.MEGABYTES_SECOND, '777 MB/s'],
|
||||
[1000, UniversalYAxisUnit.MEGABYTES_SECOND, '1 GB/s'],
|
||||
[1023, UniversalYAxisUnit.MEGABYTES_SECOND, '1.02 GB/s'],
|
||||
// Gigabytes/second
|
||||
[432, UniversalYAxisUnit.GIGABYTES_SECOND, '432 GB/s'],
|
||||
[1000, UniversalYAxisUnit.GIGABYTES_SECOND, '1 TB/s'],
|
||||
[1023, UniversalYAxisUnit.GIGABYTES_SECOND, '1.02 TB/s'],
|
||||
// Terabytes/second
|
||||
[678, UniversalYAxisUnit.TERABYTES_SECOND, '678 TB/s'],
|
||||
[1000, UniversalYAxisUnit.TERABYTES_SECOND, '1 PB/s'],
|
||||
[1023, UniversalYAxisUnit.TERABYTES_SECOND, '1.02 PB/s'],
|
||||
// Petabytes/second
|
||||
[845, UniversalYAxisUnit.PETABYTES_SECOND, '845 PB/s'],
|
||||
[1000, UniversalYAxisUnit.PETABYTES_SECOND, '1 EB/s'],
|
||||
[1023, UniversalYAxisUnit.PETABYTES_SECOND, '1.02 EB/s'],
|
||||
// Exabytes/second
|
||||
[921, UniversalYAxisUnit.EXABYTES_SECOND, '921 EB/s'],
|
||||
[1000, UniversalYAxisUnit.EXABYTES_SECOND, '1 ZB/s'],
|
||||
[1023, UniversalYAxisUnit.EXABYTES_SECOND, '1.02 ZB/s'],
|
||||
// Zettabytes/second
|
||||
[921, UniversalYAxisUnit.ZETTABYTES_SECOND, '921 ZB/s'],
|
||||
[1000, UniversalYAxisUnit.ZETTABYTES_SECOND, '1 YB/s'],
|
||||
[1023, UniversalYAxisUnit.ZETTABYTES_SECOND, '1.02 YB/s'],
|
||||
// Yottabytes/second
|
||||
[921, UniversalYAxisUnit.YOTTABYTES_SECOND, '921 YB/s'],
|
||||
[1000, UniversalYAxisUnit.YOTTABYTES_SECOND, '1000 YB/s'],
|
||||
[1023, UniversalYAxisUnit.YOTTABYTES_SECOND, '1023 YB/s'],
|
||||
])('formats data value %s %s as %s', (value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bit', () => {
|
||||
test.each([
|
||||
// Bits
|
||||
[1, UniversalYAxisUnit.BITS, '1 b'],
|
||||
[250, UniversalYAxisUnit.BITS, '250 b'],
|
||||
[1000, UniversalYAxisUnit.BITS, '1 kb'],
|
||||
[1023, UniversalYAxisUnit.BITS, '1.02 kb'],
|
||||
// Kilobits
|
||||
[0.5, UniversalYAxisUnit.KILOBITS, '500 b'],
|
||||
[375, UniversalYAxisUnit.KILOBITS, '375 kb'],
|
||||
[1000, UniversalYAxisUnit.KILOBITS, '1 Mb'],
|
||||
[1023, UniversalYAxisUnit.KILOBITS, '1.02 Mb'],
|
||||
// Megabits
|
||||
[0.5, UniversalYAxisUnit.MEGABITS, '500 kb'],
|
||||
[640, UniversalYAxisUnit.MEGABITS, '640 Mb'],
|
||||
[1000, UniversalYAxisUnit.MEGABITS, '1 Gb'],
|
||||
[1023, UniversalYAxisUnit.MEGABITS, '1.02 Gb'],
|
||||
// Gigabits
|
||||
[0.5, UniversalYAxisUnit.GIGABITS, '500 Mb'],
|
||||
[875, UniversalYAxisUnit.GIGABITS, '875 Gb'],
|
||||
[1000, UniversalYAxisUnit.GIGABITS, '1 Tb'],
|
||||
[1023, UniversalYAxisUnit.GIGABITS, '1.02 Tb'],
|
||||
// Terabits
|
||||
[0.5, UniversalYAxisUnit.TERABITS, '500 Gb'],
|
||||
[430, UniversalYAxisUnit.TERABITS, '430 Tb'],
|
||||
[1000, UniversalYAxisUnit.TERABITS, '1 Pb'],
|
||||
[1023, UniversalYAxisUnit.TERABITS, '1.02 Pb'],
|
||||
// Petabits
|
||||
[0.5, UniversalYAxisUnit.PETABITS, '500 Tb'],
|
||||
[590, UniversalYAxisUnit.PETABITS, '590 Pb'],
|
||||
[1000, UniversalYAxisUnit.PETABITS, '1 Eb'],
|
||||
[1023, UniversalYAxisUnit.PETABITS, '1.02 Eb'],
|
||||
// Exabits
|
||||
[0.5, UniversalYAxisUnit.EXABITS, '500 Pb'],
|
||||
[715, UniversalYAxisUnit.EXABITS, '715 Eb'],
|
||||
[1000, UniversalYAxisUnit.EXABITS, '1 Zb'],
|
||||
[1023, UniversalYAxisUnit.EXABITS, '1.02 Zb'],
|
||||
// Zettabits
|
||||
[0.5, UniversalYAxisUnit.ZETTABITS, '500 Eb'],
|
||||
[840, UniversalYAxisUnit.ZETTABITS, '840 Zb'],
|
||||
[1000, UniversalYAxisUnit.ZETTABITS, '1 Yb'],
|
||||
[1023, UniversalYAxisUnit.ZETTABITS, '1.02 Yb'],
|
||||
// Yottabits
|
||||
[0.5, UniversalYAxisUnit.YOTTABITS, '500 Zb'],
|
||||
[965, UniversalYAxisUnit.YOTTABITS, '965 Yb'],
|
||||
[1000, UniversalYAxisUnit.YOTTABITS, '1000 Yb'],
|
||||
[1023, UniversalYAxisUnit.YOTTABITS, '1023 Yb'],
|
||||
])('formats bit value %s %s as %s', (value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bit rate', () => {
|
||||
test.each([
|
||||
// Bits/second
|
||||
[512, UniversalYAxisUnit.BITS_SECOND, '512 b/s'],
|
||||
[1000, UniversalYAxisUnit.BITS_SECOND, '1 kb/s'],
|
||||
[1023, UniversalYAxisUnit.BITS_SECOND, '1.02 kb/s'],
|
||||
// Kilobits/second
|
||||
[0.5, UniversalYAxisUnit.KILOBITS_SECOND, '500 b/s'],
|
||||
[512, UniversalYAxisUnit.KILOBITS_SECOND, '512 kb/s'],
|
||||
[1000, UniversalYAxisUnit.KILOBITS_SECOND, '1 Mb/s'],
|
||||
[1023, UniversalYAxisUnit.KILOBITS_SECOND, '1.02 Mb/s'],
|
||||
// Megabits/second
|
||||
[0.5, UniversalYAxisUnit.MEGABITS_SECOND, '500 kb/s'],
|
||||
[512, UniversalYAxisUnit.MEGABITS_SECOND, '512 Mb/s'],
|
||||
[1000, UniversalYAxisUnit.MEGABITS_SECOND, '1 Gb/s'],
|
||||
[1023, UniversalYAxisUnit.MEGABITS_SECOND, '1.02 Gb/s'],
|
||||
// Gigabits/second
|
||||
[0.5, UniversalYAxisUnit.GIGABITS_SECOND, '500 Mb/s'],
|
||||
[512, UniversalYAxisUnit.GIGABITS_SECOND, '512 Gb/s'],
|
||||
[1000, UniversalYAxisUnit.GIGABITS_SECOND, '1 Tb/s'],
|
||||
[1023, UniversalYAxisUnit.GIGABITS_SECOND, '1.02 Tb/s'],
|
||||
// Terabits/second
|
||||
[0.5, UniversalYAxisUnit.TERABITS_SECOND, '500 Gb/s'],
|
||||
[512, UniversalYAxisUnit.TERABITS_SECOND, '512 Tb/s'],
|
||||
[1000, UniversalYAxisUnit.TERABITS_SECOND, '1 Pb/s'],
|
||||
[1023, UniversalYAxisUnit.TERABITS_SECOND, '1.02 Pb/s'],
|
||||
// Petabits/second
|
||||
[0.5, UniversalYAxisUnit.PETABITS_SECOND, '500 Tb/s'],
|
||||
[512, UniversalYAxisUnit.PETABITS_SECOND, '512 Pb/s'],
|
||||
[1000, UniversalYAxisUnit.PETABITS_SECOND, '1 Eb/s'],
|
||||
[1023, UniversalYAxisUnit.PETABITS_SECOND, '1.02 Eb/s'],
|
||||
// Exabits/second
|
||||
[512, UniversalYAxisUnit.EXABITS_SECOND, '512 Eb/s'],
|
||||
[1000, UniversalYAxisUnit.EXABITS_SECOND, '1 Zb/s'],
|
||||
[1023, UniversalYAxisUnit.EXABITS_SECOND, '1.02 Zb/s'],
|
||||
// Zettabits/second
|
||||
[0.5, UniversalYAxisUnit.ZETTABITS_SECOND, '500 Eb/s'],
|
||||
[512, UniversalYAxisUnit.ZETTABITS_SECOND, '512 Zb/s'],
|
||||
[1000, UniversalYAxisUnit.ZETTABITS_SECOND, '1 Yb/s'],
|
||||
[1023, UniversalYAxisUnit.ZETTABITS_SECOND, '1.02 Yb/s'],
|
||||
// Yottabits/second
|
||||
[0.5, UniversalYAxisUnit.YOTTABITS_SECOND, '500 Zb/s'],
|
||||
[512, UniversalYAxisUnit.YOTTABITS_SECOND, '512 Yb/s'],
|
||||
[1000, UniversalYAxisUnit.YOTTABITS_SECOND, '1000 Yb/s'],
|
||||
[1023, UniversalYAxisUnit.YOTTABITS_SECOND, '1023 Yb/s'],
|
||||
])('formats bit rate value %s %s as %s', (value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Count', () => {
|
||||
test.each([
|
||||
[100, UniversalYAxisUnit.COUNT, '100'],
|
||||
[875, UniversalYAxisUnit.COUNT, '875'],
|
||||
[1000, UniversalYAxisUnit.COUNT, '1 K'],
|
||||
[2500, UniversalYAxisUnit.COUNT, '2.5 K'],
|
||||
[10000, UniversalYAxisUnit.COUNT, '10 K'],
|
||||
[25000, UniversalYAxisUnit.COUNT, '25 K'],
|
||||
[100000, UniversalYAxisUnit.COUNT, '100 K'],
|
||||
[1000000, UniversalYAxisUnit.COUNT, '1 Mil'],
|
||||
[10000000, UniversalYAxisUnit.COUNT, '10 Mil'],
|
||||
[100000000, UniversalYAxisUnit.COUNT, '100 Mil'],
|
||||
[1000000000, UniversalYAxisUnit.COUNT, '1 Bil'],
|
||||
[10000000000, UniversalYAxisUnit.COUNT, '10 Bil'],
|
||||
[100000000000, UniversalYAxisUnit.COUNT, '100 Bil'],
|
||||
[1000000000000, UniversalYAxisUnit.COUNT, '1 Tri'],
|
||||
[10000000000000, UniversalYAxisUnit.COUNT, '10 Tri'],
|
||||
])('formats count value %s %s as %s', (value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[100, UniversalYAxisUnit.COUNT_SECOND, '100 c/s'],
|
||||
[875, UniversalYAxisUnit.COUNT_SECOND, '875 c/s'],
|
||||
[1000, UniversalYAxisUnit.COUNT_SECOND, '1K c/s'],
|
||||
[2500, UniversalYAxisUnit.COUNT_SECOND, '2.5K c/s'],
|
||||
[10000, UniversalYAxisUnit.COUNT_SECOND, '10K c/s'],
|
||||
[25000, UniversalYAxisUnit.COUNT_SECOND, '25K c/s'],
|
||||
])('formats count per time value %s %s as %s', (value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[100, UniversalYAxisUnit.COUNT_MINUTE, '100 c/m'],
|
||||
[875, UniversalYAxisUnit.COUNT_MINUTE, '875 c/m'],
|
||||
[1000, UniversalYAxisUnit.COUNT_MINUTE, '1K c/m'],
|
||||
[2500, UniversalYAxisUnit.COUNT_MINUTE, '2.5K c/m'],
|
||||
[10000, UniversalYAxisUnit.COUNT_MINUTE, '10K c/m'],
|
||||
[25000, UniversalYAxisUnit.COUNT_MINUTE, '25K c/m'],
|
||||
])('formats count per time value %s %s as %s', (value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Operations units', () => {
|
||||
test.each([
|
||||
[780, UniversalYAxisUnit.OPS_SECOND, '780 ops/s'],
|
||||
[1000, UniversalYAxisUnit.OPS_SECOND, '1K ops/s'],
|
||||
[520, UniversalYAxisUnit.OPS_MINUTE, '520 ops/m'],
|
||||
[1000, UniversalYAxisUnit.OPS_MINUTE, '1K ops/m'],
|
||||
[2500, UniversalYAxisUnit.OPS_MINUTE, '2.5K ops/m'],
|
||||
[10000, UniversalYAxisUnit.OPS_MINUTE, '10K ops/m'],
|
||||
[25000, UniversalYAxisUnit.OPS_MINUTE, '25K ops/m'],
|
||||
])(
|
||||
'formats operations per time value %s %s as %s',
|
||||
(value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Request units', () => {
|
||||
test.each([
|
||||
[615, UniversalYAxisUnit.REQUESTS_SECOND, '615 req/s'],
|
||||
[1000, UniversalYAxisUnit.REQUESTS_SECOND, '1K req/s'],
|
||||
[480, UniversalYAxisUnit.REQUESTS_MINUTE, '480 req/m'],
|
||||
[1000, UniversalYAxisUnit.REQUESTS_MINUTE, '1K req/m'],
|
||||
[2500, UniversalYAxisUnit.REQUESTS_MINUTE, '2.5K req/m'],
|
||||
[10000, UniversalYAxisUnit.REQUESTS_MINUTE, '10K req/m'],
|
||||
[25000, UniversalYAxisUnit.REQUESTS_MINUTE, '25K req/m'],
|
||||
])('formats requests per time value %s %s as %s', (value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Read/Write units', () => {
|
||||
test.each([
|
||||
[505, UniversalYAxisUnit.READS_SECOND, '505 rd/s'],
|
||||
[1000, UniversalYAxisUnit.READS_SECOND, '1K rd/s'],
|
||||
[610, UniversalYAxisUnit.WRITES_SECOND, '610 wr/s'],
|
||||
[1000, UniversalYAxisUnit.WRITES_SECOND, '1K wr/s'],
|
||||
[715, UniversalYAxisUnit.READS_MINUTE, '715 rd/m'],
|
||||
[1000, UniversalYAxisUnit.READS_MINUTE, '1K rd/m'],
|
||||
[2500, UniversalYAxisUnit.READS_MINUTE, '2.5K rd/m'],
|
||||
[10000, UniversalYAxisUnit.READS_MINUTE, '10K rd/m'],
|
||||
[25000, UniversalYAxisUnit.READS_MINUTE, '25K rd/m'],
|
||||
[830, UniversalYAxisUnit.WRITES_MINUTE, '830 wr/m'],
|
||||
[1000, UniversalYAxisUnit.WRITES_MINUTE, '1K wr/m'],
|
||||
[2500, UniversalYAxisUnit.WRITES_MINUTE, '2.5K wr/m'],
|
||||
[10000, UniversalYAxisUnit.WRITES_MINUTE, '10K wr/m'],
|
||||
[25000, UniversalYAxisUnit.WRITES_MINUTE, '25K wr/m'],
|
||||
])(
|
||||
'formats reads and writes per time value %s %s as %s',
|
||||
(value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('IO Operations units', () => {
|
||||
test.each([
|
||||
[777, UniversalYAxisUnit.IOOPS_SECOND, '777 io/s'],
|
||||
[1000, UniversalYAxisUnit.IOOPS_SECOND, '1K io/s'],
|
||||
[2500, UniversalYAxisUnit.IOOPS_SECOND, '2.5K io/s'],
|
||||
[10000, UniversalYAxisUnit.IOOPS_SECOND, '10K io/s'],
|
||||
[25000, UniversalYAxisUnit.IOOPS_SECOND, '25K io/s'],
|
||||
])('formats IOPS value %s %s as %s', (value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Percent units', () => {
|
||||
it('formats percent as-is', () => {
|
||||
expect(formatUniversalUnit(456, UniversalYAxisUnit.PERCENT)).toBe('456%');
|
||||
});
|
||||
|
||||
it('multiplies percent_unit by 100', () => {
|
||||
expect(formatUniversalUnit(9, UniversalYAxisUnit.PERCENT_UNIT)).toBe('900%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('None unit', () => {
|
||||
it('formats as plain number', () => {
|
||||
expect(formatUniversalUnit(742, UniversalYAxisUnit.NONE)).toBe('742');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Time (additional)', () => {
|
||||
test.each([
|
||||
[900, UniversalYAxisUnit.DURATION_MS, '900 milliseconds'],
|
||||
[1000, UniversalYAxisUnit.DURATION_MS, '1 second'],
|
||||
[1, UniversalYAxisUnit.DURATION_MS, '1 millisecond'],
|
||||
[900, UniversalYAxisUnit.DURATION_S, '15 minutes'],
|
||||
[1, UniversalYAxisUnit.DURATION_HMS, '00:00:01'],
|
||||
[90005, UniversalYAxisUnit.DURATION_HMS, '25:00:05'],
|
||||
[90005, UniversalYAxisUnit.DURATION_DHMS, '1 d 01:00:05'],
|
||||
[900, UniversalYAxisUnit.TIMETICKS, '9 s'],
|
||||
[1, UniversalYAxisUnit.TIMETICKS, '10 ms'],
|
||||
[900, UniversalYAxisUnit.CLOCK_MS, '900ms'],
|
||||
[1, UniversalYAxisUnit.CLOCK_MS, '001ms'],
|
||||
[1, UniversalYAxisUnit.CLOCK_S, '01s:000ms'],
|
||||
[900, UniversalYAxisUnit.CLOCK_S, '15m:00s:000ms'],
|
||||
[900, UniversalYAxisUnit.TIME_HERTZ, '900 Hz'],
|
||||
[1000, UniversalYAxisUnit.TIME_HERTZ, '1 kHz'],
|
||||
[1000000, UniversalYAxisUnit.TIME_HERTZ, '1 MHz'],
|
||||
[1000000000, UniversalYAxisUnit.TIME_HERTZ, '1 GHz'],
|
||||
[1008, UniversalYAxisUnit.TIME_HERTZ, '1.01 kHz'],
|
||||
])('formats duration value %s %s as %s', (value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data (IEC/Binary)', () => {
|
||||
test.each([
|
||||
// Bytes
|
||||
[900, UniversalYAxisUnit.BYTES_IEC, '900 B'],
|
||||
[1024, UniversalYAxisUnit.BYTES_IEC, '1 KiB'],
|
||||
[1080, UniversalYAxisUnit.BYTES_IEC, '1.05 KiB'],
|
||||
// Kibibytes
|
||||
[900, UniversalYAxisUnit.KIBIBYTES, '900 KiB'],
|
||||
[1024, UniversalYAxisUnit.KIBIBYTES, '1 MiB'],
|
||||
[1080, UniversalYAxisUnit.KIBIBYTES, '1.05 MiB'],
|
||||
// Mebibytes
|
||||
[900, UniversalYAxisUnit.MEBIBYTES, '900 MiB'],
|
||||
[1024, UniversalYAxisUnit.MEBIBYTES, '1 GiB'],
|
||||
[1080, UniversalYAxisUnit.MEBIBYTES, '1.05 GiB'],
|
||||
// Gibibytes
|
||||
[900, UniversalYAxisUnit.GIBIBYTES, '900 GiB'],
|
||||
[1024, UniversalYAxisUnit.GIBIBYTES, '1 TiB'],
|
||||
[1080, UniversalYAxisUnit.GIBIBYTES, '1.05 TiB'],
|
||||
// Tebibytes
|
||||
[900, UniversalYAxisUnit.TEBIBYTES, '900 TiB'],
|
||||
[1024, UniversalYAxisUnit.TEBIBYTES, '1 PiB'],
|
||||
[1080, UniversalYAxisUnit.TEBIBYTES, '1.05 PiB'],
|
||||
// Pebibytes
|
||||
[900, UniversalYAxisUnit.PEBIBYTES, '900 PiB'],
|
||||
[1024, UniversalYAxisUnit.PEBIBYTES, '1 EiB'],
|
||||
[1080, UniversalYAxisUnit.PEBIBYTES, '1.05 EiB'],
|
||||
// Exbibytes
|
||||
[900, UniversalYAxisUnit.EXBIBYTES, '900 EiB'],
|
||||
[1024, UniversalYAxisUnit.EXBIBYTES, '1 ZiB'],
|
||||
[1080, UniversalYAxisUnit.EXBIBYTES, '1.05 ZiB'],
|
||||
// Zebibytes
|
||||
[900, UniversalYAxisUnit.ZEBIBYTES, '900 ZiB'],
|
||||
[1024, UniversalYAxisUnit.ZEBIBYTES, '1 YiB'],
|
||||
[1080, UniversalYAxisUnit.ZEBIBYTES, '1.05 YiB'],
|
||||
// Yobibytes
|
||||
[900, UniversalYAxisUnit.YOBIBYTES, '900 YiB'],
|
||||
[1024, UniversalYAxisUnit.YOBIBYTES, '1024 YiB'],
|
||||
])('formats IEC bytes value %s %s as %s', (value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data Rate (IEC/Binary)', () => {
|
||||
test.each([
|
||||
// Kibibytes/second
|
||||
[900, UniversalYAxisUnit.KIBIBYTES_SECOND, '900 KiB/s'],
|
||||
[1024, UniversalYAxisUnit.KIBIBYTES_SECOND, '1 MiB/s'],
|
||||
[1080, UniversalYAxisUnit.KIBIBYTES_SECOND, '1.05 MiB/s'],
|
||||
// Mebibytes/second
|
||||
[900, UniversalYAxisUnit.MEBIBYTES_SECOND, '900 MiB/s'],
|
||||
[1024, UniversalYAxisUnit.MEBIBYTES_SECOND, '1 GiB/s'],
|
||||
[1080, UniversalYAxisUnit.MEBIBYTES_SECOND, '1.05 GiB/s'],
|
||||
// Gibibytes/second
|
||||
[900, UniversalYAxisUnit.GIBIBYTES_SECOND, '900 GiB/s'],
|
||||
[1024, UniversalYAxisUnit.GIBIBYTES_SECOND, '1 TiB/s'],
|
||||
[1080, UniversalYAxisUnit.GIBIBYTES_SECOND, '1.05 TiB/s'],
|
||||
// Tebibytes/second
|
||||
[900, UniversalYAxisUnit.TEBIBYTES_SECOND, '900 TiB/s'],
|
||||
[1024, UniversalYAxisUnit.TEBIBYTES_SECOND, '1 PiB/s'],
|
||||
[1080, UniversalYAxisUnit.TEBIBYTES_SECOND, '1.05 PiB/s'],
|
||||
// Pebibytes/second
|
||||
[900, UniversalYAxisUnit.PEBIBYTES_SECOND, '900 PiB/s'],
|
||||
[1024, UniversalYAxisUnit.PEBIBYTES_SECOND, '1 EiB/s'],
|
||||
[1080, UniversalYAxisUnit.PEBIBYTES_SECOND, '1.05 EiB/s'],
|
||||
// Exbibytes/second
|
||||
[900, UniversalYAxisUnit.EXBIBYTES_SECOND, '900 EiB/s'],
|
||||
[1024, UniversalYAxisUnit.EXBIBYTES_SECOND, '1 ZiB/s'],
|
||||
[1080, UniversalYAxisUnit.EXBIBYTES_SECOND, '1.05 ZiB/s'],
|
||||
// Zebibytes/second
|
||||
[900, UniversalYAxisUnit.ZEBIBYTES_SECOND, '900 ZiB/s'],
|
||||
[1024, UniversalYAxisUnit.ZEBIBYTES_SECOND, '1 YiB/s'],
|
||||
[1080, UniversalYAxisUnit.ZEBIBYTES_SECOND, '1.05 YiB/s'],
|
||||
// Yobibytes/second
|
||||
[900, UniversalYAxisUnit.YOBIBYTES_SECOND, '900 YiB/s'],
|
||||
[1024, UniversalYAxisUnit.YOBIBYTES_SECOND, '1024 YiB/s'],
|
||||
[1080, UniversalYAxisUnit.YOBIBYTES_SECOND, '1080 YiB/s'],
|
||||
// Packets/second
|
||||
[900, UniversalYAxisUnit.DATA_RATE_PACKETS_PER_SECOND, '900 p/s'],
|
||||
[1000, UniversalYAxisUnit.DATA_RATE_PACKETS_PER_SECOND, '1 kp/s'],
|
||||
[1080, UniversalYAxisUnit.DATA_RATE_PACKETS_PER_SECOND, '1.08 kp/s'],
|
||||
])('formats IEC byte rates value %s %s as %s', (value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bits (IEC)', () => {
|
||||
test.each([
|
||||
[900, UniversalYAxisUnit.BITS_IEC, '900 b'],
|
||||
[1024, UniversalYAxisUnit.BITS_IEC, '1 Kib'],
|
||||
[1080, UniversalYAxisUnit.BITS_IEC, '1.05 Kib'],
|
||||
])('formats IEC bits value %s %s as %s', (value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hash Rate', () => {
|
||||
test.each([
|
||||
// Hashes/second
|
||||
[412, UniversalYAxisUnit.HASH_RATE_HASHES_PER_SECOND, '412 H/s'],
|
||||
[1000, UniversalYAxisUnit.HASH_RATE_HASHES_PER_SECOND, '1 kH/s'],
|
||||
[1023, UniversalYAxisUnit.HASH_RATE_HASHES_PER_SECOND, '1.02 kH/s'],
|
||||
// Kilohashes/second
|
||||
[412, UniversalYAxisUnit.HASH_RATE_KILOHASHES_PER_SECOND, '412 kH/s'],
|
||||
[1000, UniversalYAxisUnit.HASH_RATE_KILOHASHES_PER_SECOND, '1 MH/s'],
|
||||
[1023, UniversalYAxisUnit.HASH_RATE_KILOHASHES_PER_SECOND, '1.02 MH/s'],
|
||||
// Megahashes/second
|
||||
[412, UniversalYAxisUnit.HASH_RATE_MEGAHASHES_PER_SECOND, '412 MH/s'],
|
||||
[1000, UniversalYAxisUnit.HASH_RATE_MEGAHASHES_PER_SECOND, '1 GH/s'],
|
||||
[1023, UniversalYAxisUnit.HASH_RATE_MEGAHASHES_PER_SECOND, '1.02 GH/s'],
|
||||
// Gigahashes/second
|
||||
[412, UniversalYAxisUnit.HASH_RATE_GIGAHASHES_PER_SECOND, '412 GH/s'],
|
||||
[1000, UniversalYAxisUnit.HASH_RATE_GIGAHASHES_PER_SECOND, '1 TH/s'],
|
||||
[1023, UniversalYAxisUnit.HASH_RATE_GIGAHASHES_PER_SECOND, '1.02 TH/s'],
|
||||
// Terahashes/second
|
||||
[412, UniversalYAxisUnit.HASH_RATE_TERAHASHES_PER_SECOND, '412 TH/s'],
|
||||
[1000, UniversalYAxisUnit.HASH_RATE_TERAHASHES_PER_SECOND, '1 PH/s'],
|
||||
[1023, UniversalYAxisUnit.HASH_RATE_TERAHASHES_PER_SECOND, '1.02 PH/s'],
|
||||
// Petahashes/second
|
||||
[412, UniversalYAxisUnit.HASH_RATE_PETAHASHES_PER_SECOND, '412 PH/s'],
|
||||
[1000, UniversalYAxisUnit.HASH_RATE_PETAHASHES_PER_SECOND, '1 EH/s'],
|
||||
[1023, UniversalYAxisUnit.HASH_RATE_PETAHASHES_PER_SECOND, '1.02 EH/s'],
|
||||
// Exahashes/second
|
||||
[412, UniversalYAxisUnit.HASH_RATE_EXAHASHES_PER_SECOND, '412 EH/s'],
|
||||
[1000, UniversalYAxisUnit.HASH_RATE_EXAHASHES_PER_SECOND, '1 ZH/s'],
|
||||
[1023, UniversalYAxisUnit.HASH_RATE_EXAHASHES_PER_SECOND, '1.02 ZH/s'],
|
||||
])('formats hash rate value %s %s as %s', (value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Miscellaneous', () => {
|
||||
test.each([
|
||||
[742, UniversalYAxisUnit.MISC_STRING, '742'],
|
||||
[688, UniversalYAxisUnit.MISC_SHORT, '688'],
|
||||
[555, UniversalYAxisUnit.MISC_HUMIDITY, '555 %H'],
|
||||
[812, UniversalYAxisUnit.MISC_DECIBEL, '812 dB'],
|
||||
[1024, UniversalYAxisUnit.MISC_HEXADECIMAL, '400'],
|
||||
[1024, UniversalYAxisUnit.MISC_HEXADECIMAL_0X, '0x400'],
|
||||
[900, UniversalYAxisUnit.MISC_SCIENTIFIC_NOTATION, '9e+2'],
|
||||
[678, UniversalYAxisUnit.MISC_LOCALE_FORMAT, '678'],
|
||||
[444, UniversalYAxisUnit.MISC_PIXELS, '444 px'],
|
||||
])('formats miscellaneous value %s %s as %s', (value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Acceleration', () => {
|
||||
test.each([
|
||||
[
|
||||
875,
|
||||
UniversalYAxisUnit.ACCELERATION_METERS_PER_SECOND_SQUARED,
|
||||
'875 m/sec²',
|
||||
],
|
||||
[640, UniversalYAxisUnit.ACCELERATION_FEET_PER_SECOND_SQUARED, '640 f/sec²'],
|
||||
[512, UniversalYAxisUnit.ACCELERATION_G_UNIT, '512 g'],
|
||||
[
|
||||
2500,
|
||||
UniversalYAxisUnit.ACCELERATION_METERS_PER_SECOND_SQUARED,
|
||||
'2500 m/sec²',
|
||||
],
|
||||
])('formats acceleration value %s %s as %s', (value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Angular', () => {
|
||||
test.each([
|
||||
[415, UniversalYAxisUnit.ANGULAR_DEGREE, '415 °'],
|
||||
[732, UniversalYAxisUnit.ANGULAR_RADIAN, '732 rad'],
|
||||
[128, UniversalYAxisUnit.ANGULAR_GRADIAN, '128 grad'],
|
||||
[560, UniversalYAxisUnit.ANGULAR_ARC_MINUTE, '560 arcmin'],
|
||||
[945, UniversalYAxisUnit.ANGULAR_ARC_SECOND, '945 arcsec'],
|
||||
])('formats angular value %s %s as %s', (value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Area', () => {
|
||||
test.each([
|
||||
[210, UniversalYAxisUnit.AREA_SQUARE_METERS, '210 m²'],
|
||||
[152, UniversalYAxisUnit.AREA_SQUARE_FEET, '152 ft²'],
|
||||
[64, UniversalYAxisUnit.AREA_SQUARE_MILES, '64 mi²'],
|
||||
])('formats area value %s %s as %s', (value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FLOPs', () => {
|
||||
test.each([
|
||||
// FLOPS
|
||||
[150, UniversalYAxisUnit.FLOPS_FLOPS, '150 FLOPS'],
|
||||
[1000, UniversalYAxisUnit.FLOPS_FLOPS, '1 kFLOPS'],
|
||||
[1080, UniversalYAxisUnit.FLOPS_FLOPS, '1.08 kFLOPS'],
|
||||
// MFLOPS
|
||||
[275, UniversalYAxisUnit.FLOPS_MFLOPS, '275 MFLOPS'],
|
||||
[1000, UniversalYAxisUnit.FLOPS_MFLOPS, '1 GFLOPS'],
|
||||
[1080, UniversalYAxisUnit.FLOPS_MFLOPS, '1.08 GFLOPS'],
|
||||
// GFLOPS
|
||||
[640, UniversalYAxisUnit.FLOPS_GFLOPS, '640 GFLOPS'],
|
||||
[1000, UniversalYAxisUnit.FLOPS_GFLOPS, '1 TFLOPS'],
|
||||
[1080, UniversalYAxisUnit.FLOPS_GFLOPS, '1.08 TFLOPS'],
|
||||
// TFLOPS
|
||||
[875, UniversalYAxisUnit.FLOPS_TFLOPS, '875 TFLOPS'],
|
||||
[1000, UniversalYAxisUnit.FLOPS_TFLOPS, '1 PFLOPS'],
|
||||
[1080, UniversalYAxisUnit.FLOPS_TFLOPS, '1.08 PFLOPS'],
|
||||
// PFLOPS
|
||||
[430, UniversalYAxisUnit.FLOPS_PFLOPS, '430 PFLOPS'],
|
||||
[1000, UniversalYAxisUnit.FLOPS_PFLOPS, '1 EFLOPS'],
|
||||
[1080, UniversalYAxisUnit.FLOPS_PFLOPS, '1.08 EFLOPS'],
|
||||
// EFLOPS
|
||||
[590, UniversalYAxisUnit.FLOPS_EFLOPS, '590 EFLOPS'],
|
||||
[1000, UniversalYAxisUnit.FLOPS_EFLOPS, '1 ZFLOPS'],
|
||||
[1080, UniversalYAxisUnit.FLOPS_EFLOPS, '1.08 ZFLOPS'],
|
||||
// ZFLOPS
|
||||
[715, UniversalYAxisUnit.FLOPS_ZFLOPS, '715 ZFLOPS'],
|
||||
[1000, UniversalYAxisUnit.FLOPS_ZFLOPS, '1 YFLOPS'],
|
||||
[1080, UniversalYAxisUnit.FLOPS_ZFLOPS, '1.08 YFLOPS'],
|
||||
// YFLOPS
|
||||
[840, UniversalYAxisUnit.FLOPS_YFLOPS, '840 YFLOPS'],
|
||||
[1000, UniversalYAxisUnit.FLOPS_YFLOPS, '1000 YFLOPS'],
|
||||
])('formats FLOPs value %s %s as %s', (value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Concentration', () => {
|
||||
test.each([
|
||||
[415, UniversalYAxisUnit.CONCENTRATION_PPM, '415 ppm'],
|
||||
[1000, UniversalYAxisUnit.CONCENTRATION_PPM, '1000 ppm'],
|
||||
[732, UniversalYAxisUnit.CONCENTRATION_PPB, '732 ppb'],
|
||||
[1000, UniversalYAxisUnit.CONCENTRATION_PPB, '1000 ppb'],
|
||||
[128, UniversalYAxisUnit.CONCENTRATION_NG_M3, '128 ng/m³'],
|
||||
[1000, UniversalYAxisUnit.CONCENTRATION_NG_M3, '1000 ng/m³'],
|
||||
[560, UniversalYAxisUnit.CONCENTRATION_NG_NORMAL_CUBIC_METER, '560 ng/Nm³'],
|
||||
[
|
||||
1000,
|
||||
UniversalYAxisUnit.CONCENTRATION_NG_NORMAL_CUBIC_METER,
|
||||
'1000 ng/Nm³',
|
||||
],
|
||||
[945, UniversalYAxisUnit.CONCENTRATION_UG_M3, '945 μg/m³'],
|
||||
[1000, UniversalYAxisUnit.CONCENTRATION_UG_M3, '1000 μg/m³'],
|
||||
[210, UniversalYAxisUnit.CONCENTRATION_UG_NORMAL_CUBIC_METER, '210 μg/Nm³'],
|
||||
[
|
||||
1000,
|
||||
UniversalYAxisUnit.CONCENTRATION_UG_NORMAL_CUBIC_METER,
|
||||
'1000 μg/Nm³',
|
||||
],
|
||||
[152, UniversalYAxisUnit.CONCENTRATION_MG_M3, '152 mg/m³'],
|
||||
[64, UniversalYAxisUnit.CONCENTRATION_MG_NORMAL_CUBIC_METER, '64 mg/Nm³'],
|
||||
[508, UniversalYAxisUnit.CONCENTRATION_G_M3, '508 g/m³'],
|
||||
[1000, UniversalYAxisUnit.CONCENTRATION_G_M3, '1000 g/m³'],
|
||||
[377, UniversalYAxisUnit.CONCENTRATION_G_NORMAL_CUBIC_METER, '377 g/Nm³'],
|
||||
[1000, UniversalYAxisUnit.CONCENTRATION_G_NORMAL_CUBIC_METER, '1000 g/Nm³'],
|
||||
[286, UniversalYAxisUnit.CONCENTRATION_MG_PER_DL, '286 mg/dL'],
|
||||
[1000, UniversalYAxisUnit.CONCENTRATION_MG_PER_DL, '1000 mg/dL'],
|
||||
[675, UniversalYAxisUnit.CONCENTRATION_MMOL_PER_L, '675 mmol/L'],
|
||||
[1000, UniversalYAxisUnit.CONCENTRATION_MMOL_PER_L, '1000 mmol/L'],
|
||||
])('formats concentration value %s %s as %s', (value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Currency', () => {
|
||||
test.each([
|
||||
[812, UniversalYAxisUnit.CURRENCY_USD, '$812'],
|
||||
[645, UniversalYAxisUnit.CURRENCY_GBP, '£645'],
|
||||
[731, UniversalYAxisUnit.CURRENCY_EUR, '€731'],
|
||||
[508, UniversalYAxisUnit.CURRENCY_JPY, '¥508'],
|
||||
[963, UniversalYAxisUnit.CURRENCY_RUB, '₽963'],
|
||||
[447, UniversalYAxisUnit.CURRENCY_UAH, '₴447'],
|
||||
[592, UniversalYAxisUnit.CURRENCY_BRL, 'R$592'],
|
||||
[375, UniversalYAxisUnit.CURRENCY_DKK, '375kr'],
|
||||
[418, UniversalYAxisUnit.CURRENCY_ISK, '418kr'],
|
||||
[536, UniversalYAxisUnit.CURRENCY_NOK, '536kr'],
|
||||
[689, UniversalYAxisUnit.CURRENCY_SEK, '689kr'],
|
||||
[724, UniversalYAxisUnit.CURRENCY_CZK, 'czk724'],
|
||||
[381, UniversalYAxisUnit.CURRENCY_CHF, 'CHF381'],
|
||||
[267, UniversalYAxisUnit.CURRENCY_PLN, 'PLN267'],
|
||||
[154, UniversalYAxisUnit.CURRENCY_BTC, '฿154'],
|
||||
[999, UniversalYAxisUnit.CURRENCY_MBTC, 'mBTC999'],
|
||||
[423, UniversalYAxisUnit.CURRENCY_UBTC, 'μBTC423'],
|
||||
[611, UniversalYAxisUnit.CURRENCY_ZAR, 'R611'],
|
||||
[782, UniversalYAxisUnit.CURRENCY_INR, '₹782'],
|
||||
[834, UniversalYAxisUnit.CURRENCY_KRW, '₩834'],
|
||||
[455, UniversalYAxisUnit.CURRENCY_IDR, 'Rp455'],
|
||||
[978, UniversalYAxisUnit.CURRENCY_PHP, 'PHP978'],
|
||||
[366, UniversalYAxisUnit.CURRENCY_VND, '366đ'],
|
||||
])('formats currency value %s %s as %s', (value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Datetime', () => {
|
||||
it('formats datetime units', () => {
|
||||
expect(formatUniversalUnit(900, UniversalYAxisUnit.DATETIME_FROM_NOW)).toBe(
|
||||
'56 years ago',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Power/Electrical', () => {
|
||||
test.each([
|
||||
[715, UniversalYAxisUnit.POWER_WATT, '715 W'],
|
||||
[1000, UniversalYAxisUnit.POWER_WATT, '1 kW'],
|
||||
[1080, UniversalYAxisUnit.POWER_WATT, '1.08 kW'],
|
||||
[438, UniversalYAxisUnit.POWER_KILOWATT, '438 kW'],
|
||||
[1000, UniversalYAxisUnit.POWER_KILOWATT, '1 MW'],
|
||||
[1080, UniversalYAxisUnit.POWER_KILOWATT, '1.08 MW'],
|
||||
[582, UniversalYAxisUnit.POWER_MEGAWATT, '582 MW'],
|
||||
[1000, UniversalYAxisUnit.POWER_MEGAWATT, '1 GW'],
|
||||
[1080, UniversalYAxisUnit.POWER_MEGAWATT, '1.08 GW'],
|
||||
[267, UniversalYAxisUnit.POWER_GIGAWATT, '267 GW'],
|
||||
[853, UniversalYAxisUnit.POWER_MILLIWATT, '853 mW'],
|
||||
[693, UniversalYAxisUnit.POWER_WATT_PER_SQUARE_METER, '693 W/m²'],
|
||||
[544, UniversalYAxisUnit.POWER_VOLT_AMPERE, '544 VA'],
|
||||
[812, UniversalYAxisUnit.POWER_KILOVOLT_AMPERE, '812 kVA'],
|
||||
[478, UniversalYAxisUnit.POWER_VOLT_AMPERE_REACTIVE, '478 VAr'],
|
||||
[365, UniversalYAxisUnit.POWER_KILOVOLT_AMPERE_REACTIVE, '365 kVAr'],
|
||||
[629, UniversalYAxisUnit.POWER_WATT_HOUR, '629 Wh'],
|
||||
[471, UniversalYAxisUnit.POWER_WATT_HOUR_PER_KG, '471 Wh/kg'],
|
||||
[557, UniversalYAxisUnit.POWER_KILOWATT_HOUR, '557 kWh'],
|
||||
[389, UniversalYAxisUnit.POWER_KILOWATT_MINUTE, '389 kW-Min'],
|
||||
[642, UniversalYAxisUnit.POWER_AMPERE_HOUR, '642 Ah'],
|
||||
[731, UniversalYAxisUnit.POWER_KILOAMPERE_HOUR, '731 kAh'],
|
||||
[815, UniversalYAxisUnit.POWER_MILLIAMPERE_HOUR, '815 mAh'],
|
||||
[963, UniversalYAxisUnit.POWER_JOULE, '963 J'],
|
||||
[506, UniversalYAxisUnit.POWER_ELECTRON_VOLT, '506 eV'],
|
||||
[298, UniversalYAxisUnit.POWER_AMPERE, '298 A'],
|
||||
[654, UniversalYAxisUnit.POWER_KILOAMPERE, '654 kA'],
|
||||
[187, UniversalYAxisUnit.POWER_MILLIAMPERE, '187 mA'],
|
||||
[472, UniversalYAxisUnit.POWER_VOLT, '472 V'],
|
||||
[538, UniversalYAxisUnit.POWER_KILOVOLT, '538 kV'],
|
||||
[226, UniversalYAxisUnit.POWER_MILLIVOLT, '226 mV'],
|
||||
[592, UniversalYAxisUnit.POWER_DECIBEL_MILLIWATT, '592 dBm'],
|
||||
[333, UniversalYAxisUnit.POWER_OHM, '333 Ω'],
|
||||
[447, UniversalYAxisUnit.POWER_KILOOHM, '447 kΩ'],
|
||||
[781, UniversalYAxisUnit.POWER_MEGAOHM, '781 MΩ'],
|
||||
[650, UniversalYAxisUnit.POWER_FARAD, '650 F'],
|
||||
[512, UniversalYAxisUnit.POWER_MICROFARAD, '512 µF'],
|
||||
[478, UniversalYAxisUnit.POWER_NANOFARAD, '478 nF'],
|
||||
[341, UniversalYAxisUnit.POWER_PICOFARAD, '341 pF'],
|
||||
[129, UniversalYAxisUnit.POWER_FEMTOFARAD, '129 fF'],
|
||||
[904, UniversalYAxisUnit.POWER_HENRY, '904 H'],
|
||||
[1000, UniversalYAxisUnit.POWER_HENRY, '1 kH'],
|
||||
[275, UniversalYAxisUnit.POWER_MILLIHENRY, '275 mH'],
|
||||
[618, UniversalYAxisUnit.POWER_MICROHENRY, '618 µH'],
|
||||
[1000, UniversalYAxisUnit.POWER_MICROHENRY, '1 mH'],
|
||||
[1080, UniversalYAxisUnit.POWER_MICROHENRY, '1.08 mH'],
|
||||
[459, UniversalYAxisUnit.POWER_LUMENS, '459 Lm'],
|
||||
[1000, UniversalYAxisUnit.POWER_LUMENS, '1 kLm'],
|
||||
[1080, UniversalYAxisUnit.POWER_LUMENS, '1.08 kLm'],
|
||||
])('formats power value %s %s as %s', (value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Flow', () => {
|
||||
test.each([
|
||||
[512, UniversalYAxisUnit.FLOW_GALLONS_PER_MINUTE, '512 gpm'],
|
||||
[1000, UniversalYAxisUnit.FLOW_GALLONS_PER_MINUTE, '1000 gpm'],
|
||||
[678, UniversalYAxisUnit.FLOW_CUBIC_METERS_PER_SECOND, '678 cms'],
|
||||
[1000, UniversalYAxisUnit.FLOW_CUBIC_METERS_PER_SECOND, '1000 cms'],
|
||||
[245, UniversalYAxisUnit.FLOW_CUBIC_FEET_PER_SECOND, '245 cfs'],
|
||||
[389, UniversalYAxisUnit.FLOW_CUBIC_FEET_PER_MINUTE, '389 cfm'],
|
||||
[1000, UniversalYAxisUnit.FLOW_CUBIC_FEET_PER_MINUTE, '1000 cfm'],
|
||||
[731, UniversalYAxisUnit.FLOW_LITERS_PER_HOUR, '731 L/h'],
|
||||
[1000, UniversalYAxisUnit.FLOW_LITERS_PER_HOUR, '1000 L/h'],
|
||||
[864, UniversalYAxisUnit.FLOW_LITERS_PER_MINUTE, '864 L/min'],
|
||||
[1000, UniversalYAxisUnit.FLOW_LITERS_PER_MINUTE, '1000 L/min'],
|
||||
[150, UniversalYAxisUnit.FLOW_MILLILITERS_PER_MINUTE, '150 mL/min'],
|
||||
[1000, UniversalYAxisUnit.FLOW_MILLILITERS_PER_MINUTE, '1000 mL/min'],
|
||||
[947, UniversalYAxisUnit.FLOW_LUX, '947 lux'],
|
||||
[1000, UniversalYAxisUnit.FLOW_LUX, '1000 lux'],
|
||||
])('formats flow value %s %s as %s', (value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Force', () => {
|
||||
test.each([
|
||||
[845, UniversalYAxisUnit.FORCE_NEWTON_METERS, '845 Nm'],
|
||||
[1000, UniversalYAxisUnit.FORCE_NEWTON_METERS, '1 kNm'],
|
||||
[1080, UniversalYAxisUnit.FORCE_NEWTON_METERS, '1.08 kNm'],
|
||||
[268, UniversalYAxisUnit.FORCE_KILONEWTON_METERS, '268 kNm'],
|
||||
[1000, UniversalYAxisUnit.FORCE_KILONEWTON_METERS, '1 MNm'],
|
||||
[1080, UniversalYAxisUnit.FORCE_KILONEWTON_METERS, '1.08 MNm'],
|
||||
[593, UniversalYAxisUnit.FORCE_NEWTONS, '593 N'],
|
||||
[1000, UniversalYAxisUnit.FORCE_KILONEWTONS, '1 MN'],
|
||||
[1080, UniversalYAxisUnit.FORCE_KILONEWTONS, '1.08 MN'],
|
||||
])('formats force value %s %s as %s', (value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mass', () => {
|
||||
test.each([
|
||||
[120, UniversalYAxisUnit.MASS_MILLIGRAM, '120 mg'],
|
||||
[120000, UniversalYAxisUnit.MASS_MILLIGRAM, '120 g'],
|
||||
[987, UniversalYAxisUnit.MASS_GRAM, '987 g'],
|
||||
[1020, UniversalYAxisUnit.MASS_GRAM, '1.02 kg'],
|
||||
[456, UniversalYAxisUnit.MASS_POUND, '456 lb'],
|
||||
[321, UniversalYAxisUnit.MASS_KILOGRAM, '321 kg'],
|
||||
[654, UniversalYAxisUnit.MASS_METRIC_TON, '654 t'],
|
||||
])('formats mass value %s %s as %s', (value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Length', () => {
|
||||
test.each([
|
||||
[88, UniversalYAxisUnit.LENGTH_MILLIMETER, '88 mm'],
|
||||
[100, UniversalYAxisUnit.LENGTH_MILLIMETER, '100 mm'],
|
||||
[1000, UniversalYAxisUnit.LENGTH_MILLIMETER, '1 m'],
|
||||
[177, UniversalYAxisUnit.LENGTH_INCH, '177 in'],
|
||||
[266, UniversalYAxisUnit.LENGTH_FOOT, '266 ft'],
|
||||
[355, UniversalYAxisUnit.LENGTH_METER, '355 m'],
|
||||
[355000, UniversalYAxisUnit.LENGTH_METER, '355 km'],
|
||||
[444, UniversalYAxisUnit.LENGTH_KILOMETER, '444 km'],
|
||||
[533, UniversalYAxisUnit.LENGTH_MILE, '533 mi'],
|
||||
])('formats length value %s %s as %s', (value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pressure', () => {
|
||||
test.each([
|
||||
[45, UniversalYAxisUnit.PRESSURE_MILLIBAR, '45 mbar'],
|
||||
[1013, UniversalYAxisUnit.PRESSURE_MILLIBAR, '1.01 bar'],
|
||||
[27, UniversalYAxisUnit.PRESSURE_BAR, '27 bar'],
|
||||
[62, UniversalYAxisUnit.PRESSURE_KILOBAR, '62 kbar'],
|
||||
[845, UniversalYAxisUnit.PRESSURE_PASCAL, '845 Pa'],
|
||||
[540, UniversalYAxisUnit.PRESSURE_HECTOPASCAL, '540 hPa'],
|
||||
[378, UniversalYAxisUnit.PRESSURE_KILOPASCAL, '378 kPa'],
|
||||
[29, UniversalYAxisUnit.PRESSURE_INCHES_HG, '29 "Hg'],
|
||||
[65, UniversalYAxisUnit.PRESSURE_PSI, '65psi'],
|
||||
])('formats pressure value %s %s as %s', (value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Radiation', () => {
|
||||
test.each([
|
||||
[452, UniversalYAxisUnit.RADIATION_BECQUEREL, '452 Bq'],
|
||||
[37, UniversalYAxisUnit.RADIATION_CURIE, '37 Ci'],
|
||||
[128, UniversalYAxisUnit.RADIATION_GRAY, '128 Gy'],
|
||||
[512, UniversalYAxisUnit.RADIATION_RAD, '512 rad'],
|
||||
[256, UniversalYAxisUnit.RADIATION_SIEVERT, '256 Sv'],
|
||||
[640, UniversalYAxisUnit.RADIATION_MILLISIEVERT, '640 mSv'],
|
||||
[875, UniversalYAxisUnit.RADIATION_MICROSIEVERT, '875 µSv'],
|
||||
[875000, UniversalYAxisUnit.RADIATION_MICROSIEVERT, '875 mSv'],
|
||||
[92, UniversalYAxisUnit.RADIATION_REM, '92 rem'],
|
||||
[715, UniversalYAxisUnit.RADIATION_EXPOSURE_C_PER_KG, '715 C/kg'],
|
||||
[833, UniversalYAxisUnit.RADIATION_ROENTGEN, '833 R'],
|
||||
[468, UniversalYAxisUnit.RADIATION_SIEVERT_PER_HOUR, '468 Sv/h'],
|
||||
[590, UniversalYAxisUnit.RADIATION_MILLISIEVERT_PER_HOUR, '590 mSv/h'],
|
||||
[712, UniversalYAxisUnit.RADIATION_MICROSIEVERT_PER_HOUR, '712 µSv/h'],
|
||||
])('formats radiation value %s %s as %s', (value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rotation Speed', () => {
|
||||
test.each([
|
||||
[345, UniversalYAxisUnit.ROTATION_SPEED_REVOLUTIONS_PER_MINUTE, '345 rpm'],
|
||||
[789, UniversalYAxisUnit.ROTATION_SPEED_HERTZ, '789 Hz'],
|
||||
[789000, UniversalYAxisUnit.ROTATION_SPEED_HERTZ, '789 kHz'],
|
||||
[213, UniversalYAxisUnit.ROTATION_SPEED_RADIANS_PER_SECOND, '213 rad/s'],
|
||||
[654, UniversalYAxisUnit.ROTATION_SPEED_DEGREES_PER_SECOND, '654 °/s'],
|
||||
])('formats rotation speed value %s %s as %s', (value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Temperature', () => {
|
||||
test.each([
|
||||
[37, UniversalYAxisUnit.TEMPERATURE_CELSIUS, '37 °C'],
|
||||
[451, UniversalYAxisUnit.TEMPERATURE_FAHRENHEIT, '451 °F'],
|
||||
[310, UniversalYAxisUnit.TEMPERATURE_KELVIN, '310 K'],
|
||||
])('formats temperature value %s %s as %s', (value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Velocity', () => {
|
||||
test.each([
|
||||
[900, UniversalYAxisUnit.VELOCITY_METERS_PER_SECOND, '900 m/s'],
|
||||
[456, UniversalYAxisUnit.VELOCITY_KILOMETERS_PER_HOUR, '456 km/h'],
|
||||
[789, UniversalYAxisUnit.VELOCITY_MILES_PER_HOUR, '789 mph'],
|
||||
[222, UniversalYAxisUnit.VELOCITY_KNOT, '222 kn'],
|
||||
])('formats velocity value %s %s as %s', (value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Volume', () => {
|
||||
test.each([
|
||||
[1200, UniversalYAxisUnit.VOLUME_MILLILITER, '1.2 L'],
|
||||
[9000000, UniversalYAxisUnit.VOLUME_MILLILITER, '9 kL'],
|
||||
[9, UniversalYAxisUnit.VOLUME_LITER, '9 L'],
|
||||
[9000, UniversalYAxisUnit.VOLUME_LITER, '9 kL'],
|
||||
[9000000, UniversalYAxisUnit.VOLUME_LITER, '9 ML'],
|
||||
[9000000000, UniversalYAxisUnit.VOLUME_LITER, '9 GL'],
|
||||
[9000000000000, UniversalYAxisUnit.VOLUME_LITER, '9 TL'],
|
||||
[9000000000000000, UniversalYAxisUnit.VOLUME_LITER, '9 PL'],
|
||||
[9010000000000000000, UniversalYAxisUnit.VOLUME_LITER, '9.01 EL'],
|
||||
[9020000000000000000000, UniversalYAxisUnit.VOLUME_LITER, '9.02 ZL'],
|
||||
[9030000000000000000000000, UniversalYAxisUnit.VOLUME_LITER, '9.03 YL'],
|
||||
[900, UniversalYAxisUnit.VOLUME_CUBIC_METER, '900 m³'],
|
||||
[
|
||||
9000000000000000000000000000000,
|
||||
UniversalYAxisUnit.VOLUME_CUBIC_METER,
|
||||
'9e+30 m³',
|
||||
],
|
||||
[900, UniversalYAxisUnit.VOLUME_NORMAL_CUBIC_METER, '900 Nm³'],
|
||||
[
|
||||
9000000000000000000000000000000,
|
||||
UniversalYAxisUnit.VOLUME_NORMAL_CUBIC_METER,
|
||||
'9e+30 Nm³',
|
||||
],
|
||||
[900, UniversalYAxisUnit.VOLUME_CUBIC_DECIMETER, '900 dm³'],
|
||||
[
|
||||
9000000000000000000000000000000,
|
||||
UniversalYAxisUnit.VOLUME_CUBIC_DECIMETER,
|
||||
'9e+30 dm³',
|
||||
],
|
||||
[900, UniversalYAxisUnit.VOLUME_GALLON, '900 gal'],
|
||||
[
|
||||
9000000000000000000000000000000,
|
||||
UniversalYAxisUnit.VOLUME_GALLON,
|
||||
'9e+30 gal',
|
||||
],
|
||||
])('formats volume value %s %s as %s', (value, unit, expected) => {
|
||||
expect(formatUniversalUnit(value, unit)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Boolean', () => {
|
||||
it('formats boolean units', () => {
|
||||
expect(formatUniversalUnit(1, UniversalYAxisUnit.TRUE_FALSE)).toBe('True');
|
||||
expect(formatUniversalUnit(1, UniversalYAxisUnit.YES_NO)).toBe('Yes');
|
||||
expect(formatUniversalUnit(1, UniversalYAxisUnit.ON_OFF)).toBe('On');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mapping Validator', () => {
|
||||
it('validates that all units have a mapping', () => {
|
||||
// Each universal unit should have a mapping to a 1:1 Grafana unit in UniversalUnitToGrafanaUnit or an additional mapping in AdditionalLabelsMappingForGrafanaUnits
|
||||
const units = Object.values(UniversalYAxisUnit);
|
||||
expect(
|
||||
units.every((unit) => {
|
||||
const hasBaseMapping = unit in UniversalUnitToGrafanaUnit;
|
||||
const hasAdditionalMapping = unit in AdditionalLabelsMappingForGrafanaUnits;
|
||||
const hasMapping = hasBaseMapping || hasAdditionalMapping;
|
||||
if (!hasMapping) {
|
||||
throw new Error(`Unit ${unit} does not have a mapping`);
|
||||
}
|
||||
return hasMapping;
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { UniversalYAxisUnit } from '../types';
|
||||
import {
|
||||
getUniversalNameFromMetricUnit,
|
||||
mapMetricUnitToUniversalUnit,
|
||||
mergeCategories,
|
||||
} from '../utils';
|
||||
|
||||
describe('YAxisUnitSelector utils', () => {
|
||||
@@ -36,4 +38,43 @@ describe('YAxisUnitSelector utils', () => {
|
||||
expect(getUniversalNameFromMetricUnit('s')).toBe('Seconds (s)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeCategories', () => {
|
||||
it('merges categories correctly', () => {
|
||||
const categories1 = [
|
||||
{
|
||||
name: 'Data',
|
||||
units: [
|
||||
{ name: 'bytes', id: UniversalYAxisUnit.BYTES },
|
||||
{ name: 'kilobytes', id: UniversalYAxisUnit.KILOBYTES },
|
||||
],
|
||||
},
|
||||
];
|
||||
const categories2 = [
|
||||
{
|
||||
name: 'Data',
|
||||
units: [{ name: 'bits', id: UniversalYAxisUnit.BITS }],
|
||||
},
|
||||
{
|
||||
name: 'Time',
|
||||
units: [{ name: 'seconds', id: UniversalYAxisUnit.SECONDS }],
|
||||
},
|
||||
];
|
||||
const mergedCategories = mergeCategories(categories1, categories2);
|
||||
expect(mergedCategories).toEqual([
|
||||
{
|
||||
name: 'Data',
|
||||
units: [
|
||||
{ name: 'bytes', id: UniversalYAxisUnit.BYTES },
|
||||
{ name: 'kilobytes', id: UniversalYAxisUnit.KILOBYTES },
|
||||
{ name: 'bits', id: UniversalYAxisUnit.BITS },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Time',
|
||||
units: [{ name: 'seconds', id: UniversalYAxisUnit.SECONDS }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
1269
frontend/src/components/YAxisUnitSelector/data.ts
Normal file
90
frontend/src/components/YAxisUnitSelector/formatter.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { formattedValueToString, getValueFormat } from '@grafana/data';
|
||||
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
|
||||
import { formatDecimalWithLeadingZeros } from 'components/Graph/utils';
|
||||
import {
|
||||
AdditionalLabelsMappingForGrafanaUnits,
|
||||
CUSTOM_SCALING_FAMILIES,
|
||||
UniversalUnitToGrafanaUnit,
|
||||
} from 'components/YAxisUnitSelector/constants';
|
||||
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
|
||||
|
||||
function scaleValue(
|
||||
value: number,
|
||||
unit: UniversalYAxisUnit,
|
||||
family: UniversalYAxisUnit[],
|
||||
factor: number,
|
||||
): { value: number; label: string } {
|
||||
let idx = family.indexOf(unit);
|
||||
// If the unit is not in the family, return the unit with the additional label
|
||||
if (idx === -1) {
|
||||
return { value, label: AdditionalLabelsMappingForGrafanaUnits[unit] || '' };
|
||||
}
|
||||
|
||||
// Scale the value up or down to the nearest unit in the family
|
||||
let scaled = value;
|
||||
// Scale up
|
||||
while (scaled >= factor && idx < family.length - 1) {
|
||||
scaled /= factor;
|
||||
idx += 1;
|
||||
}
|
||||
// Scale down
|
||||
while (scaled < 1 && idx > 0) {
|
||||
scaled *= factor;
|
||||
idx -= 1;
|
||||
}
|
||||
|
||||
// Return the scaled value and the label of the nearest unit in the family
|
||||
return {
|
||||
value: scaled,
|
||||
label: AdditionalLabelsMappingForGrafanaUnits[family[idx]] || '',
|
||||
};
|
||||
}
|
||||
|
||||
export function formatUniversalUnit(
|
||||
value: number,
|
||||
unit: UniversalYAxisUnit,
|
||||
precision: PrecisionOption = PrecisionOptionsEnum.FULL,
|
||||
decimals: number | undefined = undefined,
|
||||
): string {
|
||||
// Check if this unit belongs to a family that needs custom scaling
|
||||
const family = CUSTOM_SCALING_FAMILIES.find((family) =>
|
||||
family.units.includes(unit),
|
||||
);
|
||||
if (family) {
|
||||
const scaled = scaleValue(value, unit, family.units, family.scaleFactor);
|
||||
const formatter = getValueFormat(scaled.label);
|
||||
const formatted = formatter(scaled.value, decimals);
|
||||
if (formatted.text && formatted.text.includes('.')) {
|
||||
formatted.text = formatDecimalWithLeadingZeros(
|
||||
parseFloat(formatted.text),
|
||||
precision,
|
||||
);
|
||||
}
|
||||
return `${formatted.text} ${scaled.label}`;
|
||||
}
|
||||
|
||||
// Use Grafana formatting with custom label mappings
|
||||
const grafanaFormat = UniversalUnitToGrafanaUnit[unit];
|
||||
if (grafanaFormat) {
|
||||
const formatter = getValueFormat(grafanaFormat);
|
||||
const formatted = formatter(value, decimals);
|
||||
if (formatted.text && formatted.text.includes('.')) {
|
||||
formatted.text = formatDecimalWithLeadingZeros(
|
||||
parseFloat(formatted.text),
|
||||
precision,
|
||||
);
|
||||
}
|
||||
return formattedValueToString(formatted);
|
||||
}
|
||||
|
||||
// Fallback to short format for other units
|
||||
const formatter = getValueFormat('short');
|
||||
const formatted = formatter(value, decimals);
|
||||
if (formatted.text && formatted.text.includes('.')) {
|
||||
formatted.text = formatDecimalWithLeadingZeros(
|
||||
parseFloat(formatted.text),
|
||||
precision,
|
||||
);
|
||||
}
|
||||
return `${formatted.text} ${unit}`;
|
||||
}
|
||||
@@ -5,11 +5,11 @@ export interface YAxisUnitSelectorProps {
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
'data-testid'?: string;
|
||||
source: YAxisSource;
|
||||
}
|
||||
|
||||
export enum UniversalYAxisUnit {
|
||||
// Time
|
||||
WEEKS = 'wk',
|
||||
DAYS = 'd',
|
||||
HOURS = 'h',
|
||||
MINUTES = 'min',
|
||||
@@ -17,6 +17,14 @@ export enum UniversalYAxisUnit {
|
||||
MICROSECONDS = 'us',
|
||||
MILLISECONDS = 'ms',
|
||||
NANOSECONDS = 'ns',
|
||||
DURATION_MS = 'dtdurationms',
|
||||
DURATION_S = 'dtdurations',
|
||||
DURATION_HMS = 'dthms',
|
||||
DURATION_DHMS = 'dtdhms',
|
||||
TIMETICKS = 'timeticks',
|
||||
CLOCK_MS = 'clockms',
|
||||
CLOCK_S = 'clocks',
|
||||
TIME_HERTZ = 'hertz',
|
||||
|
||||
// Data
|
||||
BYTES = 'By',
|
||||
@@ -29,6 +37,17 @@ export enum UniversalYAxisUnit {
|
||||
ZETTABYTES = 'ZBy',
|
||||
YOTTABYTES = 'YBy',
|
||||
|
||||
// Binary (IEC) Data
|
||||
BYTES_IEC = 'bytes',
|
||||
KIBIBYTES = 'KiBy',
|
||||
MEBIBYTES = 'MiBy',
|
||||
GIBIBYTES = 'GiBy',
|
||||
TEBIBYTES = 'TiBy',
|
||||
PEBIBYTES = 'PiBy',
|
||||
EXBIBYTES = 'EiBy',
|
||||
ZEBIBYTES = 'ZiBy',
|
||||
YOBIBYTES = 'YiBy',
|
||||
|
||||
// Data Rate
|
||||
BYTES_SECOND = 'By/s',
|
||||
KILOBYTES_SECOND = 'kBy/s',
|
||||
@@ -39,9 +58,21 @@ export enum UniversalYAxisUnit {
|
||||
EXABYTES_SECOND = 'EBy/s',
|
||||
ZETTABYTES_SECOND = 'ZBy/s',
|
||||
YOTTABYTES_SECOND = 'YBy/s',
|
||||
DATA_RATE_PACKETS_PER_SECOND = 'pps',
|
||||
|
||||
// Binary (IEC) Data Rate
|
||||
KIBIBYTES_SECOND = 'KiBy/s',
|
||||
MEBIBYTES_SECOND = 'MiBy/s',
|
||||
GIBIBYTES_SECOND = 'GiBy/s',
|
||||
TEBIBYTES_SECOND = 'TiBy/s',
|
||||
PEBIBYTES_SECOND = 'PiBy/s',
|
||||
EXBIBYTES_SECOND = 'EiBy/s',
|
||||
ZEBIBYTES_SECOND = 'ZiBy/s',
|
||||
YOBIBYTES_SECOND = 'YiBy/s',
|
||||
|
||||
// Bits
|
||||
BITS = 'bit',
|
||||
BITS_IEC = 'bits',
|
||||
KILOBITS = 'kbit',
|
||||
MEGABITS = 'Mbit',
|
||||
GIGABITS = 'Gbit',
|
||||
@@ -62,6 +93,16 @@ export enum UniversalYAxisUnit {
|
||||
ZETTABITS_SECOND = 'Zbit/s',
|
||||
YOTTABITS_SECOND = 'Ybit/s',
|
||||
|
||||
// Binary (IEC) Bit Rate
|
||||
KIBIBITS_SECOND = 'Kibit/s',
|
||||
MEBIBITS_SECOND = 'Mibit/s',
|
||||
GIBIBITS_SECOND = 'Gibit/s',
|
||||
TEBIBITS_SECOND = 'Tibit/s',
|
||||
PEBIBITS_SECOND = 'Pibit/s',
|
||||
EXBIBITS_SECOND = 'Eibit/s',
|
||||
ZEBIBITS_SECOND = 'Zibit/s',
|
||||
YOBIBITS_SECOND = 'Yibit/s',
|
||||
|
||||
// Count
|
||||
COUNT = '{count}',
|
||||
COUNT_SECOND = '{count}/s',
|
||||
@@ -87,7 +128,231 @@ export enum UniversalYAxisUnit {
|
||||
// Percent
|
||||
PERCENT = '%',
|
||||
PERCENT_UNIT = 'percentunit',
|
||||
|
||||
// Boolean
|
||||
TRUE_FALSE = '{bool}',
|
||||
YES_NO = '{bool_yn}',
|
||||
ON_OFF = 'bool_on_off',
|
||||
|
||||
// None
|
||||
NONE = '1',
|
||||
|
||||
// Hash rate
|
||||
HASH_RATE_HASHES_PER_SECOND = 'Hs',
|
||||
HASH_RATE_KILOHASHES_PER_SECOND = 'KHs',
|
||||
HASH_RATE_MEGAHASHES_PER_SECOND = 'MHs',
|
||||
HASH_RATE_GIGAHASHES_PER_SECOND = 'GHs',
|
||||
HASH_RATE_TERAHASHES_PER_SECOND = 'THs',
|
||||
HASH_RATE_PETAHASHES_PER_SECOND = 'PHs',
|
||||
HASH_RATE_EXAHASHES_PER_SECOND = 'EHs',
|
||||
|
||||
// Miscellaneous
|
||||
MISC_STRING = 'string',
|
||||
MISC_SHORT = 'short',
|
||||
MISC_HUMIDITY = 'humidity',
|
||||
MISC_DECIBEL = 'dB',
|
||||
MISC_HEXADECIMAL = 'hex',
|
||||
MISC_HEXADECIMAL_0X = 'hex0x',
|
||||
MISC_SCIENTIFIC_NOTATION = 'sci',
|
||||
MISC_LOCALE_FORMAT = 'locale',
|
||||
MISC_PIXELS = 'pixel',
|
||||
|
||||
// Acceleration
|
||||
ACCELERATION_METERS_PER_SECOND_SQUARED = 'accMS2',
|
||||
ACCELERATION_FEET_PER_SECOND_SQUARED = 'accFS2',
|
||||
ACCELERATION_G_UNIT = 'accG',
|
||||
|
||||
// Angular
|
||||
ANGULAR_DEGREE = 'degree',
|
||||
ANGULAR_RADIAN = 'radian',
|
||||
ANGULAR_GRADIAN = 'grad',
|
||||
ANGULAR_ARC_MINUTE = 'arcmin',
|
||||
ANGULAR_ARC_SECOND = 'arcsec',
|
||||
|
||||
// Area
|
||||
AREA_SQUARE_METERS = 'areaM2',
|
||||
AREA_SQUARE_FEET = 'areaF2',
|
||||
AREA_SQUARE_MILES = 'areaMI2',
|
||||
|
||||
// FLOPs
|
||||
FLOPS_FLOPS = 'flops',
|
||||
FLOPS_MFLOPS = 'mflops',
|
||||
FLOPS_GFLOPS = 'gflops',
|
||||
FLOPS_TFLOPS = 'tflops',
|
||||
FLOPS_PFLOPS = 'pflops',
|
||||
FLOPS_EFLOPS = 'eflops',
|
||||
FLOPS_ZFLOPS = 'zflops',
|
||||
FLOPS_YFLOPS = 'yflops',
|
||||
|
||||
// Concentration
|
||||
CONCENTRATION_PPM = 'ppm',
|
||||
CONCENTRATION_PPB = 'conppb',
|
||||
CONCENTRATION_NG_M3 = 'conngm3',
|
||||
CONCENTRATION_NG_NORMAL_CUBIC_METER = 'conngNm3',
|
||||
CONCENTRATION_UG_M3 = 'conμgm3',
|
||||
CONCENTRATION_UG_NORMAL_CUBIC_METER = 'conμgNm3',
|
||||
CONCENTRATION_MG_M3 = 'conmgm3',
|
||||
CONCENTRATION_MG_NORMAL_CUBIC_METER = 'conmgNm3',
|
||||
CONCENTRATION_G_M3 = 'congm3',
|
||||
CONCENTRATION_G_NORMAL_CUBIC_METER = 'congNm3',
|
||||
CONCENTRATION_MG_PER_DL = 'conmgdL',
|
||||
CONCENTRATION_MMOL_PER_L = 'conmmolL',
|
||||
|
||||
// Currency
|
||||
CURRENCY_USD = 'currencyUSD',
|
||||
CURRENCY_GBP = 'currencyGBP',
|
||||
CURRENCY_EUR = 'currencyEUR',
|
||||
CURRENCY_JPY = 'currencyJPY',
|
||||
CURRENCY_RUB = 'currencyRUB',
|
||||
CURRENCY_UAH = 'currencyUAH',
|
||||
CURRENCY_BRL = 'currencyBRL',
|
||||
CURRENCY_DKK = 'currencyDKK',
|
||||
CURRENCY_ISK = 'currencyISK',
|
||||
CURRENCY_NOK = 'currencyNOK',
|
||||
CURRENCY_SEK = 'currencySEK',
|
||||
CURRENCY_CZK = 'currencyCZK',
|
||||
CURRENCY_CHF = 'currencyCHF',
|
||||
CURRENCY_PLN = 'currencyPLN',
|
||||
CURRENCY_BTC = 'currencyBTC',
|
||||
CURRENCY_MBTC = 'currencymBTC',
|
||||
CURRENCY_UBTC = 'currencyμBTC',
|
||||
CURRENCY_ZAR = 'currencyZAR',
|
||||
CURRENCY_INR = 'currencyINR',
|
||||
CURRENCY_KRW = 'currencyKRW',
|
||||
CURRENCY_IDR = 'currencyIDR',
|
||||
CURRENCY_PHP = 'currencyPHP',
|
||||
CURRENCY_VND = 'currencyVND',
|
||||
|
||||
// Datetime
|
||||
DATETIME_ISO = 'dateTimeAsIso',
|
||||
DATETIME_ISO_NO_DATE_IF_TODAY = 'dateTimeAsIsoNoDateIfToday',
|
||||
DATETIME_US = 'dateTimeAsUS',
|
||||
DATETIME_US_NO_DATE_IF_TODAY = 'dateTimeAsUSNoDateIfToday',
|
||||
DATETIME_LOCAL = 'dateTimeAsLocal',
|
||||
DATETIME_LOCAL_NO_DATE_IF_TODAY = 'dateTimeAsLocalNoDateIfToday',
|
||||
DATETIME_SYSTEM = 'dateTimeAsSystem',
|
||||
DATETIME_FROM_NOW = 'dateTimeFromNow',
|
||||
|
||||
// Power/Electrical
|
||||
POWER_WATT = 'watt',
|
||||
POWER_KILOWATT = 'kwatt',
|
||||
POWER_MEGAWATT = 'megwatt',
|
||||
POWER_GIGAWATT = 'gwatt',
|
||||
POWER_MILLIWATT = 'mwatt',
|
||||
POWER_WATT_PER_SQUARE_METER = 'Wm2',
|
||||
POWER_VOLT_AMPERE = 'voltamp',
|
||||
POWER_KILOVOLT_AMPERE = 'kvoltamp',
|
||||
POWER_VOLT_AMPERE_REACTIVE = 'voltampreact',
|
||||
POWER_KILOVOLT_AMPERE_REACTIVE = 'kvoltampreact',
|
||||
POWER_WATT_HOUR = 'watth',
|
||||
POWER_WATT_HOUR_PER_KG = 'watthperkg',
|
||||
POWER_KILOWATT_HOUR = 'kwatth',
|
||||
POWER_KILOWATT_MINUTE = 'kwattm',
|
||||
POWER_AMPERE_HOUR = 'amph',
|
||||
POWER_KILOAMPERE_HOUR = 'kamph',
|
||||
POWER_MILLIAMPERE_HOUR = 'mamph',
|
||||
POWER_JOULE = 'joule',
|
||||
POWER_ELECTRON_VOLT = 'ev',
|
||||
POWER_AMPERE = 'amp',
|
||||
POWER_KILOAMPERE = 'kamp',
|
||||
POWER_MILLIAMPERE = 'mamp',
|
||||
POWER_VOLT = 'volt',
|
||||
POWER_KILOVOLT = 'kvolt',
|
||||
POWER_MILLIVOLT = 'mvolt',
|
||||
POWER_DECIBEL_MILLIWATT = 'dBm',
|
||||
POWER_OHM = 'ohm',
|
||||
POWER_KILOOHM = 'kohm',
|
||||
POWER_MEGAOHM = 'Mohm',
|
||||
POWER_FARAD = 'farad',
|
||||
POWER_MICROFARAD = 'µfarad',
|
||||
POWER_NANOFARAD = 'nfarad',
|
||||
POWER_PICOFARAD = 'pfarad',
|
||||
POWER_FEMTOFARAD = 'ffarad',
|
||||
POWER_HENRY = 'henry',
|
||||
POWER_MILLIHENRY = 'mhenry',
|
||||
POWER_MICROHENRY = 'µhenry',
|
||||
POWER_LUMENS = 'lumens',
|
||||
|
||||
// Flow
|
||||
FLOW_GALLONS_PER_MINUTE = 'flowgpm',
|
||||
FLOW_CUBIC_METERS_PER_SECOND = 'flowcms',
|
||||
FLOW_CUBIC_FEET_PER_SECOND = 'flowcfs',
|
||||
FLOW_CUBIC_FEET_PER_MINUTE = 'flowcfm',
|
||||
FLOW_LITERS_PER_HOUR = 'litreh',
|
||||
FLOW_LITERS_PER_MINUTE = 'flowlpm',
|
||||
FLOW_MILLILITERS_PER_MINUTE = 'flowmlpm',
|
||||
FLOW_LUX = 'lux',
|
||||
|
||||
// Force
|
||||
FORCE_NEWTON_METERS = 'forceNm',
|
||||
FORCE_KILONEWTON_METERS = 'forcekNm',
|
||||
FORCE_NEWTONS = 'forceN',
|
||||
FORCE_KILONEWTONS = 'forcekN',
|
||||
|
||||
// Mass
|
||||
MASS_MILLIGRAM = 'massmg',
|
||||
MASS_GRAM = 'massg',
|
||||
MASS_POUND = 'masslb',
|
||||
MASS_KILOGRAM = 'masskg',
|
||||
MASS_METRIC_TON = 'masst',
|
||||
|
||||
// Length
|
||||
LENGTH_MILLIMETER = 'lengthmm',
|
||||
LENGTH_INCH = 'lengthin',
|
||||
LENGTH_FOOT = 'lengthft',
|
||||
LENGTH_METER = 'lengthm',
|
||||
LENGTH_KILOMETER = 'lengthkm',
|
||||
LENGTH_MILE = 'lengthmi',
|
||||
|
||||
// Pressure
|
||||
PRESSURE_MILLIBAR = 'pressurembar',
|
||||
PRESSURE_BAR = 'pressurebar',
|
||||
PRESSURE_KILOBAR = 'pressurekbar',
|
||||
PRESSURE_PASCAL = 'pressurepa',
|
||||
PRESSURE_HECTOPASCAL = 'pressurehpa',
|
||||
PRESSURE_KILOPASCAL = 'pressurekpa',
|
||||
PRESSURE_INCHES_HG = 'pressurehg',
|
||||
PRESSURE_PSI = 'pressurepsi',
|
||||
|
||||
// Radiation
|
||||
RADIATION_BECQUEREL = 'radbq',
|
||||
RADIATION_CURIE = 'radci',
|
||||
RADIATION_GRAY = 'radgy',
|
||||
RADIATION_RAD = 'radrad',
|
||||
RADIATION_SIEVERT = 'radsv',
|
||||
RADIATION_MILLISIEVERT = 'radmsv',
|
||||
RADIATION_MICROSIEVERT = 'radusv',
|
||||
RADIATION_REM = 'radrem',
|
||||
RADIATION_EXPOSURE_C_PER_KG = 'radexpckg',
|
||||
RADIATION_ROENTGEN = 'radr',
|
||||
RADIATION_SIEVERT_PER_HOUR = 'radsvh',
|
||||
RADIATION_MILLISIEVERT_PER_HOUR = 'radmsvh',
|
||||
RADIATION_MICROSIEVERT_PER_HOUR = 'radusvh',
|
||||
|
||||
// Rotation speed
|
||||
ROTATION_SPEED_REVOLUTIONS_PER_MINUTE = 'rotrpm',
|
||||
ROTATION_SPEED_HERTZ = 'rothz',
|
||||
ROTATION_SPEED_RADIANS_PER_SECOND = 'rotrads',
|
||||
ROTATION_SPEED_DEGREES_PER_SECOND = 'rotdegs',
|
||||
|
||||
// Temperature
|
||||
TEMPERATURE_CELSIUS = 'celsius',
|
||||
TEMPERATURE_FAHRENHEIT = 'fahrenheit',
|
||||
TEMPERATURE_KELVIN = 'kelvin',
|
||||
|
||||
// Velocity
|
||||
VELOCITY_METERS_PER_SECOND = 'velocityms',
|
||||
VELOCITY_KILOMETERS_PER_HOUR = 'velocitykmh',
|
||||
VELOCITY_MILES_PER_HOUR = 'velocitymph',
|
||||
VELOCITY_KNOT = 'velocityknot',
|
||||
|
||||
// Volume
|
||||
VOLUME_MILLILITER = 'mlitre',
|
||||
VOLUME_LITER = 'litre',
|
||||
VOLUME_CUBIC_METER = 'm3',
|
||||
VOLUME_NORMAL_CUBIC_METER = 'Nm3',
|
||||
VOLUME_CUBIC_DECIMETER = 'dm3',
|
||||
VOLUME_GALLON = 'gallons',
|
||||
}
|
||||
|
||||
export enum YAxisUnit {
|
||||
@@ -293,6 +558,15 @@ export enum YAxisUnit {
|
||||
UCUM_PEBIBYTES = 'PiBy',
|
||||
OPEN_METRICS_PEBIBYTES = 'pebibytes',
|
||||
|
||||
UCUM_EXBIBYTES = 'EiBy',
|
||||
OPEN_METRICS_EXBIBYTES = 'exbibytes',
|
||||
|
||||
UCUM_ZEBIBYTES = 'ZiBy',
|
||||
OPEN_METRICS_ZEBIBYTES = 'zebibytes',
|
||||
|
||||
UCUM_YOBIBYTES = 'YiBy',
|
||||
OPEN_METRICS_YOBIBYTES = 'yobibytes',
|
||||
|
||||
UCUM_KIBIBYTES_SECOND = 'KiBy/s',
|
||||
OPEN_METRICS_KIBIBYTES_SECOND = 'kibibytes_per_second',
|
||||
|
||||
@@ -323,6 +597,24 @@ export enum YAxisUnit {
|
||||
UCUM_PEBIBITS_SECOND = 'Pibit/s',
|
||||
OPEN_METRICS_PEBIBITS_SECOND = 'pebibits_per_second',
|
||||
|
||||
UCUM_EXBIBYTES_SECOND = 'EiBy/s',
|
||||
OPEN_METRICS_EXBIBYTES_SECOND = 'exbibytes_per_second',
|
||||
|
||||
UCUM_EXBIBITS_SECOND = 'Eibit/s',
|
||||
OPEN_METRICS_EXBIBITS_SECOND = 'exbibits_per_second',
|
||||
|
||||
UCUM_ZEBIBYTES_SECOND = 'ZiBy/s',
|
||||
OPEN_METRICS_ZEBIBYTES_SECOND = 'zebibytes_per_second',
|
||||
|
||||
UCUM_ZEBIBITS_SECOND = 'Zibit/s',
|
||||
OPEN_METRICS_ZEBIBITS_SECOND = 'zebibits_per_second',
|
||||
|
||||
UCUM_YOBIBYTES_SECOND = 'YiBy/s',
|
||||
OPEN_METRICS_YOBIBYTES_SECOND = 'yobibytes_per_second',
|
||||
|
||||
UCUM_YOBIBITS_SECOND = 'Yibit/s',
|
||||
OPEN_METRICS_YOBIBITS_SECOND = 'yobibits_per_second',
|
||||
|
||||
UCUM_TRUE_FALSE = '{bool}',
|
||||
OPEN_METRICS_TRUE_FALSE = 'boolean_true_false',
|
||||
|
||||
@@ -364,3 +656,27 @@ export enum YAxisUnit {
|
||||
|
||||
OPEN_METRICS_PERCENT_UNIT = 'percentunit',
|
||||
}
|
||||
|
||||
export interface ScaledValue {
|
||||
value: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface UnitFamilyConfig {
|
||||
units: UniversalYAxisUnit[];
|
||||
scaleFactor: number;
|
||||
}
|
||||
|
||||
export interface YAxisCategory {
|
||||
name: string;
|
||||
units: {
|
||||
name: string;
|
||||
id: UniversalYAxisUnit;
|
||||
}[];
|
||||
}
|
||||
|
||||
export enum YAxisSource {
|
||||
ALERTS = 'alerts',
|
||||
DASHBOARDS = 'dashboards',
|
||||
EXPLORER = 'explorer',
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { UniversalYAxisUnitMappings, Y_AXIS_UNIT_NAMES } from './constants';
|
||||
import { UniversalYAxisUnit, YAxisUnit } from './types';
|
||||
import { ADDITIONAL_Y_AXIS_CATEGORIES, BASE_Y_AXIS_CATEGORIES } from './data';
|
||||
import {
|
||||
UniversalYAxisUnit,
|
||||
YAxisCategory,
|
||||
YAxisSource,
|
||||
YAxisUnit,
|
||||
} from './types';
|
||||
|
||||
export const mapMetricUnitToUniversalUnit = (
|
||||
unit: string | undefined,
|
||||
@@ -9,7 +15,7 @@ export const mapMetricUnitToUniversalUnit = (
|
||||
}
|
||||
|
||||
const universalUnit = Object.values(UniversalYAxisUnit).find(
|
||||
(u) => UniversalYAxisUnitMappings[u].has(unit as YAxisUnit) || unit === u,
|
||||
(u) => UniversalYAxisUnitMappings[u]?.has(unit as YAxisUnit) || unit === u,
|
||||
);
|
||||
|
||||
return universalUnit || (unit as UniversalYAxisUnit) || null;
|
||||
@@ -31,3 +37,44 @@ export const getUniversalNameFromMetricUnit = (
|
||||
|
||||
return universalName || unit || '-';
|
||||
};
|
||||
|
||||
export function isUniversalUnit(format: string): boolean {
|
||||
return Object.values(UniversalYAxisUnit).includes(
|
||||
format as UniversalYAxisUnit,
|
||||
);
|
||||
}
|
||||
|
||||
export function mergeCategories(
|
||||
categories1: YAxisCategory[],
|
||||
categories2: YAxisCategory[],
|
||||
): YAxisCategory[] {
|
||||
const mapOfCategories = new Map<string, YAxisCategory>();
|
||||
|
||||
categories1.forEach((category) => {
|
||||
mapOfCategories.set(category.name, category);
|
||||
});
|
||||
|
||||
categories2.forEach((category) => {
|
||||
if (mapOfCategories.has(category.name)) {
|
||||
mapOfCategories.set(category.name, {
|
||||
name: category.name,
|
||||
units: [
|
||||
...(mapOfCategories.get(category.name)?.units ?? []),
|
||||
...category.units,
|
||||
],
|
||||
});
|
||||
} else {
|
||||
mapOfCategories.set(category.name, category);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(mapOfCategories.values());
|
||||
}
|
||||
|
||||
export function getYAxisCategories(source: YAxisSource): YAxisCategory[] {
|
||||
if (source !== YAxisSource.DASHBOARDS) {
|
||||
return BASE_Y_AXIS_CATEGORIES;
|
||||
}
|
||||
|
||||
return mergeCategories(BASE_Y_AXIS_CATEGORIES, ADDITIONAL_Y_AXIS_CATEGORIES);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* src/components/cmdKPalette/__test__/cmdkPalette.test.tsx
|
||||
*/
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
|
||||
// ---- Mocks (must run BEFORE importing the component) ----
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import { CmdKPalette } from '../cmdKPalette';
|
||||
|
||||
const HOME_LABEL = 'Go to Home';
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||||
configurable: true,
|
||||
value: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// restore
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
delete (HTMLElement.prototype as any).scrollIntoView;
|
||||
});
|
||||
|
||||
// mock history.push / replace / go / location
|
||||
jest.mock('lib/history', () => {
|
||||
const location = { pathname: '/', search: '', hash: '' };
|
||||
|
||||
const stack: { pathname: string; search: string }[] = [
|
||||
{ pathname: '/', search: '' },
|
||||
];
|
||||
|
||||
const push = jest.fn((path: string) => {
|
||||
const [rawPath, rawQuery] = path.split('?');
|
||||
const pathname = rawPath || '/';
|
||||
const search = path.includes('?') ? `?${rawQuery || ''}` : '';
|
||||
|
||||
location.pathname = pathname;
|
||||
location.search = search;
|
||||
|
||||
stack.push({ pathname, search });
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const replace = jest.fn((path: string) => {
|
||||
const [rawPath, rawQuery] = path.split('?');
|
||||
const pathname = rawPath || '/';
|
||||
const search = path.includes('?') ? `?${rawQuery || ''}` : '';
|
||||
|
||||
location.pathname = pathname;
|
||||
location.search = search;
|
||||
|
||||
if (stack.length > 0) {
|
||||
stack[stack.length - 1] = { pathname, search };
|
||||
} else {
|
||||
stack.push({ pathname, search });
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const listen = jest.fn();
|
||||
const go = jest.fn((n: number) => {
|
||||
if (n < 0 && stack.length > 1) {
|
||||
stack.pop();
|
||||
}
|
||||
const top = stack[stack.length - 1] || { pathname: '/', search: '' };
|
||||
location.pathname = top.pathname;
|
||||
location.search = top.search;
|
||||
});
|
||||
|
||||
return {
|
||||
push,
|
||||
replace,
|
||||
listen,
|
||||
go,
|
||||
location,
|
||||
__stack: stack,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock ResizeObserver for Jest/jsdom
|
||||
class ResizeObserver {
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, class-methods-use-this
|
||||
observe() {}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, class-methods-use-this
|
||||
unobserve() {}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, class-methods-use-this
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
(global as any).ResizeObserver = ResizeObserver;
|
||||
|
||||
// mock cmdK provider hook (open state + setter)
|
||||
const mockSetOpen = jest.fn();
|
||||
jest.mock('providers/cmdKProvider', (): unknown => ({
|
||||
useCmdK: (): {
|
||||
open: boolean;
|
||||
setOpen: jest.Mock;
|
||||
openCmdK: jest.Mock;
|
||||
closeCmdK: jest.Mock;
|
||||
} => ({
|
||||
open: true,
|
||||
setOpen: mockSetOpen,
|
||||
openCmdK: jest.fn(),
|
||||
closeCmdK: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// mock notifications hook
|
||||
jest.mock('hooks/useNotifications', (): unknown => ({
|
||||
useNotifications: (): { notifications: [] } => ({ notifications: [] }),
|
||||
}));
|
||||
|
||||
// mock theme hook
|
||||
jest.mock('hooks/useDarkMode', (): unknown => ({
|
||||
useThemeMode: (): {
|
||||
setAutoSwitch: jest.Mock;
|
||||
setTheme: jest.Mock;
|
||||
theme: string;
|
||||
} => ({
|
||||
setAutoSwitch: jest.fn(),
|
||||
setTheme: jest.fn(),
|
||||
theme: 'dark',
|
||||
}),
|
||||
}));
|
||||
|
||||
// mock updateUserPreference API and react-query mutation
|
||||
jest.mock('api/v1/user/preferences/name/update', (): jest.Mock => jest.fn());
|
||||
jest.mock('react-query', (): unknown => {
|
||||
const actual = jest.requireActual('react-query');
|
||||
return {
|
||||
...actual,
|
||||
useMutation: (): { mutate: jest.Mock } => ({ mutate: jest.fn() }),
|
||||
};
|
||||
});
|
||||
|
||||
// mock other side-effecty modules
|
||||
jest.mock('api/common/logEvent', () => jest.fn());
|
||||
jest.mock('api/browser/localstorage/set', () => jest.fn());
|
||||
jest.mock('utils/error', () => ({ showErrorNotification: jest.fn() }));
|
||||
|
||||
// ---- Tests ----
|
||||
describe('CmdKPalette', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders navigation and settings groups and items', () => {
|
||||
render(<CmdKPalette userRole="ADMIN" />);
|
||||
|
||||
expect(screen.getByText('Navigation')).toBeInTheDocument();
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(HOME_LABEL)).toBeInTheDocument();
|
||||
expect(screen.getByText('Go to Dashboards')).toBeInTheDocument();
|
||||
expect(screen.getByText('Open Sidebar')).toBeInTheDocument();
|
||||
expect(screen.getByText('Switch to Dark Mode')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('clicking a navigation item calls history.push with correct route', async () => {
|
||||
render(<CmdKPalette userRole="ADMIN" />);
|
||||
|
||||
const homeItem = screen.getByText(HOME_LABEL);
|
||||
await userEvent.click(homeItem);
|
||||
|
||||
expect(history.push).toHaveBeenCalledWith(ROUTES.HOME);
|
||||
});
|
||||
|
||||
test('role-based filtering (basic smoke)', () => {
|
||||
render(<CmdKPalette userRole="VIEWER" />);
|
||||
|
||||
// VIEWER still sees basic navigation items
|
||||
expect(screen.getByText(HOME_LABEL)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('keyboard shortcut opens palette via setOpen', () => {
|
||||
render(<CmdKPalette userRole="ADMIN" />);
|
||||
|
||||
const event = new KeyboardEvent('keydown', { key: 'k', ctrlKey: true });
|
||||
window.dispatchEvent(event);
|
||||
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test('items render with icons when provided', () => {
|
||||
render(<CmdKPalette userRole="ADMIN" />);
|
||||
|
||||
const iconHolders = document.querySelectorAll('.cmd-item-icon');
|
||||
expect(iconHolders.length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(HOME_LABEL)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('closing the palette via handleInvoke sets open to false', async () => {
|
||||
render(<CmdKPalette userRole="ADMIN" />);
|
||||
|
||||
const dashItem = screen.getByText('Go to Dashboards');
|
||||
await userEvent.click(dashItem);
|
||||
|
||||
// last call from handleInvoke should set open to false
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
55
frontend/src/components/cmdKPalette/cmdKPalette.scss
Normal file
@@ -0,0 +1,55 @@
|
||||
/* Overlay stays below content */
|
||||
[data-slot='dialog-overlay'] {
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
/* Dialog content always above overlay */
|
||||
[data-slot='dialog-content'] {
|
||||
position: fixed;
|
||||
z-index: 60;
|
||||
}
|
||||
|
||||
.cmdk-section-heading [cmdk-group-heading] {
|
||||
text-transform: uppercase;
|
||||
color: var(--bg-slate-100);
|
||||
}
|
||||
|
||||
/* Hide scrollbar but keep scroll */
|
||||
.cmdk-list-scroll {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
.cmdk-list-scroll::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari, Edge */
|
||||
}
|
||||
|
||||
.cmdk-list-scroll {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.cmdk-input-wrapper {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.cmdk-item-light:hover {
|
||||
cursor: pointer;
|
||||
background-color: var(--bg-vanilla-200) !important;
|
||||
}
|
||||
|
||||
.cmdk-item-light[data-selected='true'] {
|
||||
background-color: var(--bg-vanilla-300) !important;
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
|
||||
.cmdk-item {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[cmdk-item] svg {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.cmd-item-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
336
frontend/src/components/cmdKPalette/cmdKPalette.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
import './cmdKPalette.scss';
|
||||
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandShortcut,
|
||||
} from '@signozhq/command';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import updateUserPreference from 'api/v1/user/preferences/name/update';
|
||||
import { AxiosError } from 'axios';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import { useThemeMode } from 'hooks/useDarkMode';
|
||||
import { THEME_MODE } from 'hooks/useDarkMode/constant';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import history from 'lib/history';
|
||||
import {
|
||||
BellDot,
|
||||
BugIcon,
|
||||
DraftingCompass,
|
||||
Expand,
|
||||
HardDrive,
|
||||
Home,
|
||||
LayoutGrid,
|
||||
ListMinus,
|
||||
ScrollText,
|
||||
Settings,
|
||||
} from 'lucide-react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import { UserPreference } from 'types/api/preferences/preference';
|
||||
import { showErrorNotification } from 'utils/error';
|
||||
|
||||
import { useAppContext } from '../../providers/App/App';
|
||||
import { useCmdK } from '../../providers/cmdKProvider';
|
||||
|
||||
type CmdAction = {
|
||||
id: string;
|
||||
name: string;
|
||||
shortcut?: string[];
|
||||
keywords?: string;
|
||||
section?: string;
|
||||
icon?: React.ReactNode;
|
||||
roles?: UserRole[];
|
||||
perform: () => void;
|
||||
};
|
||||
|
||||
type UserRole = 'ADMIN' | 'EDITOR' | 'AUTHOR' | 'VIEWER';
|
||||
export function CmdKPalette({
|
||||
userRole,
|
||||
}: {
|
||||
userRole: UserRole;
|
||||
}): JSX.Element | null {
|
||||
const { open, setOpen } = useCmdK();
|
||||
|
||||
const { updateUserPreferenceInContext } = useAppContext();
|
||||
const { notifications } = useNotifications();
|
||||
const { setAutoSwitch, setTheme, theme } = useThemeMode();
|
||||
|
||||
const { mutate: updateUserPreferenceMutation } = useMutation(
|
||||
updateUserPreference,
|
||||
{
|
||||
onError: (error) => {
|
||||
showErrorNotification(notifications, error as AxiosError);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// toggle palette with ⌘/Ctrl+K
|
||||
function handleGlobalCmdK(
|
||||
e: KeyboardEvent,
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
): void {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
const cmdKEffect = (): void | (() => void) => {
|
||||
const listener = (e: KeyboardEvent): void => {
|
||||
handleGlobalCmdK(e, setOpen);
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', listener);
|
||||
|
||||
return (): void => {
|
||||
window.removeEventListener('keydown', listener);
|
||||
setOpen(false);
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(cmdKEffect, [setOpen]);
|
||||
|
||||
function handleThemeChange(value: string): void {
|
||||
logEvent('Account Settings: Theme Changed', { theme: value });
|
||||
if (value === 'auto') {
|
||||
setAutoSwitch(true);
|
||||
} else {
|
||||
setAutoSwitch(false);
|
||||
setTheme(value);
|
||||
}
|
||||
}
|
||||
|
||||
function onClickHandler(key: string): void {
|
||||
history.push(key);
|
||||
}
|
||||
|
||||
function handleOpenSidebar(): void {
|
||||
setLocalStorageApi(USER_PREFERENCES.SIDENAV_PINNED, 'true');
|
||||
const save = { name: USER_PREFERENCES.SIDENAV_PINNED, value: true };
|
||||
updateUserPreferenceInContext(save as UserPreference);
|
||||
updateUserPreferenceMutation({
|
||||
name: USER_PREFERENCES.SIDENAV_PINNED,
|
||||
value: true,
|
||||
});
|
||||
}
|
||||
|
||||
function handleCloseSidebar(): void {
|
||||
setLocalStorageApi(USER_PREFERENCES.SIDENAV_PINNED, 'false');
|
||||
const save = { name: USER_PREFERENCES.SIDENAV_PINNED, value: false };
|
||||
updateUserPreferenceInContext(save as UserPreference);
|
||||
updateUserPreferenceMutation({
|
||||
name: USER_PREFERENCES.SIDENAV_PINNED,
|
||||
value: false,
|
||||
});
|
||||
}
|
||||
|
||||
const actions: CmdAction[] = [
|
||||
{
|
||||
id: 'home',
|
||||
name: 'Go to Home',
|
||||
shortcut: ['shift + h'],
|
||||
keywords: 'home',
|
||||
section: 'Navigation',
|
||||
icon: <Home size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => onClickHandler(ROUTES.HOME),
|
||||
},
|
||||
{
|
||||
id: 'dashboards',
|
||||
name: 'Go to Dashboards',
|
||||
shortcut: ['shift + d'],
|
||||
keywords: 'dashboards',
|
||||
section: 'Navigation',
|
||||
icon: <LayoutGrid size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => onClickHandler(ROUTES.ALL_DASHBOARD),
|
||||
},
|
||||
{
|
||||
id: 'services',
|
||||
name: 'Go to Services',
|
||||
shortcut: ['shift + s'],
|
||||
keywords: 'services monitoring',
|
||||
section: 'Navigation',
|
||||
icon: <HardDrive size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => onClickHandler(ROUTES.APPLICATION),
|
||||
},
|
||||
{
|
||||
id: 'traces',
|
||||
name: 'Go to Traces',
|
||||
shortcut: ['shift + t'],
|
||||
keywords: 'traces',
|
||||
section: 'Navigation',
|
||||
icon: <DraftingCompass size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => onClickHandler(ROUTES.TRACES_EXPLORER),
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
name: 'Go to Logs',
|
||||
shortcut: ['shift + l'],
|
||||
keywords: 'logs',
|
||||
section: 'Navigation',
|
||||
icon: <ScrollText size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => onClickHandler(ROUTES.LOGS),
|
||||
},
|
||||
{
|
||||
id: 'alerts',
|
||||
name: 'Go to Alerts',
|
||||
shortcut: ['shift + a'],
|
||||
keywords: 'alerts',
|
||||
section: 'Navigation',
|
||||
icon: <BellDot size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => onClickHandler(ROUTES.LIST_ALL_ALERT),
|
||||
},
|
||||
{
|
||||
id: 'exceptions',
|
||||
name: 'Go to Exceptions',
|
||||
shortcut: ['shift + e'],
|
||||
keywords: 'exceptions errors',
|
||||
section: 'Navigation',
|
||||
icon: <BugIcon size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => onClickHandler(ROUTES.ALL_ERROR),
|
||||
},
|
||||
{
|
||||
id: 'messaging-queues',
|
||||
name: 'Go to Messaging Queues',
|
||||
shortcut: ['shift + m'],
|
||||
keywords: 'messaging queues mq',
|
||||
section: 'Navigation',
|
||||
icon: <ListMinus size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => onClickHandler(ROUTES.MESSAGING_QUEUES_OVERVIEW),
|
||||
},
|
||||
{
|
||||
id: 'my-settings',
|
||||
name: 'Go to Account Settings',
|
||||
keywords: 'account settings',
|
||||
section: 'Navigation',
|
||||
icon: <Settings size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => onClickHandler(ROUTES.MY_SETTINGS),
|
||||
},
|
||||
|
||||
// Settings
|
||||
{
|
||||
id: 'open-sidebar',
|
||||
name: 'Open Sidebar',
|
||||
keywords: 'sidebar navigation menu expand',
|
||||
section: 'Settings',
|
||||
icon: <Expand size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => handleOpenSidebar(),
|
||||
},
|
||||
{
|
||||
id: 'collapse-sidebar',
|
||||
name: 'Collapse Sidebar',
|
||||
keywords: 'sidebar navigation menu collapse',
|
||||
section: 'Settings',
|
||||
icon: <Expand size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => handleCloseSidebar(),
|
||||
},
|
||||
{
|
||||
id: 'dark-mode',
|
||||
name: 'Switch to Dark Mode',
|
||||
keywords: 'theme dark mode appearance',
|
||||
section: 'Settings',
|
||||
icon: <Expand size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => handleThemeChange(THEME_MODE.DARK),
|
||||
},
|
||||
{
|
||||
id: 'light-mode',
|
||||
name: 'Switch to Light Mode [Beta]',
|
||||
keywords: 'theme light mode appearance',
|
||||
section: 'Settings',
|
||||
icon: <Expand size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => handleThemeChange(THEME_MODE.LIGHT),
|
||||
},
|
||||
{
|
||||
id: 'system-theme',
|
||||
name: 'Switch to System Theme',
|
||||
keywords: 'system theme appearance',
|
||||
section: 'Settings',
|
||||
icon: <Expand size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
|
||||
perform: (): void => handleThemeChange(THEME_MODE.SYSTEM),
|
||||
},
|
||||
];
|
||||
|
||||
// RBAC filter: show action if no roles set OR current user role is included
|
||||
const permitted = actions.filter(
|
||||
(a) => !a.roles || a.roles.includes(userRole),
|
||||
);
|
||||
|
||||
// group permitted actions by section
|
||||
const grouped: [string, CmdAction[]][] = ((): [string, CmdAction[]][] => {
|
||||
const map = new Map<string, CmdAction[]>();
|
||||
|
||||
permitted.forEach((a) => {
|
||||
const section = a.section ?? 'Other';
|
||||
const existing = map.get(section);
|
||||
|
||||
if (existing) {
|
||||
existing.push(a);
|
||||
} else {
|
||||
map.set(section, [a]);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(map.entries());
|
||||
})();
|
||||
|
||||
const handleInvoke = (action: CmdAction): void => {
|
||||
try {
|
||||
action.perform();
|
||||
} catch (e) {
|
||||
console.error('Error invoking action', e);
|
||||
} finally {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={setOpen} position="top" offset={110}>
|
||||
<CommandInput placeholder="Search…" className="cmdk-input-wrapper" />
|
||||
<CommandList className="cmdk-list-scroll">
|
||||
<CommandEmpty>No results</CommandEmpty>
|
||||
{grouped.map(([section, items]) => (
|
||||
<CommandGroup
|
||||
key={section}
|
||||
heading={section}
|
||||
className="cmdk-section-heading"
|
||||
>
|
||||
{items.map((it) => (
|
||||
<CommandItem
|
||||
key={it.id}
|
||||
onSelect={(): void => handleInvoke(it)}
|
||||
value={it.name}
|
||||
className={theme === 'light' ? 'cmdk-item-light' : 'cmdk-item'}
|
||||
>
|
||||
<span className="cmd-item-icon">{it.icon}</span>
|
||||
{it.name}
|
||||
{it.shortcut && it.shortcut.length > 0 && (
|
||||
<CommandShortcut>{it.shortcut.join(' • ')}</CommandShortcut>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
export const REACT_QUERY_KEY = {
|
||||
GET_PUBLIC_DASHBOARD: 'GET_PUBLIC_DASHBOARD',
|
||||
GET_PUBLIC_DASHBOARD_META: 'GET_PUBLIC_DASHBOARD_META',
|
||||
GET_PUBLIC_DASHBOARD_WIDGET_DATA: 'GET_PUBLIC_DASHBOARD_WIDGET_DATA',
|
||||
GET_ALL_LICENCES: 'GET_ALL_LICENCES',
|
||||
GET_QUERY_RANGE: 'GET_QUERY_RANGE',
|
||||
GET_ALL_DASHBOARDS: 'GET_ALL_DASHBOARDS',
|
||||
|
||||
@@ -81,6 +81,7 @@ const ROUTES = {
|
||||
METER_EXPLORER: '/meter/explorer',
|
||||
METER_EXPLORER_VIEWS: '/meter/explorer/views',
|
||||
HOME_PAGE: '/',
|
||||
PUBLIC_DASHBOARD: '/public/dashboard/:dashboardId',
|
||||
} as const;
|
||||
|
||||
export default ROUTES;
|
||||
|
||||
@@ -391,6 +391,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
|
||||
const routeKey = useMemo(() => getRouteKey(pathname), [pathname]);
|
||||
const pageTitle = t(routeKey);
|
||||
|
||||
const isPublicDashboard = pathname.startsWith('/public/dashboard/');
|
||||
|
||||
const renderFullScreen =
|
||||
pathname === ROUTES.GET_STARTED ||
|
||||
pathname === ROUTES.ONBOARDING ||
|
||||
@@ -399,7 +402,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
pathname === ROUTES.GET_STARTED_INFRASTRUCTURE_MONITORING ||
|
||||
pathname === ROUTES.GET_STARTED_LOGS_MANAGEMENT ||
|
||||
pathname === ROUTES.GET_STARTED_AWS_MONITORING ||
|
||||
pathname === ROUTES.GET_STARTED_AZURE_MONITORING;
|
||||
pathname === ROUTES.GET_STARTED_AZURE_MONITORING ||
|
||||
isPublicDashboard;
|
||||
|
||||
const [showTrialExpiryBanner, setShowTrialExpiryBanner] = useState(false);
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Button, Flex, Switch, Typography } from 'antd';
|
||||
import { BaseOptionType, DefaultOptionType, SelectProps } from 'antd/es/select';
|
||||
import { getInvolvedQueriesInTraceOperator } from 'components/QueryBuilderV2/QueryV2/TraceOperator/utils/utils';
|
||||
import { Y_AXIS_CATEGORIES } from 'components/YAxisUnitSelector/constants';
|
||||
import { YAxisSource } from 'components/YAxisUnitSelector/types';
|
||||
import { getYAxisCategories } from 'components/YAxisUnitSelector/utils';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
AlertThresholdMatchType,
|
||||
@@ -39,7 +40,8 @@ export function getQueryNames(currentQuery: Query): BaseOptionType[] {
|
||||
}
|
||||
|
||||
export function getCategoryByOptionId(id: string): string | undefined {
|
||||
return Y_AXIS_CATEGORIES.find((category) =>
|
||||
const categories = getYAxisCategories(YAxisSource.ALERTS);
|
||||
return categories.find((category) =>
|
||||
category.units.some((unit) => unit.id === id),
|
||||
)?.name;
|
||||
}
|
||||
@@ -47,14 +49,15 @@ export function getCategoryByOptionId(id: string): string | undefined {
|
||||
export function getCategorySelectOptionByName(
|
||||
name: string,
|
||||
): DefaultOptionType[] {
|
||||
const categories = getYAxisCategories(YAxisSource.ALERTS);
|
||||
return (
|
||||
Y_AXIS_CATEGORIES.find((category) => category.name === name)?.units.map(
|
||||
(unit) => ({
|
||||
categories
|
||||
.find((category) => category.name === name)
|
||||
?.units.map((unit) => ({
|
||||
label: unit.name,
|
||||
value: unit.id,
|
||||
'data-testid': `threshold-unit-select-option-${unit.id}`,
|
||||
}),
|
||||
) || []
|
||||
})) || []
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import YAxisUnitSelector from 'components/YAxisUnitSelector';
|
||||
import { YAxisSource } from 'components/YAxisUnitSelector/types';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useCreateAlertState } from 'container/CreateAlertV2/context';
|
||||
import ChartPreviewComponent from 'container/FormAlertRules/ChartPreview';
|
||||
@@ -37,6 +38,7 @@ function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
|
||||
onChange={(value): void => {
|
||||
setAlertState({ type: 'SET_Y_AXIS_UNIT', payload: value });
|
||||
}}
|
||||
source={YAxisSource.ALERTS}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -266,7 +266,10 @@ export default function CustomDomainSettings(): JSX.Element {
|
||||
<div className="custom-domain-settings-modal-error">
|
||||
{updateDomainError.status === 409 ? (
|
||||
<Alert
|
||||
message="You’ve already updated the custom domain once today. To make further changes, please contact our support team for assistance."
|
||||
message={
|
||||
(updateDomainError?.response?.data as { error?: string })?.error ||
|
||||
'You’ve already updated the custom domain once today. To make further changes, please contact our support team for assistance.'
|
||||
}
|
||||
type="warning"
|
||||
className="update-limit-reached-error"
|
||||
/>
|
||||
|
||||
@@ -138,9 +138,9 @@ function ErrorDetails(props: ErrorDetailsProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography>{errorDetail.exceptionType}</Typography>
|
||||
<Typography>{errorDetail.exceptionMessage}</Typography>
|
||||
<div className="error-details-container">
|
||||
<Typography.Title level={4}>{errorDetail.exceptionType}</Typography.Title>
|
||||
<Typography.Text>{errorDetail.exceptionMessage}</Typography.Text>
|
||||
<Divider />
|
||||
|
||||
<EventContainer>
|
||||
@@ -200,7 +200,7 @@ function ErrorDetails(props: ErrorDetailsProps): JSX.Element {
|
||||
<ResizeTable columns={columns} tableLayout="fixed" dataSource={data} />
|
||||
</Space>
|
||||
</EditorContainer>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
.error-details-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
height: 50vh;
|
||||
}
|
||||
|
||||
@@ -393,15 +393,21 @@ function ExplorerOptions({
|
||||
backwardCompatibleOptions = omit(options, 'version');
|
||||
}
|
||||
|
||||
// Use the correct default columns based on the current data source
|
||||
const defaultColumns =
|
||||
sourcepage === DataSource.TRACES
|
||||
? defaultTraceSelectedColumns
|
||||
: defaultLogsSelectedColumns;
|
||||
|
||||
if (extraData.selectColumns?.length) {
|
||||
handleOptionsChange({
|
||||
...backwardCompatibleOptions,
|
||||
selectColumns: extraData.selectColumns,
|
||||
});
|
||||
} else if (!isEqual(defaultTraceSelectedColumns, options.selectColumns)) {
|
||||
} else if (!isEqual(defaultColumns, options.selectColumns)) {
|
||||
handleOptionsChange({
|
||||
...backwardCompatibleOptions,
|
||||
selectColumns: defaultTraceSelectedColumns,
|
||||
selectColumns: defaultColumns,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -304,14 +304,19 @@ function WidgetHeader({
|
||||
data-testid="widget-header-search"
|
||||
/>
|
||||
)}
|
||||
<Dropdown menu={menu} trigger={['hover']} placement="bottomRight">
|
||||
<MoreOutlined
|
||||
data-testid="widget-header-options"
|
||||
className={`widget-header-more-options ${
|
||||
parentHover ? 'widget-header-hover' : ''
|
||||
} ${globalSearchAvailable ? 'widget-header-more-options-visible' : ''}`}
|
||||
/>
|
||||
</Dropdown>
|
||||
|
||||
{menu && Array.isArray(menu.items) && menu.items.length > 0 && (
|
||||
<Dropdown menu={menu} trigger={['hover']} placement="bottomRight">
|
||||
<MoreOutlined
|
||||
data-testid="widget-header-options"
|
||||
className={`widget-header-more-options ${
|
||||
parentHover ? 'widget-header-hover' : ''
|
||||
} ${
|
||||
globalSearchAvailable ? 'widget-header-more-options-visible' : ''
|
||||
}`}
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TableProps } from 'antd';
|
||||
import { PrecisionOption } from 'components/Graph/yAxisConfig';
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { LogsExplorerTableProps } from 'container/LogsExplorerTable/LogsExplorerTable.interfaces';
|
||||
import {
|
||||
|
||||
@@ -175,7 +175,18 @@ function LiveLogsContainer(): JSX.Element {
|
||||
if (isConnectionError && reconnectDueToError) {
|
||||
// Small delay to prevent immediate reconnection attempts
|
||||
const reconnectTimer = setTimeout(() => {
|
||||
handleStartNewConnection();
|
||||
const fallbackFilterExpression =
|
||||
prevFilterExpressionRef.current ||
|
||||
currentQuery?.builder.queryData[0]?.filter?.expression?.trim() ||
|
||||
null;
|
||||
|
||||
const validationResult = validateQuery(fallbackFilterExpression || '');
|
||||
|
||||
if (validationResult.isValid) {
|
||||
handleStartNewConnection(fallbackFilterExpression);
|
||||
} else {
|
||||
handleStartNewConnection(null);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return (): void => clearTimeout(reconnectTimer);
|
||||
@@ -186,6 +197,7 @@ function LiveLogsContainer(): JSX.Element {
|
||||
reconnectDueToError,
|
||||
compositeQuery,
|
||||
handleStartNewConnection,
|
||||
currentQuery,
|
||||
]);
|
||||
|
||||
// clean up the connection when the component unmounts
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
QUERY_BUILDER_FUNCTIONS,
|
||||
} from 'constants/antlrQueryConstants';
|
||||
import { useActiveLog } from 'hooks/logs/useActiveLog';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useCallback } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
|
||||
import { TitleWrapper } from './BodyTitleRenderer.styles';
|
||||
import { DROPDOWN_KEY } from './constant';
|
||||
@@ -24,6 +27,8 @@ function BodyTitleRenderer({
|
||||
value,
|
||||
}: BodyTitleRendererProps): JSX.Element {
|
||||
const { onAddToQuery } = useActiveLog();
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const filterHandler = (isFilterIn: boolean) => (): void => {
|
||||
if (parentIsArray) {
|
||||
@@ -75,18 +80,53 @@ function BodyTitleRenderer({
|
||||
onClick: onClickHandler,
|
||||
};
|
||||
|
||||
const handleTextSelection = (e: React.MouseEvent): void => {
|
||||
// Prevent tree node click when user is trying to select text
|
||||
e.stopPropagation();
|
||||
};
|
||||
const handleNodeClick = useCallback(
|
||||
(e: React.MouseEvent): void => {
|
||||
// Prevent tree node expansion/collapse
|
||||
e.stopPropagation();
|
||||
const cleanedKey = removeObjectFromString(nodeKey);
|
||||
let copyText: string;
|
||||
|
||||
// Check if value is an object or array
|
||||
const isObject = typeof value === 'object' && value !== null;
|
||||
|
||||
if (isObject) {
|
||||
// For objects/arrays, stringify the entire structure
|
||||
copyText = `"${cleanedKey}": ${JSON.stringify(value, null, 2)}`;
|
||||
} else if (parentIsArray) {
|
||||
// For array elements, copy just the value
|
||||
copyText = `"${cleanedKey}": ${value}`;
|
||||
} else {
|
||||
// For primitive values, format as JSON key-value pair
|
||||
const valueStr = typeof value === 'string' ? `"${value}"` : String(value);
|
||||
copyText = `"${cleanedKey}": ${valueStr}`;
|
||||
}
|
||||
|
||||
setCopy(copyText);
|
||||
|
||||
if (copyText) {
|
||||
const notificationMessage = isObject
|
||||
? `${cleanedKey} object copied to clipboard`
|
||||
: `${cleanedKey} copied to clipboard`;
|
||||
|
||||
notifications.success({
|
||||
message: notificationMessage,
|
||||
key: notificationMessage,
|
||||
});
|
||||
}
|
||||
},
|
||||
[nodeKey, parentIsArray, setCopy, value, notifications],
|
||||
);
|
||||
|
||||
return (
|
||||
<TitleWrapper onMouseDown={handleTextSelection}>
|
||||
<Dropdown menu={menu} trigger={['click']}>
|
||||
<SettingOutlined style={{ marginRight: 8 }} className="hover-reveal" />
|
||||
</Dropdown>
|
||||
<TitleWrapper onClick={handleNodeClick}>
|
||||
{typeof value !== 'object' && (
|
||||
<Dropdown menu={menu} trigger={['click']}>
|
||||
<SettingOutlined style={{ marginRight: 8 }} className="hover-reveal" />
|
||||
</Dropdown>
|
||||
)}
|
||||
{title.toString()}{' '}
|
||||
{!parentIsArray && (
|
||||
{!parentIsArray && typeof value !== 'object' && (
|
||||
<span>
|
||||
: <span style={{ color: orange[6] }}>{`${value}`}</span>
|
||||
</span>
|
||||
|
||||
@@ -202,9 +202,7 @@ export default function TableViewActions(
|
||||
if (record.field === 'body') {
|
||||
return (
|
||||
<div className={cx('value-field', isOpen ? 'open-popover' : '')}>
|
||||
<CopyClipboardHOC entityKey={fieldFilterKey} textToCopy={textToCopy}>
|
||||
<BodyContent fieldData={fieldData} record={record} bodyHtml={bodyHtml} />
|
||||
</CopyClipboardHOC>
|
||||
<BodyContent fieldData={fieldData} record={record} bodyHtml={bodyHtml} />
|
||||
{!isListViewPanel && !RESTRICTED_SELECTED_FIELDS.includes(fieldFilterKey) && (
|
||||
<span className="action-btn">
|
||||
<Tooltip title="Filter for value">
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import BodyTitleRenderer from '../BodyTitleRenderer';
|
||||
|
||||
let mockSetCopy: jest.Mock;
|
||||
const mockNotification = jest.fn();
|
||||
|
||||
jest.mock('hooks/logs/useActiveLog', () => ({
|
||||
useActiveLog: (): any => ({
|
||||
onAddToQuery: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-use', () => ({
|
||||
useCopyToClipboard: (): any => {
|
||||
mockSetCopy = jest.fn();
|
||||
return [{ value: null }, mockSetCopy];
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
useNotifications: (): any => ({
|
||||
notifications: {
|
||||
success: mockNotification,
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warning: jest.fn(),
|
||||
open: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('BodyTitleRenderer', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should copy primitive value when node is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<BodyTitleRenderer
|
||||
title="name"
|
||||
nodeKey="user.name"
|
||||
value="John"
|
||||
parentIsArray={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('name'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetCopy).toHaveBeenCalledWith('"user.name": "John"');
|
||||
expect(mockNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('user.name'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should copy array element value when clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<BodyTitleRenderer
|
||||
title="0"
|
||||
nodeKey="items[*].0"
|
||||
value="arrayElement"
|
||||
parentIsArray
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('0'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetCopy).toHaveBeenCalledWith('"items[*].0": arrayElement');
|
||||
});
|
||||
});
|
||||
|
||||
it('should copy entire object when object node is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const testObject = { id: 123, active: true };
|
||||
|
||||
render(
|
||||
<BodyTitleRenderer
|
||||
title="metadata"
|
||||
nodeKey="user.metadata"
|
||||
value={testObject}
|
||||
parentIsArray={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('metadata'));
|
||||
|
||||
await waitFor(() => {
|
||||
const callArg = mockSetCopy.mock.calls[0][0];
|
||||
expect(callArg).toContain('"user.metadata":');
|
||||
expect(callArg).toContain('"id": 123');
|
||||
expect(callArg).toContain('"active": true');
|
||||
expect(mockNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('object copied'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -39,9 +39,17 @@ export const computeDataNode = (
|
||||
valueIsArray: boolean,
|
||||
value: unknown,
|
||||
nodeKey: string,
|
||||
parentIsArray: boolean,
|
||||
): DataNode => ({
|
||||
key: uniqueId(),
|
||||
title: `${key} ${valueIsArray ? '[...]' : ''}`,
|
||||
title: (
|
||||
<BodyTitleRenderer
|
||||
title={`${key} ${valueIsArray ? '[...]' : ''}`}
|
||||
nodeKey={nodeKey}
|
||||
value={value}
|
||||
parentIsArray={parentIsArray}
|
||||
/>
|
||||
),
|
||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||
children: jsonToDataNodes(
|
||||
value as Record<string, unknown>,
|
||||
@@ -67,7 +75,7 @@ export function jsonToDataNodes(
|
||||
|
||||
if (parentIsArray) {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return computeDataNode(key, valueIsArray, value, nodeKey);
|
||||
return computeDataNode(key, valueIsArray, value, nodeKey, parentIsArray);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -85,7 +93,7 @@ export function jsonToDataNodes(
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return computeDataNode(key, valueIsArray, value, nodeKey);
|
||||
return computeDataNode(key, valueIsArray, value, nodeKey, parentIsArray);
|
||||
}
|
||||
return {
|
||||
key: uniqueId(),
|
||||
|
||||
@@ -209,6 +209,15 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.time-series-view-container {
|
||||
.time-series-view-container-header {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
getListQuery,
|
||||
getQueryByPanelType,
|
||||
} from 'container/LogsExplorerViews/explorerUtils';
|
||||
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
|
||||
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
|
||||
@@ -110,6 +111,8 @@ function LogsExplorerViewsContainer({
|
||||
|
||||
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
|
||||
|
||||
const [yAxisUnit, setYAxisUnit] = useState<string>('');
|
||||
|
||||
const listQuery = useMemo(() => getListQuery(stagedQuery) || null, [
|
||||
stagedQuery,
|
||||
]);
|
||||
@@ -350,6 +353,10 @@ function LogsExplorerViewsContainer({
|
||||
orderBy,
|
||||
]);
|
||||
|
||||
const onUnitChangeHandler = useCallback((value: string): void => {
|
||||
setYAxisUnit(value);
|
||||
}, []);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!stagedQuery) return [];
|
||||
|
||||
@@ -457,15 +464,24 @@ function LogsExplorerViewsContainer({
|
||||
)}
|
||||
|
||||
{selectedPanelType === PANEL_TYPES.TIME_SERIES && !showLiveLogs && (
|
||||
<TimeSeriesView
|
||||
isLoading={isLoading || isFetching}
|
||||
data={data}
|
||||
isError={isError}
|
||||
error={error as APIError}
|
||||
isFilterApplied={!isEmpty(listQuery?.filters?.items)}
|
||||
dataSource={DataSource.LOGS}
|
||||
setWarning={setWarning}
|
||||
/>
|
||||
<div className="time-series-view-container">
|
||||
<div className="time-series-view-container-header">
|
||||
<BuilderUnitsFilter
|
||||
onChange={onUnitChangeHandler}
|
||||
yAxisUnit={yAxisUnit}
|
||||
/>
|
||||
</div>
|
||||
<TimeSeriesView
|
||||
isLoading={isLoading || isFetching}
|
||||
data={data}
|
||||
isError={isError}
|
||||
error={error as APIError}
|
||||
yAxisUnit={yAxisUnit}
|
||||
isFilterApplied={!isEmpty(listQuery?.filters?.items)}
|
||||
dataSource={DataSource.LOGS}
|
||||
setWarning={setWarning}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedPanelType === PANEL_TYPES.TABLE && !showLiveLogs && (
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
|
||||
.builder-units-filter-label {
|
||||
margin-bottom: 0px !important;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { UpdateMetricMetadataProps } from 'api/metricsExplorer/updateMetricMetadata';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import YAxisUnitSelector from 'components/YAxisUnitSelector';
|
||||
import { YAxisSource } from 'components/YAxisUnitSelector/types';
|
||||
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
|
||||
import FieldRenderer from 'container/LogDetailedView/FieldRenderer';
|
||||
import { DataType } from 'container/LogDetailedView/TableView';
|
||||
@@ -120,6 +121,7 @@ function Metadata({
|
||||
setMetricMetadata((prev) => ({ ...prev, unit: value }));
|
||||
}}
|
||||
data-testid="unit-select"
|
||||
source={YAxisSource.EXPLORER}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
max-width: 80%;
|
||||
|
||||
.dashboard-btn {
|
||||
display: flex;
|
||||
@@ -130,7 +130,6 @@
|
||||
|
||||
.left-section {
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 45%;
|
||||
@@ -148,16 +147,17 @@
|
||||
font-weight: 500;
|
||||
line-height: 24px; /* 150% */
|
||||
letter-spacing: -0.08px;
|
||||
flex-shrink: 0;
|
||||
|
||||
flex: 1;
|
||||
min-width: fit-content;
|
||||
max-width: 80%;
|
||||
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.public-dashboard-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.right-section {
|
||||
|
||||
@@ -17,7 +17,7 @@ function SettingsDrawer({ drawerTitle }: { drawerTitle: string }): JSX.Element {
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
const onClose = (): void => {
|
||||
const handleClose = (): void => {
|
||||
setVisible(false);
|
||||
variableViewModeRef?.current?.();
|
||||
};
|
||||
@@ -38,7 +38,7 @@ function SettingsDrawer({ drawerTitle }: { drawerTitle: string }): JSX.Element {
|
||||
title={drawerTitle}
|
||||
placement="right"
|
||||
width="50%"
|
||||
onClose={onClose}
|
||||
onClose={handleClose}
|
||||
open={visible}
|
||||
rootClassName="settings-container-root"
|
||||
>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteButton';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { useGetPublicDashboardMeta } from 'hooks/dashboard/useGetPublicDashboardMeta';
|
||||
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
FileJson,
|
||||
FolderKanban,
|
||||
Fullscreen,
|
||||
Globe,
|
||||
LayoutGrid,
|
||||
LockKeyhole,
|
||||
PenLine,
|
||||
@@ -128,6 +130,8 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
false,
|
||||
);
|
||||
|
||||
const [isPublicDashboard, setIsPublicDashboard] = useState<boolean>(false);
|
||||
|
||||
let isAuthor = false;
|
||||
|
||||
if (selectedDashboard && user && user.email) {
|
||||
@@ -297,6 +301,38 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
safeNavigate(generatedUrl);
|
||||
}
|
||||
|
||||
const {
|
||||
data: publicDashboardResponse,
|
||||
// refetch: refetchPublicDashboardData,
|
||||
isLoading: isLoadingPublicDashboardData,
|
||||
isFetching: isFetchingPublicDashboardData,
|
||||
error: errorPublicDashboardData,
|
||||
isError: isErrorPublicDashboardData,
|
||||
} = useGetPublicDashboardMeta(selectedDashboard?.id || '');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoadingPublicDashboardData && !isFetchingPublicDashboardData) {
|
||||
if (isErrorPublicDashboardData) {
|
||||
const errorDetails = errorPublicDashboardData?.getErrorDetails();
|
||||
|
||||
if (errorDetails?.error?.code === 'public_dashboard_not_found') {
|
||||
setIsPublicDashboard(false);
|
||||
}
|
||||
} else {
|
||||
const publicDashboardData = publicDashboardResponse?.data;
|
||||
if (publicDashboardData?.publicPath) {
|
||||
setIsPublicDashboard(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isLoadingPublicDashboardData,
|
||||
isFetchingPublicDashboardData,
|
||||
isErrorPublicDashboardData,
|
||||
errorPublicDashboardData,
|
||||
publicDashboardResponse?.data,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Card className="dashboard-description-container">
|
||||
<div className="dashboard-header">
|
||||
@@ -333,11 +369,21 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
className="dashboard-title"
|
||||
data-testid="dashboard-title"
|
||||
>
|
||||
{' '}
|
||||
{title}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
{isDashboardLocked && <LockKeyhole size={14} />}
|
||||
|
||||
{isPublicDashboard && (
|
||||
<Tooltip title="This dashboard is publicly accessible">
|
||||
<Globe size={14} className="public-dashboard-icon" />
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{isDashboardLocked && (
|
||||
<Tooltip title="This dashboard is locked">
|
||||
<LockKeyhole size={14} className="lock-dashboard-icon" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="right-section">
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
.settings-tabs {
|
||||
.ant-tabs-nav-list {
|
||||
width: 228px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 2px;
|
||||
@@ -13,6 +12,10 @@
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.ant-tabs-tab:not(:last-child) {
|
||||
border-right: 1px solid var(--bg-slate-400) !important;
|
||||
}
|
||||
|
||||
.overview-btn {
|
||||
width: 114px;
|
||||
display: flex;
|
||||
@@ -27,6 +30,13 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.public-dashboard-btn {
|
||||
width: 150px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ant-tabs-ink-bar {
|
||||
display: none;
|
||||
}
|
||||
@@ -41,6 +51,11 @@
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
background: var(--bg-slate-400);
|
||||
}
|
||||
|
||||
.public-dashboard-btn {
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
background: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +78,10 @@
|
||||
.variables-btn {
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.public-dashboard-btn {
|
||||
background: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
.public-dashboard-setting-container {
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
padding: 16px !important;
|
||||
|
||||
.public-dashboard-setting-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.public-dashboard-setting-content-title {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.timerange-enabled-checkbox {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.default-time-range-select {
|
||||
margin-bottom: 8px;
|
||||
|
||||
.default-time-range-select-label {
|
||||
margin-bottom: 4px;
|
||||
|
||||
.default-time-range-select-label-text {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-selector {
|
||||
display: flex;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
|
||||
.ant-select-selection-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.list-item-image {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.public-dashboard-url {
|
||||
.url-label-container {
|
||||
margin-bottom: 4px;
|
||||
|
||||
.url-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.url-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
border-radius: 4px;
|
||||
padding: 0px 4px;
|
||||
|
||||
.url-text {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.default-time-range-select-dropdown {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.public-dashboard-setting-callout {
|
||||
margin-top: 12px;
|
||||
background: color-mix(in srgb, var(--bg-robin-500) 10%, transparent);
|
||||
padding: 12px 8px;
|
||||
border-radius: 3px;
|
||||
|
||||
.public-dashboard-setting-callout-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--text-robin-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.public-dashboard-setting-actions {
|
||||
margin-top: 32px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.public-dashboard-setting-container {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-100);
|
||||
|
||||
.public-dashboard-setting-content {
|
||||
.default-time-range-select {
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
|
||||
.public-dashboard-url {
|
||||
.url-container {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { fireEvent, within } from '@testing-library/react';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
import {
|
||||
publishedPublicDashboardMeta,
|
||||
unpublishedPublicDashboardMeta,
|
||||
} from 'mocks-server/__mockdata__/publicDashboard';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import PublicDashboardSetting from '../index';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('providers/Dashboard/Dashboard');
|
||||
jest.mock('react-use', () => ({
|
||||
...jest.requireActual('react-use'),
|
||||
useCopyToClipboard: jest.fn(),
|
||||
}));
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockUseDashboard = jest.mocked(useDashboard);
|
||||
const mockUseCopyToClipboard = jest.mocked(useCopyToClipboard);
|
||||
const mockToast = jest.mocked(toast);
|
||||
|
||||
// Test constants
|
||||
const MOCK_DASHBOARD_ID = 'test-dashboard-id';
|
||||
const MOCK_PUBLIC_PATH = '/public/dashboard/test-dashboard-id';
|
||||
const DEFAULT_TIME_RANGE = '30m';
|
||||
const DASHBOARD_VARIABLES_WARNING =
|
||||
"Dashboard variables won't work in public dashboards";
|
||||
|
||||
// Use wildcard pattern to match both relative and absolute URLs in MSW
|
||||
const publicDashboardURL = `*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`;
|
||||
|
||||
const mockSelectedDashboard = {
|
||||
id: MOCK_DASHBOARD_ID,
|
||||
data: {
|
||||
title: 'Test Dashboard',
|
||||
widgets: [],
|
||||
layout: [],
|
||||
panelMap: {},
|
||||
variables: {},
|
||||
},
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
server.listen();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
const mockSetCopyPublicDashboardURL = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Mock window.open
|
||||
window.open = jest.fn();
|
||||
|
||||
// Mock useDashboard
|
||||
mockUseDashboard.mockReturnValue(({
|
||||
selectedDashboard: mockSelectedDashboard,
|
||||
} as unknown) as ReturnType<typeof useDashboard>);
|
||||
|
||||
// Mock useCopyToClipboard
|
||||
mockUseCopyToClipboard.mockReturnValue(([
|
||||
undefined,
|
||||
mockSetCopyPublicDashboardURL,
|
||||
] as unknown) as ReturnType<typeof useCopyToClipboard>);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('PublicDashboardSetting', () => {
|
||||
describe('Unpublished Dashboard', () => {
|
||||
it('Unpublished dashboard should be handled correctly', async () => {
|
||||
server.use(
|
||||
rest.get(
|
||||
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(StatusCodes.NOT_FOUND),
|
||||
ctx.json(unpublishedPublicDashboardMeta),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<PublicDashboardSetting />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
/This dashboard is private. Publish it to make it accessible to anyone with the link./i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('checkbox', { name: /enable time range/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/default time range/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Last 30 minutes/i)).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(new RegExp(DASHBOARD_VARIABLES_WARNING, 'i')),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /publish dashboard/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Published Dashboard', () => {
|
||||
it('Published dashboard should be handled correctly', async () => {
|
||||
server.use(
|
||||
rest.get(
|
||||
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
|
||||
(_req, res, ctx) =>
|
||||
res(ctx.status(StatusCodes.OK), ctx.json(publishedPublicDashboardMeta)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<PublicDashboardSetting />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
/This dashboard is publicly accessible. Anyone with the link can view it./i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('checkbox', { name: /enable time range/i }),
|
||||
).toBeChecked();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/default time range/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Last 30 minutes/i)).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Public Dashboard URL/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: /update published dashboard/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: /unpublish dashboard/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Time Range Settings', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get(
|
||||
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
|
||||
(_req, res, ctx) =>
|
||||
res(ctx.status(StatusCodes.OK), ctx.json(publishedPublicDashboardMeta)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should toggle time range enabled when checkbox is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<PublicDashboardSetting />);
|
||||
|
||||
// Wait for checkbox to be rendered and verify initial state
|
||||
const checkbox = await screen.findByRole('checkbox', {
|
||||
name: /enable time range/i,
|
||||
});
|
||||
expect(checkbox).toBeChecked();
|
||||
|
||||
await user.click(checkbox);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
it('should update default time range when select value changes', async () => {
|
||||
render(<PublicDashboardSetting />);
|
||||
|
||||
const selectContainer = await screen.findByTestId(
|
||||
'default-time-range-select-dropdown',
|
||||
);
|
||||
|
||||
const combobox = within(selectContainer).getByRole('combobox');
|
||||
|
||||
fireEvent.mouseDown(combobox);
|
||||
|
||||
await screen.findByRole('listbox');
|
||||
|
||||
const option = await screen.findByText(/Last 1 hour/i, {
|
||||
selector: '.ant-select-item-option-content',
|
||||
});
|
||||
fireEvent.click(option);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(selectContainer).getByText(/Last 1 hour/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Create Public Dashboard', () => {
|
||||
it('should call create API when publish button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let createApiCalled = false;
|
||||
|
||||
server.use(
|
||||
rest.get(publicDashboardURL, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(StatusCodes.OK),
|
||||
ctx.json({
|
||||
data: {
|
||||
timeRangeEnabled: true,
|
||||
defaultTimeRange: DEFAULT_TIME_RANGE,
|
||||
publicPath: '',
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
rest.post(publicDashboardURL, async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
createApiCalled = true;
|
||||
expect(body).toEqual({
|
||||
timeRangeEnabled: true,
|
||||
defaultTimeRange: DEFAULT_TIME_RANGE,
|
||||
});
|
||||
return res(
|
||||
ctx.status(StatusCodes.CREATED),
|
||||
ctx.json({
|
||||
data: {
|
||||
timeRangeEnabled: true,
|
||||
defaultTimeRange: DEFAULT_TIME_RANGE,
|
||||
publicPath: MOCK_PUBLIC_PATH,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(<PublicDashboardSetting />);
|
||||
|
||||
// Find and click publish button
|
||||
const publishButton = await screen.findByRole('button', {
|
||||
name: /publish dashboard/i,
|
||||
});
|
||||
await user.click(publishButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createApiCalled).toBe(true);
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Public dashboard created successfully',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Update Public Dashboard', () => {
|
||||
it('should call update API when update button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let updateApiCalled = false;
|
||||
let capturedRequestBody: {
|
||||
timeRangeEnabled: boolean;
|
||||
defaultTimeRange: string;
|
||||
} | null = null;
|
||||
|
||||
server.use(
|
||||
rest.get(
|
||||
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
|
||||
(_req, res, ctx) =>
|
||||
res(ctx.status(StatusCodes.OK), ctx.json(publishedPublicDashboardMeta)),
|
||||
),
|
||||
rest.put(publicDashboardURL, async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
updateApiCalled = true;
|
||||
capturedRequestBody = body;
|
||||
return res(ctx.status(StatusCodes.NO_CONTENT), ctx.json({}));
|
||||
}),
|
||||
);
|
||||
|
||||
render(<PublicDashboardSetting />);
|
||||
|
||||
// Wait for API response and component update
|
||||
const updateButton = await screen.findByRole(
|
||||
'button',
|
||||
{ name: /update published dashboard/i },
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
await user.click(updateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateApiCalled).toBe(true);
|
||||
expect(capturedRequestBody).toEqual({
|
||||
timeRangeEnabled: true,
|
||||
defaultTimeRange: DEFAULT_TIME_RANGE,
|
||||
});
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Public dashboard updated successfully',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Revoke Public Dashboard Access', () => {
|
||||
it('should call revoke API when unpublish button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let revokeApiCalled = false;
|
||||
let capturedDashboardId: string | null = null;
|
||||
|
||||
server.use(
|
||||
rest.get(
|
||||
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
|
||||
(_req, res, ctx) =>
|
||||
res(ctx.status(StatusCodes.OK), ctx.json(publishedPublicDashboardMeta)),
|
||||
),
|
||||
rest.delete(publicDashboardURL, (req, res, ctx) => {
|
||||
revokeApiCalled = true;
|
||||
// Extract dashboard ID from URL: /api/v1/dashboards/{id}/public
|
||||
const urlMatch = req.url.pathname.match(
|
||||
/\/api\/v1\/dashboards\/([^/]+)\/public/,
|
||||
);
|
||||
capturedDashboardId = urlMatch ? urlMatch[1] : null;
|
||||
return res(ctx.status(StatusCodes.NO_CONTENT), ctx.json({}));
|
||||
}),
|
||||
);
|
||||
|
||||
render(<PublicDashboardSetting />);
|
||||
|
||||
// Wait for API response and component update
|
||||
const unpublishButton = await screen.findByRole(
|
||||
'button',
|
||||
{ name: /unpublish dashboard/i },
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
await user.click(unpublishButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(revokeApiCalled).toBe(true);
|
||||
expect(capturedDashboardId).toBe(MOCK_DASHBOARD_ID);
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Dashboard unpublished successfully',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import './PublicDashboard.styles.scss';
|
||||
|
||||
import { Checkbox } from '@signozhq/checkbox';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { Button, Select, Typography } from 'antd';
|
||||
import createPublicDashboardAPI from 'api/dashboard/public/createPublicDashboard';
|
||||
import revokePublicDashboardAccessAPI from 'api/dashboard/public/revokePublicDashboardAccess';
|
||||
import updatePublicDashboardAPI from 'api/dashboard/public/updatePublicDashboard';
|
||||
import { useGetPublicDashboardMeta } from 'hooks/dashboard/useGetPublicDashboardMeta';
|
||||
import { Copy, ExternalLink, Globe, Info, Loader2, Trash } from 'lucide-react';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { PublicDashboardMetaProps } from 'types/api/dashboard/public/getMeta';
|
||||
|
||||
export const TIME_RANGE_PRESETS_OPTIONS = [
|
||||
{
|
||||
label: 'Last 5 minutes',
|
||||
value: '5m',
|
||||
},
|
||||
{
|
||||
label: 'Last 15 minutes',
|
||||
value: '15m',
|
||||
},
|
||||
{
|
||||
label: 'Last 30 minutes',
|
||||
value: '30m',
|
||||
},
|
||||
{
|
||||
label: 'Last 1 hour',
|
||||
value: '1h',
|
||||
},
|
||||
{
|
||||
label: 'Last 6 hours',
|
||||
value: '6h',
|
||||
},
|
||||
{
|
||||
label: 'Last 1 day',
|
||||
value: '24h',
|
||||
},
|
||||
];
|
||||
|
||||
function PublicDashboardSetting(): JSX.Element {
|
||||
const [publicDashboardData, setPublicDashboardData] = useState<
|
||||
PublicDashboardMetaProps | undefined
|
||||
>(undefined);
|
||||
const [timeRangeEnabled, setTimeRangeEnabled] = useState(true);
|
||||
const [defaultTimeRange, setDefaultTimeRange] = useState('30m');
|
||||
const [, setCopyPublicDashboardURL] = useCopyToClipboard();
|
||||
|
||||
const { selectedDashboard } = useDashboard();
|
||||
|
||||
const handleDefaultTimeRange = useCallback((value: string): void => {
|
||||
setDefaultTimeRange(value);
|
||||
}, []);
|
||||
|
||||
const handleTimeRangeEnabled = useCallback((): void => {
|
||||
setTimeRangeEnabled((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
data: publicDashboardResponse,
|
||||
isLoading: isLoadingPublicDashboard,
|
||||
isFetching: isFetchingPublicDashboard,
|
||||
refetch: refetchPublicDashboard,
|
||||
error: errorPublicDashboard,
|
||||
} = useGetPublicDashboardMeta(selectedDashboard?.id || '');
|
||||
|
||||
const isPublicDashboardEnabled = !!publicDashboardData?.publicPath;
|
||||
|
||||
useEffect(() => {
|
||||
if (publicDashboardResponse?.data) {
|
||||
setPublicDashboardData(publicDashboardResponse?.data);
|
||||
}
|
||||
|
||||
if (errorPublicDashboard) {
|
||||
console.error('Error getting public dashboard', errorPublicDashboard);
|
||||
setPublicDashboardData(undefined);
|
||||
setTimeRangeEnabled(true);
|
||||
setDefaultTimeRange('30m');
|
||||
}
|
||||
}, [publicDashboardResponse, errorPublicDashboard]);
|
||||
|
||||
useEffect(() => {
|
||||
if (publicDashboardResponse?.data) {
|
||||
setTimeRangeEnabled(
|
||||
publicDashboardResponse?.data?.timeRangeEnabled || false,
|
||||
);
|
||||
setDefaultTimeRange(
|
||||
publicDashboardResponse?.data?.defaultTimeRange || '30m',
|
||||
);
|
||||
}
|
||||
}, [publicDashboardResponse]);
|
||||
|
||||
const {
|
||||
mutate: createPublicDashboard,
|
||||
isLoading: isLoadingCreatePublicDashboard,
|
||||
data: createPublicDashboardResponse,
|
||||
} = useMutation(createPublicDashboardAPI, {
|
||||
onSuccess: () => {
|
||||
toast.success('Public dashboard created successfully');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to create public dashboard');
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
mutate: updatePublicDashboard,
|
||||
isLoading: isLoadingUpdatePublicDashboard,
|
||||
data: updatePublicDashboardResponse,
|
||||
} = useMutation(updatePublicDashboardAPI, {
|
||||
onSuccess: () => {
|
||||
toast.success('Public dashboard updated successfully');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to update public dashboard');
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
mutate: revokePublicDashboardAccess,
|
||||
isLoading: isLoadingRevokePublicDashboardAccess,
|
||||
data: revokePublicDashboardAccessResponse,
|
||||
} = useMutation(revokePublicDashboardAccessAPI, {
|
||||
onSuccess: () => {
|
||||
toast.success('Dashboard unpublished successfully');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to unpublish dashboard');
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreatePublicDashboard = (): void => {
|
||||
if (!selectedDashboard) return;
|
||||
|
||||
createPublicDashboard({
|
||||
dashboardId: selectedDashboard.id,
|
||||
timeRangeEnabled,
|
||||
defaultTimeRange,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdatePublicDashboard = (): void => {
|
||||
if (!selectedDashboard) return;
|
||||
|
||||
updatePublicDashboard({
|
||||
dashboardId: selectedDashboard.id,
|
||||
timeRangeEnabled,
|
||||
defaultTimeRange,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRevokePublicDashboardAccess = (): void => {
|
||||
if (!selectedDashboard) return;
|
||||
|
||||
revokePublicDashboardAccess({
|
||||
id: selectedDashboard.id,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
(createPublicDashboardResponse &&
|
||||
createPublicDashboardResponse.httpStatusCode === 201) ||
|
||||
(updatePublicDashboardResponse &&
|
||||
updatePublicDashboardResponse.httpStatusCode === 204) ||
|
||||
(revokePublicDashboardAccessResponse &&
|
||||
revokePublicDashboardAccessResponse.httpStatusCode === 204)
|
||||
) {
|
||||
refetchPublicDashboard();
|
||||
}
|
||||
}, [
|
||||
createPublicDashboardResponse,
|
||||
updatePublicDashboardResponse,
|
||||
revokePublicDashboardAccessResponse,
|
||||
refetchPublicDashboard,
|
||||
]);
|
||||
|
||||
const handleCopyPublicDashboardURL = (): void => {
|
||||
if (!publicDashboardResponse?.data?.publicPath) return;
|
||||
|
||||
try {
|
||||
setCopyPublicDashboardURL(
|
||||
`${window.location.origin}${publicDashboardResponse?.data?.publicPath}`,
|
||||
);
|
||||
toast.success('Copied Public Dashboard URL successfully');
|
||||
} catch (error) {
|
||||
console.error('Error copying public dashboard URL', error);
|
||||
}
|
||||
};
|
||||
|
||||
const publicDashboardURL = useMemo(
|
||||
() => `${window.location.origin}${publicDashboardResponse?.data?.publicPath}`,
|
||||
[publicDashboardResponse],
|
||||
);
|
||||
|
||||
const isLoading =
|
||||
isLoadingCreatePublicDashboard ||
|
||||
isLoadingUpdatePublicDashboard ||
|
||||
isLoadingRevokePublicDashboardAccess ||
|
||||
isLoadingPublicDashboard;
|
||||
|
||||
return (
|
||||
<div className="public-dashboard-setting-container">
|
||||
<div className="public-dashboard-setting-content">
|
||||
<Typography.Title
|
||||
level={5}
|
||||
className="public-dashboard-setting-content-title"
|
||||
>
|
||||
{isPublicDashboardEnabled
|
||||
? 'This dashboard is publicly accessible. Anyone with the link can view it.'
|
||||
: 'This dashboard is private. Publish it to make it accessible to anyone with the link.'}
|
||||
</Typography.Title>
|
||||
|
||||
<div className="timerange-enabled-checkbox">
|
||||
<Checkbox
|
||||
id="enable-time-range"
|
||||
checked={timeRangeEnabled}
|
||||
onCheckedChange={handleTimeRangeEnabled}
|
||||
labelName="Enable time range"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="default-time-range-select">
|
||||
<div className="default-time-range-select-label">
|
||||
<Typography.Text className="default-time-range-select-label-text">
|
||||
Default time range
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Select
|
||||
placeholder="Select default time range"
|
||||
options={TIME_RANGE_PRESETS_OPTIONS}
|
||||
value={defaultTimeRange}
|
||||
onChange={handleDefaultTimeRange}
|
||||
data-testid="default-time-range-select-dropdown"
|
||||
className="default-time-range-select-dropdown"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isPublicDashboardEnabled && (
|
||||
<div className="public-dashboard-url">
|
||||
<div className="url-label-container">
|
||||
<Typography.Text className="url-label">
|
||||
Public Dashboard URL
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className="url-container">
|
||||
<Typography.Text className="url-text">
|
||||
{publicDashboardURL}
|
||||
</Typography.Text>
|
||||
|
||||
<Button
|
||||
type="link"
|
||||
className="url-copy-btn periscope-btn ghost"
|
||||
icon={<Copy size={12} />}
|
||||
onClick={handleCopyPublicDashboardURL}
|
||||
/>
|
||||
<Button
|
||||
type="link"
|
||||
className="periscope-btn ghost"
|
||||
icon={<ExternalLink size={12} />}
|
||||
onClick={(): void => {
|
||||
if (publicDashboardURL) {
|
||||
window.open(publicDashboardURL, '_blank');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="public-dashboard-setting-callout">
|
||||
<Typography.Text className="public-dashboard-setting-callout-text">
|
||||
<Info size={12} className="public-dashboard-setting-callout-icon" />{' '}
|
||||
Dashboard variables won't work in public dashboards
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className="public-dashboard-setting-actions">
|
||||
{!isPublicDashboardEnabled ? (
|
||||
<Button
|
||||
type="primary"
|
||||
className="create-public-dashboard-btn periscope-btn primary"
|
||||
disabled={isLoading}
|
||||
onClick={handleCreatePublicDashboard}
|
||||
loading={
|
||||
isLoadingCreatePublicDashboard ||
|
||||
isFetchingPublicDashboard ||
|
||||
isLoadingPublicDashboard
|
||||
}
|
||||
icon={
|
||||
isLoadingCreatePublicDashboard ||
|
||||
isFetchingPublicDashboard ||
|
||||
isLoadingPublicDashboard ? (
|
||||
<Loader2 className="animate-spin" size={14} />
|
||||
) : (
|
||||
<Globe size={14} />
|
||||
)
|
||||
}
|
||||
>
|
||||
Publish dashboard
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="default"
|
||||
className="periscope-btn secondary"
|
||||
disabled={isLoading}
|
||||
onClick={handleRevokePublicDashboardAccess}
|
||||
loading={isLoadingRevokePublicDashboardAccess}
|
||||
icon={<Trash size={14} />}
|
||||
>
|
||||
Unpublish dashboard
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
className="create-public-dashboard-btn periscope-btn primary"
|
||||
disabled={isLoading}
|
||||
onClick={handleUpdatePublicDashboard}
|
||||
loading={isLoadingUpdatePublicDashboard}
|
||||
icon={<Globe size={14} />}
|
||||
>
|
||||
Update published dashboard
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublicDashboardSetting;
|
||||
@@ -1,9 +1,10 @@
|
||||
import './DashboardSettingsContent.styles.scss';
|
||||
|
||||
import { Button, Tabs } from 'antd';
|
||||
import { Braces, Table } from 'lucide-react';
|
||||
import { Braces, Globe, Table } from 'lucide-react';
|
||||
|
||||
import GeneralDashboardSettings from './General';
|
||||
import PublicDashboardSetting from './PublicDashboard';
|
||||
import VariablesSetting from './Variables';
|
||||
|
||||
function DashboardSettingsContent({
|
||||
@@ -30,6 +31,19 @@ function DashboardSettingsContent({
|
||||
key: 'variables',
|
||||
children: <VariablesSetting variableViewModeRef={variableViewModeRef} />,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Globe size={14} />}
|
||||
className="public-dashboard-btn"
|
||||
>
|
||||
Publish
|
||||
</Button>
|
||||
),
|
||||
key: 'public-dashboard',
|
||||
children: <PublicDashboardSetting />,
|
||||
},
|
||||
];
|
||||
|
||||
return <Tabs items={items} animated className="settings-tabs" />;
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { Checkbox, Empty } from 'antd';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { EXCLUDED_COLUMNS } from 'container/OptionsMenu/constants';
|
||||
import { QueryKeySuggestionsResponseProps } from 'types/api/querySuggestions/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
type ExplorerAttributeColumnsProps = {
|
||||
isLoading: boolean;
|
||||
data: any;
|
||||
data: AxiosResponse<QueryKeySuggestionsResponseProps> | undefined;
|
||||
searchText: string;
|
||||
isAttributeKeySelected: (key: string) => boolean;
|
||||
handleCheckboxChange: (key: string) => void;
|
||||
dataSource: DataSource;
|
||||
};
|
||||
|
||||
function ExplorerAttributeColumns({
|
||||
@@ -15,6 +20,7 @@ function ExplorerAttributeColumns({
|
||||
searchText,
|
||||
isAttributeKeySelected,
|
||||
handleCheckboxChange,
|
||||
dataSource,
|
||||
}: ExplorerAttributeColumnsProps): JSX.Element {
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -27,8 +33,10 @@ function ExplorerAttributeColumns({
|
||||
const filteredAttributeKeys =
|
||||
Object.values(data?.data?.data?.keys || {})
|
||||
?.flat()
|
||||
?.filter((attributeKey: any) =>
|
||||
attributeKey.name.toLowerCase().includes(searchText.toLowerCase()),
|
||||
?.filter(
|
||||
(attributeKey) =>
|
||||
attributeKey.name.toLowerCase().includes(searchText.toLowerCase()) &&
|
||||
!EXCLUDED_COLUMNS[dataSource].includes(attributeKey.name),
|
||||
) || [];
|
||||
if (filteredAttributeKeys.length === 0) {
|
||||
return (
|
||||
|
||||
@@ -183,6 +183,7 @@ function ExplorerColumnsRenderer({
|
||||
searchText={searchText}
|
||||
isAttributeKeySelected={isAttributeKeySelected}
|
||||
handleCheckboxChange={handleCheckboxChange}
|
||||
dataSource={initialDataSource}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -450,4 +450,58 @@ describe('ExplorerColumnsRenderer', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show isRoot or isEntryPoint in add column dropdown (traces, dashboard table panel)', async () => {
|
||||
(useQueryBuilder as jest.Mock).mockReturnValue({
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'count',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
|
||||
data: {
|
||||
data: {
|
||||
data: {
|
||||
keys: {
|
||||
attributeKeys: [
|
||||
{ name: 'isRoot', dataType: 'bool', type: '' },
|
||||
{ name: 'isEntryPoint', dataType: 'bool', type: '' },
|
||||
{ name: 'duration', dataType: 'number', type: '' },
|
||||
{ name: 'serviceName', dataType: 'string', type: '' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<Wrapper>
|
||||
<ExplorerColumnsRenderer
|
||||
selectedLogFields={[]}
|
||||
setSelectedLogFields={mockSetSelectedLogFields}
|
||||
selectedTracesFields={[]}
|
||||
setSelectedTracesFields={mockSetSelectedTracesFields}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByTestId('add-columns-button'));
|
||||
|
||||
// Visible columns should appear
|
||||
expect(screen.getByText('duration')).toBeInTheDocument();
|
||||
expect(screen.getByText('serviceName')).toBeInTheDocument();
|
||||
|
||||
// Hidden columns should NOT appear
|
||||
expect(screen.queryByText('isRoot')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('isEntryPoint')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,10 +12,7 @@ import {
|
||||
Switch,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
PrecisionOption,
|
||||
PrecisionOptionsEnum,
|
||||
} from 'components/Graph/yAxisConfig';
|
||||
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
|
||||
import TimePreference from 'components/TimePreferenceDropDown';
|
||||
import { PANEL_TYPES, PanelDisplay } from 'constants/queryBuilder';
|
||||
import GraphTypes, {
|
||||
|
||||
@@ -4,10 +4,7 @@ import './NewWidget.styles.scss';
|
||||
import { WarningOutlined } from '@ant-design/icons';
|
||||
import { Button, Flex, Modal, Space, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
PrecisionOption,
|
||||
PrecisionOptionsEnum,
|
||||
} from 'components/Graph/yAxisConfig';
|
||||
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { adjustQueryForV5 } from 'components/QueryBuilderV2/utils';
|
||||
import { QueryParams } from 'constants/query';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DefaultOptionType } from 'antd/es/select';
|
||||
import { omitIdFromQuery } from 'components/ExplorerCard/utils';
|
||||
import { PrecisionOptionsEnum } from 'components/Graph/yAxisConfig';
|
||||
import { PrecisionOptionsEnum } from 'components/Graph/types';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
PANEL_TYPES,
|
||||
|
||||