Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs
This commit is contained in:
@@ -3,8 +3,8 @@ module github.com/grafana/grafana
|
||||
go 1.24.4
|
||||
|
||||
require (
|
||||
buf.build/gen/go/parca-dev/parca/connectrpc/go v1.17.0-20240902100956-02fd72488966.1 // @grafana/observability-traces-and-profiling
|
||||
buf.build/gen/go/parca-dev/parca/protocolbuffers/go v1.34.2-20240902100956-02fd72488966.2 // @grafana/observability-traces-and-profiling
|
||||
buf.build/gen/go/parca-dev/parca/connectrpc/go v1.18.1-20250703125925-3f0fcf4bff96.1 // @grafana/observability-traces-and-profiling
|
||||
buf.build/gen/go/parca-dev/parca/protocolbuffers/go v1.36.2-20250703125925-3f0fcf4bff96.1 // @grafana/observability-traces-and-profiling
|
||||
cloud.google.com/go/kms v1.21.0 // @grafana/grafana-backend-group
|
||||
cloud.google.com/go/storage v1.52.0 // @grafana/grafana-backend-group
|
||||
connectrpc.com/connect v1.18.1 // @grafana/observability-traces-and-profiling
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
buf.build/gen/go/parca-dev/parca/connectrpc/go v1.17.0-20240902100956-02fd72488966.1 h1:Ur1tEsOxct4cuBumorzL/Zl9rRedJKdkPqufih3WsqQ=
|
||||
buf.build/gen/go/parca-dev/parca/connectrpc/go v1.17.0-20240902100956-02fd72488966.1/go.mod h1:gC0oJPXUcGXzgiyUzMPBIgIfHbdoovWQD3/njIe5EVA=
|
||||
buf.build/gen/go/parca-dev/parca/protocolbuffers/go v1.34.2-20240902100956-02fd72488966.2 h1:oRhx9wmKP52zEp1/PWWjaNxWtxWFrKXYSF1UJygjbzQ=
|
||||
buf.build/gen/go/parca-dev/parca/protocolbuffers/go v1.34.2-20240902100956-02fd72488966.2/go.mod h1:w3CrNzdvwGJ4FwUlhshojc2FDXDN+3ou5nlcLTu7dHs=
|
||||
buf.build/gen/go/parca-dev/parca/connectrpc/go v1.18.1-20250703125925-3f0fcf4bff96.1 h1:MXQkdpJwbCwskYLQ3dUv/5gmk8fSsUwu78u+kXebbek=
|
||||
buf.build/gen/go/parca-dev/parca/connectrpc/go v1.18.1-20250703125925-3f0fcf4bff96.1/go.mod h1:pjl83IqpNF7Lm/lOPMLQ5IIfCg+yTu2A9OgYTcEncCs=
|
||||
buf.build/gen/go/parca-dev/parca/protocolbuffers/go v1.36.2-20250703125925-3f0fcf4bff96.1 h1:9nqE/pDc/HXAXiD5pZncPywjAzWgKuBkFFYgdK2lVU8=
|
||||
buf.build/gen/go/parca-dev/parca/protocolbuffers/go v1.36.2-20250703125925-3f0fcf4bff96.1/go.mod h1:1M7nlq2ljfzb95x9LaA2j1gYIvDkVZii58mGvTa9ExM=
|
||||
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0=
|
||||
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w=
|
||||
cel.dev/expr v0.23.1 h1:K4KOtPCJQjVggkARsjG9RWXP6O4R73aHeJMa/dmCQQg=
|
||||
|
||||
+77
@@ -6,6 +6,10 @@ atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs=
|
||||
atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU=
|
||||
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250425153114-8976f5be98c1.1 h1:YhMSc48s25kr7kv31Z8vf7sPUIq5YJva9z1mn/hAt0M=
|
||||
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250425153114-8976f5be98c1.1/go.mod h1:avRlCjnFzl98VPaeCtJ24RrV/wwHFzB8sWXhj26+n/U=
|
||||
buf.build/gen/go/parca-dev/parca/connectrpc/go v1.18.1-20250703125925-3f0fcf4bff96.1 h1:MXQkdpJwbCwskYLQ3dUv/5gmk8fSsUwu78u+kXebbek=
|
||||
buf.build/gen/go/parca-dev/parca/connectrpc/go v1.18.1-20250703125925-3f0fcf4bff96.1/go.mod h1:pjl83IqpNF7Lm/lOPMLQ5IIfCg+yTu2A9OgYTcEncCs=
|
||||
buf.build/gen/go/parca-dev/parca/protocolbuffers/go v1.36.2-20250703125925-3f0fcf4bff96.1 h1:9nqE/pDc/HXAXiD5pZncPywjAzWgKuBkFFYgdK2lVU8=
|
||||
buf.build/gen/go/parca-dev/parca/protocolbuffers/go v1.36.2-20250703125925-3f0fcf4bff96.1/go.mod h1:1M7nlq2ljfzb95x9LaA2j1gYIvDkVZii58mGvTa9ExM=
|
||||
buf.build/go/protovalidate v0.12.0 h1:4GKJotbspQjRCcqZMGVSuC8SjwZ/FmgtSuKDpKUTZew=
|
||||
buf.build/go/protovalidate v0.12.0/go.mod h1:q3PFfbzI05LeqxSwq+begW2syjy2Z6hLxZSkP1OH/D0=
|
||||
cel.dev/expr v0.23.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
|
||||
@@ -310,6 +314,7 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0 h1:t527LHHE3HmiHrq74QMpNPZpGCIJzTx+apLkMKt4HC0=
|
||||
github.com/RoaringBitmap/gocroaring v0.4.0 h1:5nufXUgWpBEUNEJXw7926YAA58ZAQRpWPrQV1xCoSjc=
|
||||
github.com/RoaringBitmap/real-roaring-datasets v0.0.0-20190726190000-eb7c87156f76 h1:ZYlhPbqQFU+AHfgtCdHGDTtRW1a8geZyiE8c6Q+Sl1s=
|
||||
github.com/RoaringBitmap/real-roaring-datasets v0.0.0-20190726190000-eb7c87156f76/go.mod h1:oM0MHmQ3nDsq609SS36p+oYbRi16+oVvU2Bw4Ipv0SE=
|
||||
github.com/Sereal/Sereal/Go/sereal v0.0.0-20231009093132-b9187f1a92c6 h1:5kUcJJAKWWI82Xnp/CaU0eu5hLlHkmm9acjowSkwCd0=
|
||||
github.com/Sereal/Sereal/Go/sereal v0.0.0-20231009093132-b9187f1a92c6/go.mod h1:JwrycNnC8+sZPDyzM3MQ86LvaGzSpfxg885KOOwFRW4=
|
||||
github.com/Shopify/goreferrer v0.0.0-20220729165902-8cddb4f5de06 h1:KkH3I3sJuOLP3TjA/dfr4NAY8bghDwnXiU7cTKxQqo0=
|
||||
@@ -355,6 +360,12 @@ github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e h1:QEF07wC0T1rKkctt1
|
||||
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6 h1:G1bPvciwNyF7IUmKXNt9Ak3m6u9DE1rF+RmtIkBpVdA=
|
||||
github.com/aws/aws-msk-iam-sasl-signer-go v1.0.1 h1:nMp7diZObd4XEVUR0pEvn7/E13JIgManMX79Q6quV6E=
|
||||
github.com/aws/aws-msk-iam-sasl-signer-go v1.0.1/go.mod h1:MVYeeOhILFFemC/XlYTClvBjYZrg/EPd3ts885KrNTI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.29.17 h1:jSuiQ5jEe4SAMH6lLRMY9OVC+TqJLP5655pBGjmnjr0=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.29.17/go.mod h1:9P4wwACpbeXs9Pm9w1QTh6BwWwJjwYvJ1iCt5QbCXh8=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.70 h1:ONnH5CM16RTXRkS8Z1qg7/s2eDOhHhaXVd72mmyv4/0=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.70/go.mod h1:M+lWhhmomVGgtuPOhO85u4pEa3SmssPTdcYpP/5J/xc=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32 h1:KAXP9JSHO1vKGCr5f4O6WmlVKLFFXgWYAGoJosorxzU=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32/go.mod h1:h4Sg6FQdexC1yYG9RDnOvLbW1a/P986++/Y/a+GyEM8=
|
||||
github.com/aws/aws-sdk-go-v2/service/kms v1.35.3 h1:UPTdlTOwWUX49fVi7cymEN6hDqCwe3LNv1vi7TXUutk=
|
||||
github.com/aws/aws-sdk-go-v2/service/kms v1.35.3/go.mod h1:gjDP16zn+WWalyaUqwCCioQ8gU8lzttCCc9jYsiQI/8=
|
||||
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.32.4 h1:NgRFYyFpiMD62y4VPXh4DosPFbZd4vdMVBWKk0VmWXc=
|
||||
@@ -365,6 +376,12 @@ github.com/aws/aws-sdk-go-v2/service/sqs v1.34.3 h1:Vjqy5BZCOIsn4Pj8xzyqgGmsSqzz
|
||||
github.com/aws/aws-sdk-go-v2/service/sqs v1.34.3/go.mod h1:L0enV3GCRd5iG9B64W35C4/hwsCB00Ib+DKVGTadKHI=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssm v1.52.4 h1:hgSBvRT7JEWx2+vEGI9/Ld5rZtl7M5lu8PqdvOmbRHw=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssm v1.52.4/go.mod h1:v7NIzEFIHBiicOMaMTuEmbnzGnqW0d+6ulNALul6fYE=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.25.5 h1:AIRJ3lfb2w/1/8wOOSqYb9fUKGwQbtysJ2H1MofRUPg=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.25.5/go.mod h1:b7SiVprpU+iGazDUqvRSLf5XmCdn+JtT1on7uNL6Ipc=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3 h1:BpOxT3yhLwSJ77qIY3DoHAQjZsc4HEGfMCE4NGy3uFg=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3/go.mod h1:vq/GQR1gOFLquZMSrxUK/cpvKCNVYibNyJ1m7JrU88E=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.34.0 h1:NFOJ/NXEGV4Rq//71Hs1jC/NvPs1ezajK+yQmkwnPV0=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.34.0/go.mod h1:7ph2tGpfQvwzgistp2+zga9f+bCjlQJPkPUmMgDSD7w=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/baidubce/bce-sdk-go v0.9.188 h1:8MA7ewe4VpX01uYl7Kic6ZvfIReUFdSKbY46ZqlQM7U=
|
||||
@@ -598,13 +615,73 @@ github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||
github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
|
||||
github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
|
||||
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||
github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grafana/alerting v0.0.0-20250129195454-3e5b80036b7a/go.mod h1:QsnoKX/iYZxA4Cv+H+wC7uxutBD8qi8ZW5UJvD2TYmU=
|
||||
github.com/grafana/alerting v0.0.0-20250310104713-16b885f1c79e/go.mod h1:HfvjmU3UqCIpoy9Z2wgKGrZ4A5vz+yQlP9ZXvCfEkiA=
|
||||
github.com/grafana/alerting v0.0.0-20250403153742-418bc7118d05 h1:hMzOzI/S0nkZt0nUqpfAa4Rdb+YL8z8oG3pl4Jb31h8=
|
||||
github.com/grafana/alerting v0.0.0-20250403153742-418bc7118d05/go.mod h1:K3YAJumchx5EEZItGv4D3pCv/Ux796hmoOibP/p/eYk=
|
||||
github.com/grafana/alerting v0.0.0-20250429131604-de176b4a0309 h1:H2p3XKDHnTBGkMXLCgXiqb2dFnHbQ4zPDXOwKK4Ne3Y=
|
||||
github.com/grafana/alerting v0.0.0-20250429131604-de176b4a0309/go.mod h1:pMfhRxL2LZ3Pm8iy7VcVsb9CLYuBtjFYbf1oxgx7yFA=
|
||||
github.com/grafana/alerting v0.0.0-20250701210250-cea2d1683945 h1:3imTbxFpZSVI6IBIB9mn+Xc40lUweWjfMaBSgXR7rLs=
|
||||
github.com/grafana/alerting v0.0.0-20250701210250-cea2d1683945/go.mod h1:gtR7agmxVfJOmNKV/n2ZULgOYTYNL+PDKYB5N48tQ7Q=
|
||||
github.com/grafana/authlib v0.0.0-20250123104008-e99947858901/go.mod h1:/gYfphsNu9v1qYWXxpv1NSvMEMSwvdf8qb8YlgwIRl8=
|
||||
github.com/grafana/authlib/types v0.0.0-20250120144156-d6737a7dc8f5/go.mod h1:qYjSd1tmJiuVoSICp7Py9/zD54O9uQQA3wuM6Gg4DFM=
|
||||
github.com/grafana/authlib/types v0.0.0-20250120145936-5f0e28e7a87c/go.mod h1:qYjSd1tmJiuVoSICp7Py9/zD54O9uQQA3wuM6Gg4DFM=
|
||||
github.com/grafana/authlib/types v0.0.0-20250314102521-a77865c746c0/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw=
|
||||
github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2 h1:qhugDMdQ4Vp68H0tp/0iN17DM2ehRo1rLEdOFe/gB8I=
|
||||
github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2/go.mod h1:w/aiO1POVIeXUQyl0VQSZjl5OAGDTL5aX+4v0RA1tcw=
|
||||
github.com/grafana/go-gelf/v2 v2.0.1 h1:BOChP0h/jLeD+7F9mL7tq10xVkDG15he3T1zHuQaWak=
|
||||
github.com/grafana/go-gelf/v2 v2.0.1/go.mod h1:lexHie0xzYGwCgiRGcvZ723bSNyNI8ZRD4s0CLobh90=
|
||||
github.com/grafana/gomemcache v0.0.0-20250228145437-da7b95fd2ac1/go.mod h1:j/s0jkda4UXTemDs7Pgw/vMT06alWc42CHisvYac0qw=
|
||||
github.com/grafana/grafana-app-sdk/logging v0.38.0/go.mod h1:Y/bvbDhBiV/tkIle9RW49pgfSPIPSON8Q4qjx3pyqDk=
|
||||
github.com/grafana/grafana-app-sdk/logging v0.39.0 h1:3GgN5+dUZYqq74Q+GT9/ET+yo+V54zWQk/Q2/JsJQB4=
|
||||
github.com/grafana/grafana-app-sdk/logging v0.39.0/go.mod h1:WhDENSnaGHtyVVwZGVnAR7YLvh2xlLDYR3D7E6h7XVk=
|
||||
github.com/grafana/grafana-aws-sdk v0.38.2 h1:TzQD0OpWsNjtldi5G5TLDlBRk8OyDf+B5ujcoAu4Dp0=
|
||||
github.com/grafana/grafana-aws-sdk v0.38.2/go.mod h1:j3vi+cXYHEFqjhBGrI6/lw1TNM+dl0Y3f0cSnDOPy+s=
|
||||
github.com/grafana/grafana-aws-sdk v1.0.2 h1:98eBuHYFmgvH0xO9kKf4RBsEsgQRp8EOA/9yhDIpkss=
|
||||
github.com/grafana/grafana-aws-sdk v1.0.2/go.mod h1:hO7q7yWV+t6dmiyJjMa3IbuYnYkBua+G/IAlOPVIYKE=
|
||||
github.com/grafana/grafana-plugin-sdk-go v0.263.0/go.mod h1:U43Cnrj/9DNYyvFcNdeUWNjMXTKNB0jcTcQGpWKd2gw=
|
||||
github.com/grafana/grafana-plugin-sdk-go v0.267.0/go.mod h1:OuwS4c/JYgn0rr/w5zhJBpLo4gKm/vw15RsfpYAvK9Q=
|
||||
github.com/grafana/grafana-plugin-sdk-go v0.269.1/go.mod h1:yv2KbO4mlr9WuDK2f+2gHAMTwwLmLuqaEnrPXTRU+OI=
|
||||
github.com/grafana/grafana-plugin-sdk-go v0.275.0/go.mod h1:mO9LJqdXDh5JpO/xIdPAeg5LdThgQ06Y/SLpXDWKw2c=
|
||||
github.com/grafana/grafana-plugin-sdk-go v0.277.0/go.mod h1:mAUWg68w5+1f5TLDqagIr8sWr1RT9h7ufJl5NMcWJAU=
|
||||
github.com/grafana/grafana/apps/advisor v0.0.0-20250123151950-b066a6313173/go.mod h1:goSDiy3jtC2cp8wjpPZdUHRENcoSUHae1/Px/MDfddA=
|
||||
github.com/grafana/grafana/apps/advisor v0.0.0-20250220154326-6e5de80ef295/go.mod h1:9I1dKV3Dqr0NPR9Af0WJGxOytp5/6W3JLiNChOz8r+c=
|
||||
github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250121113133-e747350fee2d/go.mod h1:AvleS6icyPmcBjihtx5jYEvdzLmHGBp66NuE0AMR57A=
|
||||
github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250416173722-ec17e0e4ce03/go.mod h1:oemrhKvFxxc5m32xKHPxInEHAObH0/hPPyHUiBUZ1Cc=
|
||||
github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250506052906-7a2fc797fb4a/go.mod h1:VkX53kBiqIMHBoGgeEDJnzm5Nwcmv/726tuZuT5SvJY=
|
||||
github.com/grafana/grafana/apps/dashboard v0.0.0-20250616135341-59c2f154336b/go.mod h1:OIlvNnUufYDhBXa4xK4CyzPI2C69ZJkHy5+aFDyPtXw=
|
||||
github.com/grafana/grafana/apps/dashboard v0.0.0-20250616145019-8d27f12428cb/go.mod h1:OIlvNnUufYDhBXa4xK4CyzPI2C69ZJkHy5+aFDyPtXw=
|
||||
github.com/grafana/grafana/apps/investigation v0.0.0-20250121113133-e747350fee2d/go.mod h1:HQprw3MmiYj5OUV9CZnkwA1FKDZBmYACuAB3oDvUOmI=
|
||||
github.com/grafana/grafana/apps/playlist v0.0.0-20250121113133-e747350fee2d/go.mod h1:DjJe5osrW/BKrzN9hAAOSElNWutj1bcriExa7iDP7kA=
|
||||
github.com/grafana/grafana/pkg/aggregator v0.0.0-20250121113133-e747350fee2d/go.mod h1:1sq0guad+G4SUTlBgx7SXfhnzy7D86K/LcVOtiQCiMA=
|
||||
github.com/grafana/grafana/pkg/build v0.0.0-20250220114259-be81314e2118/go.mod h1:STVpVboMYeBAfyn6Zw6XHhTHqUxzMy7pzRiVgk1l0W0=
|
||||
github.com/grafana/grafana/pkg/build v0.0.0-20250227105625-8f465f124924/go.mod h1:Vw0LdoMma64VgIMVpRY3i0D156jddgUGjTQBOcyeF3k=
|
||||
github.com/grafana/grafana/pkg/build v0.0.0-20250227163402-d78c646f93bb/go.mod h1:Vw0LdoMma64VgIMVpRY3i0D156jddgUGjTQBOcyeF3k=
|
||||
github.com/grafana/grafana/pkg/build v0.0.0-20250403075254-4918d8720c61/go.mod h1:LGVnSwdrS0ZnJ2WXEl5acgDoYPm74EUSFavca1NKHI8=
|
||||
github.com/grafana/grafana/pkg/build v0.0.0-20250625151647-35f89a456cc6/go.mod h1:dIu5dZy00k2TBdpVBXkvSbxHNj5H7lW/sOTpJTtKIXg=
|
||||
github.com/grafana/grafana/pkg/build v0.0.0-20250703134522-185ce90a4b20/go.mod h1:uU3TK/cKf6IdO7ou7CKgNNb8GEnW761Ecdb8tk8DQLg=
|
||||
github.com/grafana/grafana/pkg/build v0.0.0-20250703142147-cfd3b9f58211/go.mod h1:uU3TK/cKf6IdO7ou7CKgNNb8GEnW761Ecdb8tk8DQLg=
|
||||
github.com/grafana/grafana/pkg/build v0.0.0-20250703171819-20f30462ad64/go.mod h1:uU3TK/cKf6IdO7ou7CKgNNb8GEnW761Ecdb8tk8DQLg=
|
||||
github.com/grafana/grafana/pkg/semconv v0.0.0-20250121113133-e747350fee2d/go.mod h1:tfLnBpPYgwrBMRz4EXqPCZJyCjEG4Ev37FSlXnocJ2c=
|
||||
github.com/grafana/grafana/pkg/storage/unified/apistore v0.0.0-20250121113133-e747350fee2d/go.mod h1:CXpwZ3Mkw6xVlGKc0SqUxqXCP3Uv182q6qAQnLaLxRg=
|
||||
github.com/grafana/grafana/pkg/storage/unified/apistore v0.0.0-20250514132646-acbc7b54ed9e/go.mod h1:xrKQcxQxz+IUF90ybtfENFeEXtlj9nAsX/3Fw0KEIeQ=
|
||||
github.com/grafana/nanogit v0.0.0-20250616082354-5e94194d02ed h1:59JF1WhHLT+lNX89Tm1OzOEySMVMASAhaPbsRjtp8Kc=
|
||||
github.com/grafana/nanogit v0.0.0-20250616082354-5e94194d02ed/go.mod h1:OIAAKNgG5fpuJQRNO1lUSj9nc18Xl3O7M8fjIlBO1cI=
|
||||
github.com/grafana/nanogit v0.0.0-20250619160700-ebf70d342aa5 h1:MAQ2B0cu0V1S91ZjVa7NomNZFjaR2SmdtvdwhqBtyhU=
|
||||
github.com/grafana/nanogit v0.0.0-20250619160700-ebf70d342aa5/go.mod h1:tN93IZUaAmnSWgL0IgnKdLv6DNeIhTJGvl1wvQMrWco=
|
||||
github.com/grafana/prometheus-alertmanager v0.25.1-0.20240930132144-b5e64e81e8d3 h1:6D2gGAwyQBElSrp3E+9lSr7k8gLuP3Aiy20rweLWeBw=
|
||||
github.com/grafana/prometheus-alertmanager v0.25.1-0.20240930132144-b5e64e81e8d3/go.mod h1:YeND+6FDA7OuFgDzYODN8kfPhXLCehcpxe4T9mdnpCY=
|
||||
github.com/grafana/prometheus-alertmanager v0.25.1-0.20250331083058-4563aec7a975 h1:4/BZkGObFWZf4cLbE2Vqg/1VTz67Q0AJ7LHspWLKJoQ=
|
||||
github.com/grafana/prometheus-alertmanager v0.25.1-0.20250331083058-4563aec7a975/go.mod h1:FGdGvhI40Dq+CTQaSzK9evuve774cgOUdGfVO04OXkw=
|
||||
github.com/grafana/prometheus-alertmanager v0.25.1-0.20250604130045-92c8f6389b36 h1:AjZ58JRw1ZieFH/SdsddF5BXtsDKt5kSrKNPWrzYz3Y=
|
||||
github.com/grafana/prometheus-alertmanager v0.25.1-0.20250604130045-92c8f6389b36/go.mod h1:O/QP1BCm0HHIzbKvgMzqb5sSyH88rzkFk84F4TfJjBU=
|
||||
github.com/grafana/sqlds/v4 v4.2.3 h1:9ibD1c5O5u9fifEkBSig+jAc41TUEz+M+bWQqDsofP4=
|
||||
github.com/grafana/sqlds/v4 v4.2.3/go.mod h1:bv+XHabfUF4xkgg4y+nYFCK8rpMHZsMaQk56qNaJcAM=
|
||||
github.com/grafana/tail v0.0.0-20230510142333-77b18831edf0 h1:bjh0PVYSVVFxzINqPFYJmAmJNrWPgnVjuSdYJGHmtFU=
|
||||
github.com/grafana/tail v0.0.0-20230510142333-77b18831edf0/go.mod h1:7t5XR+2IA8P2qggOAHTj/GCZfoLBle3OvNSYh1VkRBU=
|
||||
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA=
|
||||
|
||||
+2
-2
@@ -289,8 +289,8 @@
|
||||
"@grafana/plugin-ui": "0.10.7",
|
||||
"@grafana/prometheus": "workspace:*",
|
||||
"@grafana/runtime": "workspace:*",
|
||||
"@grafana/scenes": "^6.26.1",
|
||||
"@grafana/scenes-react": "^6.26.1",
|
||||
"@grafana/scenes": "^6.27.0",
|
||||
"@grafana/scenes-react": "^6.27.0",
|
||||
"@grafana/schema": "workspace:*",
|
||||
"@grafana/sql": "workspace:*",
|
||||
"@grafana/ui": "workspace:*",
|
||||
|
||||
@@ -964,6 +964,10 @@ export interface TableFieldOptions {
|
||||
inspect: boolean;
|
||||
minWidth?: number;
|
||||
width?: number;
|
||||
/**
|
||||
* Enables text wrapping for the display name in the table header.
|
||||
*/
|
||||
wrapHeaderText?: boolean;
|
||||
}
|
||||
|
||||
export const defaultTableFieldOptions: Partial<TableFieldOptions> = {
|
||||
|
||||
@@ -105,5 +105,7 @@ TableFieldOptions: {
|
||||
filterable?: bool
|
||||
// Hides any header for a column, useful for columns that show some static content or buttons.
|
||||
hideHeader?: bool
|
||||
// Enables text wrapping for the display name in the table header.
|
||||
wrapHeaderText?: bool
|
||||
} @cuetsy(kind="interface")
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ export * from '../common/common.gen';
|
||||
export const defaultTableFieldOptions: raw.TableFieldOptions = {
|
||||
align: 'auto',
|
||||
inspect: false,
|
||||
wrapHeaderText: false,
|
||||
cellOptions: {
|
||||
type: raw.TableCellDisplayMode.Auto,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Column, SortDirection } from 'react-data-grid';
|
||||
|
||||
import { Field, GrafanaTheme2 } from '@grafana/data';
|
||||
@@ -34,9 +34,10 @@ const HeaderCell: React.FC<HeaderCellProps> = ({
|
||||
crossFilterRows,
|
||||
showTypeIcons,
|
||||
}) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const displayName = useMemo(() => getDisplayName(field), [field]);
|
||||
const filterable = useMemo(() => field.config.custom?.filterable ?? false, [field]);
|
||||
const headerCellWrap = field.config.custom?.wrapHeaderText ?? false;
|
||||
const styles = useStyles2(getStyles, headerCellWrap);
|
||||
const displayName = getDisplayName(field);
|
||||
const filterable = field.config.custom?.filterable ?? false;
|
||||
|
||||
// we have to remove/reset the filter if the column is not filterable
|
||||
useEffect(() => {
|
||||
@@ -50,15 +51,18 @@ const HeaderCell: React.FC<HeaderCellProps> = ({
|
||||
}, [filterable, displayName, filter, setFilter]);
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<>
|
||||
<span className={styles.headerCellLabel}>
|
||||
{showTypeIcons && <Icon name={getFieldTypeIcon(field)} title={field?.type} size="sm" />}
|
||||
{/* Used cached displayName if available, otherwise use the column name (nested tables) */}
|
||||
<div>{getDisplayName(field)}</div>
|
||||
{direction && (direction === 'ASC' ? <Icon name="arrow-up" size="lg" /> : <Icon name="arrow-down" size="lg" />)}
|
||||
</span>
|
||||
|
||||
{showTypeIcons && (
|
||||
<Icon className={styles.headerCellIcon} name={getFieldTypeIcon(field)} title={field?.type} size="sm" />
|
||||
)}
|
||||
<span className={styles.headerCellLabel}>{getDisplayName(field)}</span>
|
||||
{direction && (
|
||||
<Icon
|
||||
className={cx(styles.headerCellIcon, styles.headerSortIcon)}
|
||||
size="lg"
|
||||
name={direction === 'ASC' ? 'arrow-up' : 'arrow-down'}
|
||||
/>
|
||||
)}
|
||||
{filterable && (
|
||||
<Filter
|
||||
name={column.key}
|
||||
@@ -68,32 +72,34 @@ const HeaderCell: React.FC<HeaderCellProps> = ({
|
||||
field={field}
|
||||
crossFilterOrder={crossFilterOrder}
|
||||
crossFilterRows={crossFilterRows}
|
||||
iconClassName={styles.headerCellIcon}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
const getStyles = (theme: GrafanaTheme2, headerTextWrap?: boolean) => ({
|
||||
headerCellLabel: css({
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
background: 'inherit',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
color: theme.colors.text.secondary,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: theme.colors.text.secondary,
|
||||
gap: theme.spacing(1),
|
||||
|
||||
whiteSpace: headerTextWrap ? 'pre-line' : 'nowrap',
|
||||
'&:hover': {
|
||||
textDecoration: 'underline',
|
||||
color: theme.colors.text.link,
|
||||
},
|
||||
}),
|
||||
headerCellIcon: css({
|
||||
marginBottom: theme.spacing(0.5),
|
||||
alignSelf: 'flex-end',
|
||||
color: theme.colors.text.secondary,
|
||||
}),
|
||||
headerSortIcon: css({
|
||||
marginBottom: theme.spacing(0.25),
|
||||
}),
|
||||
});
|
||||
|
||||
export { HeaderCell };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import { Field, GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
|
||||
@@ -19,9 +19,19 @@ interface Props {
|
||||
field?: Field;
|
||||
crossFilterOrder: string[];
|
||||
crossFilterRows: { [key: string]: TableRow[] };
|
||||
iconClassName?: string;
|
||||
}
|
||||
|
||||
export const Filter = ({ name, rows, filter, setFilter, field, crossFilterOrder, crossFilterRows }: Props) => {
|
||||
export const Filter = ({
|
||||
name,
|
||||
rows,
|
||||
filter,
|
||||
setFilter,
|
||||
field,
|
||||
crossFilterOrder,
|
||||
crossFilterRows,
|
||||
iconClassName,
|
||||
}: Props) => {
|
||||
const filterValue = filter[name]?.filtered;
|
||||
|
||||
// get rows for cross filtering
|
||||
@@ -42,13 +52,13 @@ export const Filter = ({ name, rows, filter, setFilter, field, crossFilterOrder,
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const [isPopoverVisible, setPopoverVisible] = useState<boolean>(false);
|
||||
const styles = useStyles2(getStyles);
|
||||
const filterEnabled = useMemo(() => Boolean(filterValue), [filterValue]);
|
||||
const filterEnabled = Boolean(filterValue);
|
||||
const [searchFilter, setSearchFilter] = useState(filter[name]?.searchFilter || '');
|
||||
const [operator, setOperator] = useState<SelectableValue<string>>(filter[name]?.operator || REGEX_OPERATOR);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cx(styles.headerFilter, filterEnabled ? styles.filterIconEnabled : styles.filterIconDisabled)}
|
||||
className={styles.headerFilter}
|
||||
ref={ref}
|
||||
type="button"
|
||||
onClick={(ev) => {
|
||||
@@ -56,7 +66,7 @@ export const Filter = ({ name, rows, filter, setFilter, field, crossFilterOrder,
|
||||
ev.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Icon name="filter" />
|
||||
<Icon name="filter" className={cx(iconClassName, { [styles.filterIconEnabled]: filterEnabled })} />
|
||||
{isPopoverVisible && ref.current && (
|
||||
<Popover
|
||||
content={
|
||||
@@ -93,13 +103,10 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
border: 'none',
|
||||
label: 'headerFilter',
|
||||
padding: 0,
|
||||
alignSelf: 'flex-end',
|
||||
}),
|
||||
filterIconEnabled: css({
|
||||
label: 'filterIconEnabled',
|
||||
color: theme.colors.primary.text,
|
||||
}),
|
||||
filterIconDisabled: css({
|
||||
label: 'filterIconDisabled',
|
||||
color: theme.colors.text.disabled,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
|
||||
import { DataHoverClearEvent, DataHoverEvent, Field, FieldType, GrafanaTheme2, ReducerID } from '@grafana/data';
|
||||
import { t, Trans } from '@grafana/i18n';
|
||||
import { TableCellHeight } from '@grafana/schema';
|
||||
|
||||
import { useStyles2, useTheme2 } from '../../../themes/ThemeContext';
|
||||
import { ContextMenu } from '../../ContextMenu/ContextMenu';
|
||||
@@ -33,10 +34,11 @@ import {
|
||||
useColumnResize,
|
||||
useFilteredRows,
|
||||
useFooterCalcs,
|
||||
useHeaderHeight,
|
||||
usePaginatedRows,
|
||||
useRowHeight,
|
||||
useSortedRows,
|
||||
useTextWraps,
|
||||
useTypographyCtx,
|
||||
} from './hooks';
|
||||
import { TableNGProps, TableRow, TableSummaryRow, TableColumn, ContextMenuProps } from './types';
|
||||
import {
|
||||
@@ -48,11 +50,12 @@ import {
|
||||
getVisibleFields,
|
||||
shouldTextOverflow,
|
||||
getApplyToRowBgFn,
|
||||
getColumnTypes,
|
||||
computeColWidths,
|
||||
applySort,
|
||||
getCellColors,
|
||||
getCellOptions,
|
||||
shouldTextWrap,
|
||||
isCellInspectEnabled,
|
||||
} from './utils';
|
||||
|
||||
type CellRootRenderer = (key: React.Key, props: CellRendererProps<TableRow, TableSummaryRow>) => React.ReactNode;
|
||||
@@ -116,7 +119,6 @@ export function TableNG(props: TableNGProps) {
|
||||
}, [isContextMenuOpen]);
|
||||
|
||||
const rows = useMemo(() => frameToRecords(data), [data]);
|
||||
const columnTypes = useMemo(() => getColumnTypes(data.fields), [data.fields]);
|
||||
const hasNestedFrames = useMemo(() => getIsNestedTable(data.fields), [data]);
|
||||
|
||||
const {
|
||||
@@ -131,9 +133,10 @@ export function TableNG(props: TableNGProps) {
|
||||
rows: sortedRows,
|
||||
sortColumns,
|
||||
setSortColumns,
|
||||
} = useSortedRows(filteredRows, data.fields, { columnTypes, hasNestedFrames, initialSortBy });
|
||||
} = useSortedRows(filteredRows, data.fields, { hasNestedFrames, initialSortBy });
|
||||
|
||||
const defaultRowHeight = useMemo(() => getDefaultRowHeight(theme, cellHeight), [theme, cellHeight]);
|
||||
const defaultRowHeight = getDefaultRowHeight(theme, cellHeight);
|
||||
const defaultHeaderHeight = getDefaultRowHeight(theme, TableCellHeight.Sm);
|
||||
const [isInspecting, setIsInspecting] = useState(false);
|
||||
const [expandedRows, setExpandedRows] = useState<Record<string, boolean>>({});
|
||||
|
||||
@@ -143,8 +146,26 @@ export function TableNG(props: TableNGProps) {
|
||||
() => (hasNestedFrames ? width - COLUMN.EXPANDER_WIDTH : width),
|
||||
[width, hasNestedFrames]
|
||||
);
|
||||
const typographyCtx = useTypographyCtx();
|
||||
const widths = useMemo(() => computeColWidths(visibleFields, availableWidth), [visibleFields, availableWidth]);
|
||||
const rowHeight = useRowHeight(widths, visibleFields, hasNestedFrames, defaultRowHeight, expandedRows);
|
||||
const headerHeight = useHeaderHeight({
|
||||
columnWidths: widths,
|
||||
fields: visibleFields,
|
||||
enabled: hasHeader,
|
||||
defaultHeight: defaultHeaderHeight,
|
||||
sortColumns,
|
||||
showTypeIcons: showTypeIcons ?? false,
|
||||
typographyCtx,
|
||||
});
|
||||
const rowHeight = useRowHeight({
|
||||
columnWidths: widths,
|
||||
fields: visibleFields,
|
||||
hasNestedFrames,
|
||||
defaultHeight: defaultRowHeight,
|
||||
headerHeight,
|
||||
expandedRows,
|
||||
typographyCtx,
|
||||
});
|
||||
|
||||
const {
|
||||
rows: paginatedRows,
|
||||
@@ -158,13 +179,12 @@ export function TableNG(props: TableNGProps) {
|
||||
enabled: enablePagination,
|
||||
width: availableWidth,
|
||||
height,
|
||||
hasHeader,
|
||||
hasFooter,
|
||||
headerHeight,
|
||||
footerHeight: hasFooter ? defaultRowHeight : 0,
|
||||
rowHeight,
|
||||
});
|
||||
|
||||
// Create a map of column key to text wrap
|
||||
const textWraps = useTextWraps(data.fields);
|
||||
const footerCalcs = useFooterCalcs(sortedRows, data.fields, { enabled: hasFooter, footerOptions, isCountRowsSet });
|
||||
const applyToRowBgFn = useMemo(() => getApplyToRowBgFn(data.fields, theme) ?? undefined, [data.fields, theme]);
|
||||
|
||||
@@ -217,16 +237,16 @@ export function TableNG(props: TableNGProps) {
|
||||
sortColumns,
|
||||
rowHeight,
|
||||
headerRowClass: styles.headerRow,
|
||||
headerRowHeight: noHeader ? 0 : TABLE.HEADER_ROW_HEIGHT,
|
||||
headerRowHeight: headerHeight,
|
||||
bottomSummaryRows: hasFooter ? [{}] : undefined,
|
||||
}) satisfies Partial<DataGridProps<TableRow, TableSummaryRow>>,
|
||||
[
|
||||
enableVirtualization,
|
||||
resizeHandler,
|
||||
sortColumns,
|
||||
rowHeight,
|
||||
headerHeight,
|
||||
styles.headerRow,
|
||||
noHeader,
|
||||
rowHeight,
|
||||
hasFooter,
|
||||
setSortColumns,
|
||||
onSortByChange,
|
||||
@@ -253,7 +273,7 @@ export function TableNG(props: TableNGProps) {
|
||||
const cellOptions = getCellOptions(field);
|
||||
const renderFieldCell = getCellRenderer(field, cellOptions);
|
||||
|
||||
const cellInspect = Boolean(field.config.custom?.inspect);
|
||||
const cellInspect = isCellInspectEnabled(field);
|
||||
const showFilters = Boolean(field.config.filterable && onCellFilterAdded != null);
|
||||
const showActions = cellInspect || showFilters;
|
||||
const width = widths[i];
|
||||
@@ -269,9 +289,8 @@ export function TableNG(props: TableNGProps) {
|
||||
: undefined;
|
||||
|
||||
const cellType = cellOptions.type;
|
||||
const fieldType = columnTypes[displayName];
|
||||
const shouldWrap = textWraps[displayName];
|
||||
const shouldOverflow = shouldTextOverflow(fieldType, cellType, shouldWrap, cellInspect);
|
||||
const shouldOverflow = shouldTextOverflow(field);
|
||||
const shouldWrap = shouldTextWrap(field);
|
||||
|
||||
let lastRowIdx = -1;
|
||||
let _rowHeight = 0;
|
||||
@@ -327,7 +346,7 @@ export function TableNG(props: TableNGProps) {
|
||||
cellOptions,
|
||||
frame,
|
||||
field,
|
||||
height,
|
||||
height: _rowHeight,
|
||||
justifyContent,
|
||||
rowIdx,
|
||||
theme,
|
||||
@@ -361,7 +380,7 @@ export function TableNG(props: TableNGProps) {
|
||||
width,
|
||||
headerCellClass,
|
||||
renderCell: renderCellContent,
|
||||
renderHeaderCell: ({ column, sortDirection }): JSX.Element => (
|
||||
renderHeaderCell: ({ column, sortDirection }) => (
|
||||
<HeaderCell
|
||||
column={column}
|
||||
rows={rows}
|
||||
@@ -474,6 +493,7 @@ export function TableNG(props: TableNGProps) {
|
||||
|
||||
return result;
|
||||
}, [
|
||||
applyToRowBgFn,
|
||||
availableWidth,
|
||||
commonDataGridProps,
|
||||
crossFilterOrder,
|
||||
@@ -490,8 +510,8 @@ export function TableNG(props: TableNGProps) {
|
||||
onCellFilterAdded,
|
||||
panelContext,
|
||||
replaceVariables,
|
||||
rows,
|
||||
rowHeight,
|
||||
rows,
|
||||
setFilter,
|
||||
showTypeIcons,
|
||||
sortColumns,
|
||||
@@ -499,10 +519,6 @@ export function TableNG(props: TableNGProps) {
|
||||
theme,
|
||||
visibleFields,
|
||||
widths,
|
||||
applyToRowBgFn,
|
||||
columnTypes,
|
||||
height,
|
||||
textWraps,
|
||||
]);
|
||||
|
||||
// invalidate columns on every structureRev change. this supports width editing in the fieldConfig.
|
||||
@@ -698,7 +714,11 @@ const getGridStyles = (
|
||||
headerRow: css({
|
||||
paddingBlockStart: 0,
|
||||
fontWeight: 'normal',
|
||||
...(noHeader && { display: 'none' }),
|
||||
...(noHeader ? { display: 'none' } : {}),
|
||||
'& .rdg-cell': {
|
||||
height: '100%',
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
}),
|
||||
paginationContainer: css({
|
||||
alignItems: 'center',
|
||||
@@ -736,9 +756,11 @@ const getHeaderCellStyles = (theme: GrafanaTheme2, justifyContent: Property.Just
|
||||
gap: theme.spacing(0.5),
|
||||
zIndex: theme.zIndex.tooltip - 1,
|
||||
paddingInline: TABLE.CELL_PADDING,
|
||||
paddingBlock: TABLE.CELL_PADDING,
|
||||
borderInlineEnd: 'none',
|
||||
paddingBlockEnd: TABLE.CELL_PADDING,
|
||||
justifyContent,
|
||||
'&:last-child': {
|
||||
borderInlineEnd: 'none',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -9,9 +9,10 @@ export const COLUMN = {
|
||||
/** Table layout and display constants */
|
||||
export const TABLE = {
|
||||
CELL_PADDING: 6,
|
||||
HEADER_ROW_HEIGHT: 28,
|
||||
MAX_CELL_HEIGHT: 48,
|
||||
PAGINATION_LIMIT: 750,
|
||||
SCROLL_BAR_WIDTH: 8,
|
||||
SCROLL_BAR_MARGIN: 2,
|
||||
LINE_HEIGHT: 22,
|
||||
BORDER_RIGHT: 0.666667,
|
||||
};
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { varPreLine } from 'uwrap';
|
||||
|
||||
import { Field, FieldType } from '@grafana/data';
|
||||
|
||||
import { useFilteredRows, usePaginatedRows, useSortedRows, useFooterCalcs } from './hooks';
|
||||
import { getColumnTypes } from './utils';
|
||||
import {
|
||||
useFilteredRows,
|
||||
usePaginatedRows,
|
||||
useSortedRows,
|
||||
useFooterCalcs,
|
||||
useHeaderHeight,
|
||||
useTypographyCtx,
|
||||
} from './hooks';
|
||||
|
||||
jest.mock('uwrap', () => ({
|
||||
// ...jest.requireActual('uwrap'),
|
||||
varPreLine: jest.fn(() => ({
|
||||
count: jest.fn(() => 1),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('TableNG hooks', () => {
|
||||
function setupData() {
|
||||
@@ -87,10 +101,8 @@ describe('TableNG hooks', () => {
|
||||
describe('useSortedRows', () => {
|
||||
it('should correctly set up the table with an initial sort', () => {
|
||||
const { fields, rows } = setupData();
|
||||
const columnTypes = getColumnTypes(fields);
|
||||
const { result } = renderHook(() =>
|
||||
useSortedRows(rows, fields, {
|
||||
columnTypes,
|
||||
hasNestedFrames: false,
|
||||
initialSortBy: [{ displayName: 'age', desc: false }],
|
||||
})
|
||||
@@ -103,10 +115,8 @@ describe('TableNG hooks', () => {
|
||||
|
||||
it('should change the sort on setSortColumns', () => {
|
||||
const { fields, rows } = setupData();
|
||||
const columnTypes = getColumnTypes(fields);
|
||||
const { result } = renderHook(() =>
|
||||
useSortedRows(rows, fields, {
|
||||
columnTypes,
|
||||
hasNestedFrames: false,
|
||||
initialSortBy: [{ displayName: 'age', desc: false }],
|
||||
})
|
||||
@@ -134,7 +144,14 @@ describe('TableNG hooks', () => {
|
||||
it('should return defaults for pagination values when pagination is disabled', () => {
|
||||
const { rows } = setupData();
|
||||
const { result } = renderHook(() =>
|
||||
usePaginatedRows(rows, { rowHeight: 30, height: 300, width: 800, enabled: false })
|
||||
usePaginatedRows(rows, {
|
||||
rowHeight: 30,
|
||||
height: 300,
|
||||
width: 800,
|
||||
enabled: false,
|
||||
headerHeight: 28,
|
||||
footerHeight: 0,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.current.page).toBe(-1);
|
||||
@@ -153,6 +170,39 @@ describe('TableNG hooks', () => {
|
||||
height: 60,
|
||||
width: 800,
|
||||
rowHeight: 10,
|
||||
headerHeight: 0,
|
||||
footerHeight: 0,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.current.page).toBe(0);
|
||||
expect(result.current.rowsPerPage).toBe(2);
|
||||
expect(result.current.pageRangeStart).toBe(1);
|
||||
expect(result.current.pageRangeEnd).toBe(2);
|
||||
expect(result.current.rows.length).toBe(2);
|
||||
|
||||
act(() => {
|
||||
result.current.setPage(1);
|
||||
});
|
||||
|
||||
expect(result.current.page).toBe(1);
|
||||
expect(result.current.rowsPerPage).toBe(2);
|
||||
expect(result.current.pageRangeStart).toBe(3);
|
||||
expect(result.current.pageRangeEnd).toBe(3);
|
||||
expect(result.current.rows.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle header and footer correctly', () => {
|
||||
// with the numbers provided here, we have 3 rows, with 2 rows per page, over 2 pages total.
|
||||
const { rows } = setupData();
|
||||
const { result } = renderHook(() =>
|
||||
usePaginatedRows(rows, {
|
||||
enabled: true,
|
||||
height: 140,
|
||||
width: 800,
|
||||
rowHeight: 10,
|
||||
headerHeight: 28,
|
||||
footerHeight: 45,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -332,4 +382,154 @@ describe('TableNG hooks', () => {
|
||||
expect(result.current).toEqual(['Total', '6', '13']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useHeaderHeight', () => {
|
||||
it('should return 0 when no header is present', () => {
|
||||
const { fields } = setupData();
|
||||
const { result } = renderHook(() => {
|
||||
const typographyCtx = useTypographyCtx();
|
||||
return useHeaderHeight({
|
||||
fields,
|
||||
columnWidths: [],
|
||||
enabled: false,
|
||||
typographyCtx,
|
||||
defaultHeight: 28,
|
||||
sortColumns: [],
|
||||
});
|
||||
});
|
||||
expect(result.current).toBe(0);
|
||||
});
|
||||
|
||||
it('should return the default height when wrap is disabled', () => {
|
||||
const { fields } = setupData();
|
||||
const { result } = renderHook(() => {
|
||||
const typographyCtx = useTypographyCtx();
|
||||
return useHeaderHeight({
|
||||
fields,
|
||||
columnWidths: [],
|
||||
enabled: true,
|
||||
typographyCtx,
|
||||
defaultHeight: 28,
|
||||
sortColumns: [],
|
||||
});
|
||||
});
|
||||
expect(result.current).toBe(22);
|
||||
});
|
||||
|
||||
it('should return the appropriate height for wrapped text', () => {
|
||||
// Simulate 2 lines of text
|
||||
jest.mocked(varPreLine).mockReturnValue({
|
||||
count: jest.fn(() => 2),
|
||||
each: jest.fn(),
|
||||
split: jest.fn(),
|
||||
test: jest.fn(),
|
||||
});
|
||||
|
||||
const { fields } = setupData();
|
||||
const { result } = renderHook(() => {
|
||||
const typographyCtx = useTypographyCtx();
|
||||
return useHeaderHeight({
|
||||
fields: fields.map((field) => {
|
||||
if (field.name === 'name') {
|
||||
return {
|
||||
...field,
|
||||
name: 'Longer name that needs wrapping',
|
||||
config: {
|
||||
...field.config,
|
||||
custom: {
|
||||
...field.config?.custom,
|
||||
wrapHeaderText: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return field;
|
||||
}),
|
||||
columnWidths: [100, 100, 100],
|
||||
enabled: true,
|
||||
typographyCtx: { ...typographyCtx, avgCharWidth: 5 },
|
||||
defaultHeight: 28,
|
||||
sortColumns: [],
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current).toBe(50);
|
||||
});
|
||||
|
||||
it('should calculate the available width for a header cell based on the icons rendered within it', () => {
|
||||
const countFn = jest.fn(() => 1);
|
||||
|
||||
// Simulate 2 lines of text
|
||||
jest.mocked(varPreLine).mockReturnValue({
|
||||
count: countFn,
|
||||
each: jest.fn(),
|
||||
split: jest.fn(),
|
||||
test: jest.fn(),
|
||||
});
|
||||
|
||||
const { fields } = setupData();
|
||||
|
||||
renderHook(() => {
|
||||
const typographyCtx = useTypographyCtx();
|
||||
|
||||
return useHeaderHeight({
|
||||
fields: fields.map((field) => {
|
||||
if (field.name === 'name') {
|
||||
return {
|
||||
...field,
|
||||
name: 'Longer name that needs wrapping',
|
||||
config: {
|
||||
...field.config,
|
||||
custom: {
|
||||
...field.config?.custom,
|
||||
wrapHeaderText: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return field;
|
||||
}),
|
||||
columnWidths: [100, 100, 100],
|
||||
enabled: true,
|
||||
typographyCtx: { ...typographyCtx, avgCharWidth: 10 },
|
||||
defaultHeight: 28,
|
||||
sortColumns: [],
|
||||
showTypeIcons: false,
|
||||
});
|
||||
});
|
||||
|
||||
expect(countFn).toHaveBeenCalledWith('Longer name that needs wrapping', 87);
|
||||
|
||||
renderHook(() => {
|
||||
const typographyCtx = useTypographyCtx();
|
||||
return useHeaderHeight({
|
||||
fields: fields.map((field) => {
|
||||
if (field.name === 'name') {
|
||||
return {
|
||||
...field,
|
||||
name: 'Longer name that needs wrapping',
|
||||
config: {
|
||||
...field.config,
|
||||
custom: {
|
||||
...field.config?.custom,
|
||||
filterable: true,
|
||||
wrapHeaderText: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return field;
|
||||
}),
|
||||
columnWidths: [100, 100, 100],
|
||||
enabled: true,
|
||||
typographyCtx: { ...typographyCtx, avgCharWidth: 10 },
|
||||
defaultHeight: 28,
|
||||
sortColumns: [{ columnKey: 'Longer name that needs wrapping', direction: 'ASC' }],
|
||||
showTypeIcons: true,
|
||||
});
|
||||
});
|
||||
|
||||
expect(countFn).toHaveBeenCalledWith('Longer name that needs wrapping', 27);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useMemo, useEffect, useCallback, useRef, useLayoutEffect } from 'react';
|
||||
import { Column, DataGridProps, SortColumn } from 'react-data-grid';
|
||||
import { varPreLine } from 'uwrap';
|
||||
|
||||
import { Field, fieldReducers, FieldType, formattedValueToString, reduceField } from '@grafana/data';
|
||||
|
||||
@@ -7,8 +8,16 @@ import { useTheme2 } from '../../../themes/ThemeContext';
|
||||
import { TableCellDisplayMode, TableColumnResizeActionCallback } from '../types';
|
||||
|
||||
import { TABLE } from './constants';
|
||||
import { ColumnTypes, FilterType, TableFooterCalc, TableRow, TableSortByFieldState, TableSummaryRow } from './types';
|
||||
import { getDisplayName, processNestedTableRows, getCellHeightCalculator, applySort, getCellOptions } from './utils';
|
||||
import { FilterType, TableFooterCalc, TableRow, TableSortByFieldState, TableSummaryRow } from './types';
|
||||
import {
|
||||
getDisplayName,
|
||||
processNestedTableRows,
|
||||
applySort,
|
||||
getCellOptions,
|
||||
getColumnTypes,
|
||||
GetMaxWrapCellOptions,
|
||||
getMaxWrapCell,
|
||||
} from './utils';
|
||||
|
||||
// Helper function to get displayed value
|
||||
const getDisplayedValue = (row: TableRow, key: string, fields: Field[]) => {
|
||||
@@ -79,7 +88,6 @@ export function useFilteredRows(
|
||||
}
|
||||
|
||||
export interface SortedRowsOptions {
|
||||
columnTypes: ColumnTypes;
|
||||
hasNestedFrames: boolean;
|
||||
initialSortBy?: TableSortByFieldState[];
|
||||
}
|
||||
@@ -93,7 +101,7 @@ export interface SortedRowsResult {
|
||||
export function useSortedRows(
|
||||
rows: TableRow[],
|
||||
fields: Field[],
|
||||
{ initialSortBy, columnTypes, hasNestedFrames }: SortedRowsOptions
|
||||
{ initialSortBy, hasNestedFrames }: SortedRowsOptions
|
||||
): SortedRowsResult {
|
||||
const initialSortColumns = useMemo<SortColumn[]>(
|
||||
() =>
|
||||
@@ -111,6 +119,7 @@ export function useSortedRows(
|
||||
[] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
);
|
||||
const [sortColumns, setSortColumns] = useState<SortColumn[]>(initialSortColumns);
|
||||
const columnTypes = useMemo(() => getColumnTypes(fields), [fields]);
|
||||
|
||||
const sortedRows = useMemo(
|
||||
() => applySort(rows, fields, sortColumns, columnTypes, hasNestedFrames),
|
||||
@@ -128,8 +137,8 @@ export interface PaginatedRowsOptions {
|
||||
height: number;
|
||||
width: number;
|
||||
rowHeight: number | ((row: TableRow) => number);
|
||||
hasHeader?: boolean;
|
||||
hasFooter?: boolean;
|
||||
headerHeight: number;
|
||||
footerHeight: number;
|
||||
paginationHeight?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
@@ -150,7 +159,7 @@ const PAGINATION_HEIGHT = 38;
|
||||
|
||||
export function usePaginatedRows(
|
||||
rows: TableRow[],
|
||||
{ height, width, hasHeader, hasFooter, rowHeight, enabled }: PaginatedRowsOptions
|
||||
{ height, width, headerHeight, footerHeight, rowHeight, enabled }: PaginatedRowsOptions
|
||||
): PaginatedRowsResult {
|
||||
// TODO: allow persisted page selection via url
|
||||
const [page, setPage] = useState(0);
|
||||
@@ -177,8 +186,7 @@ export function usePaginatedRows(
|
||||
}
|
||||
|
||||
// calculate number of rowsPerPage based on height stack
|
||||
const rowAreaHeight =
|
||||
height - (hasHeader ? TABLE.HEADER_ROW_HEIGHT : 0) - (hasFooter ? avgRowHeight : 0) - PAGINATION_HEIGHT;
|
||||
const rowAreaHeight = height - headerHeight - footerHeight - PAGINATION_HEIGHT;
|
||||
const heightPerRow = Math.floor(rowAreaHeight / (avgRowHeight || 1));
|
||||
// ensure at least one row per page is displayed
|
||||
let rowsPerPage = heightPerRow > 1 ? heightPerRow : 1;
|
||||
@@ -198,7 +206,7 @@ export function usePaginatedRows(
|
||||
pageRangeEnd,
|
||||
smallPagination,
|
||||
};
|
||||
}, [width, height, hasHeader, hasFooter, avgRowHeight, enabled, numRows, page]);
|
||||
}, [width, height, headerHeight, footerHeight, avgRowHeight, enabled, numRows, page]);
|
||||
|
||||
// safeguard against page overflow on panel resize or other factors
|
||||
useEffect(() => {
|
||||
@@ -294,22 +302,16 @@ export function useFooterCalcs(
|
||||
}, [fields, enabled, footerOptions, isCountRowsSet, rows]);
|
||||
}
|
||||
|
||||
export function useTextWraps(fields: Field[]): Record<string, boolean> {
|
||||
return useMemo(
|
||||
() =>
|
||||
fields.reduce<{ [key: string]: boolean }>((acc, field) => {
|
||||
const cellOptions = getCellOptions(field);
|
||||
const displayName = getDisplayName(field);
|
||||
const wrapText = 'wrapText' in cellOptions && cellOptions.wrapText;
|
||||
return { ...acc, [displayName]: !!wrapText };
|
||||
}, {}),
|
||||
[fields]
|
||||
);
|
||||
interface TypographyCtx {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
font: string;
|
||||
avgCharWidth: number;
|
||||
calcRowHeight: (text: string, cellWidth: number, defaultHeight: number) => number;
|
||||
}
|
||||
|
||||
export function useTypographyCtx() {
|
||||
export function useTypographyCtx(): TypographyCtx {
|
||||
const theme = useTheme2();
|
||||
const { ctx, font, avgCharWidth } = useMemo(() => {
|
||||
const typographyCtx = useMemo((): TypographyCtx => {
|
||||
const font = `${theme.typography.fontSize}px ${theme.typography.fontFamily}`;
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
@@ -322,23 +324,128 @@ export function useTypographyCtx() {
|
||||
"Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s";
|
||||
const txtWidth = ctx.measureText(txt).width;
|
||||
const avgCharWidth = txtWidth / txt.length + letterSpacing;
|
||||
const { count } = varPreLine(ctx);
|
||||
const calcRowHeight = (text: string, cellWidth: number, defaultHeight: number) => {
|
||||
if (text === '') {
|
||||
return defaultHeight;
|
||||
}
|
||||
const numLines = count(text, cellWidth);
|
||||
const totalHeight = numLines * TABLE.LINE_HEIGHT + 2 * TABLE.CELL_PADDING;
|
||||
return Math.max(totalHeight, defaultHeight);
|
||||
};
|
||||
|
||||
return {
|
||||
calcRowHeight,
|
||||
ctx,
|
||||
font,
|
||||
avgCharWidth,
|
||||
};
|
||||
}, [theme.typography.fontSize, theme.typography.fontFamily]);
|
||||
return { ctx, font, avgCharWidth };
|
||||
return typographyCtx;
|
||||
}
|
||||
|
||||
export function useRowHeight(
|
||||
columnWidths: number[],
|
||||
fields: Field[],
|
||||
hasNestedFrames: boolean,
|
||||
defaultRowHeight: number,
|
||||
expandedRows: Record<string, boolean>
|
||||
): number | ((row: TableRow) => number) {
|
||||
const ICON_WIDTH = 16;
|
||||
const ICON_GAP = 4;
|
||||
|
||||
interface UseHeaderHeightOptions {
|
||||
enabled: boolean;
|
||||
fields: Field[];
|
||||
columnWidths: number[];
|
||||
defaultHeight: number;
|
||||
sortColumns: SortColumn[];
|
||||
typographyCtx: TypographyCtx;
|
||||
showTypeIcons?: boolean;
|
||||
}
|
||||
|
||||
export function useHeaderHeight({
|
||||
fields,
|
||||
enabled,
|
||||
columnWidths,
|
||||
defaultHeight,
|
||||
sortColumns,
|
||||
typographyCtx: { calcRowHeight, avgCharWidth },
|
||||
showTypeIcons = false,
|
||||
}: UseHeaderHeightOptions): number {
|
||||
const perIconSpace = ICON_WIDTH + ICON_GAP;
|
||||
const columnAvailableWidths = useMemo(
|
||||
() =>
|
||||
columnWidths.map((c, idx) => {
|
||||
let width = c - 2 * TABLE.CELL_PADDING - TABLE.BORDER_RIGHT;
|
||||
// filtering icon
|
||||
if (fields[idx]?.config?.custom?.filterable) {
|
||||
width -= perIconSpace;
|
||||
}
|
||||
// sorting icon
|
||||
if (sortColumns.some((col) => col.columnKey === getDisplayName(fields[idx]))) {
|
||||
width -= perIconSpace;
|
||||
}
|
||||
// type icon
|
||||
if (showTypeIcons) {
|
||||
width -= perIconSpace;
|
||||
}
|
||||
return Math.floor(width);
|
||||
}),
|
||||
[fields, columnWidths, sortColumns, showTypeIcons, perIconSpace]
|
||||
);
|
||||
|
||||
const [wrappedColHeaderIdxs, hasWrappedColHeaders] = useMemo(() => {
|
||||
let hasWrappedColHeaders = false;
|
||||
return [
|
||||
fields.map((field) => {
|
||||
const wrapText = field.config?.custom?.wrapHeaderText ?? false;
|
||||
if (wrapText) {
|
||||
hasWrappedColHeaders = true;
|
||||
}
|
||||
return wrapText;
|
||||
}),
|
||||
hasWrappedColHeaders,
|
||||
];
|
||||
}, [fields]);
|
||||
|
||||
const maxWrapCellOptions = useMemo<GetMaxWrapCellOptions>(
|
||||
() => ({
|
||||
colWidths: columnAvailableWidths,
|
||||
avgCharWidth,
|
||||
wrappedColIdxs: wrappedColHeaderIdxs,
|
||||
}),
|
||||
[columnAvailableWidths, avgCharWidth, wrappedColHeaderIdxs]
|
||||
);
|
||||
|
||||
// TODO: is there a less clunky way to subtract the top padding value?
|
||||
const headerHeight = useMemo(() => {
|
||||
if (!enabled) {
|
||||
return 0;
|
||||
}
|
||||
if (!hasWrappedColHeaders) {
|
||||
return defaultHeight - TABLE.CELL_PADDING;
|
||||
}
|
||||
|
||||
const { text: maxLinesText, idx: maxLinesIdx } = getMaxWrapCell(fields, -1, maxWrapCellOptions);
|
||||
return calcRowHeight(maxLinesText, columnAvailableWidths[maxLinesIdx], defaultHeight) - TABLE.CELL_PADDING;
|
||||
}, [fields, enabled, hasWrappedColHeaders, maxWrapCellOptions, calcRowHeight, columnAvailableWidths, defaultHeight]);
|
||||
|
||||
return headerHeight;
|
||||
}
|
||||
|
||||
interface UseRowHeightOptions {
|
||||
columnWidths: number[];
|
||||
fields: Field[];
|
||||
hasNestedFrames: boolean;
|
||||
defaultHeight: number;
|
||||
headerHeight: number;
|
||||
expandedRows: Record<string, boolean>;
|
||||
typographyCtx: TypographyCtx;
|
||||
}
|
||||
|
||||
export function useRowHeight({
|
||||
columnWidths,
|
||||
fields,
|
||||
hasNestedFrames,
|
||||
defaultHeight,
|
||||
headerHeight,
|
||||
expandedRows,
|
||||
typographyCtx: { calcRowHeight, avgCharWidth },
|
||||
}: UseRowHeightOptions): number | ((row: TableRow) => number) {
|
||||
const [wrappedColIdxs, hasWrappedCols] = useMemo(() => {
|
||||
let hasWrappedCols = false;
|
||||
return [
|
||||
@@ -360,22 +467,26 @@ export function useRowHeight(
|
||||
];
|
||||
}, [fields]);
|
||||
|
||||
const { ctx, avgCharWidth } = useTypographyCtx();
|
||||
const colWidths = useMemo(
|
||||
() => columnWidths.map((c) => c - 2 * TABLE.CELL_PADDING - TABLE.BORDER_RIGHT),
|
||||
[columnWidths]
|
||||
);
|
||||
|
||||
const maxWrapCellOptions = useMemo<GetMaxWrapCellOptions>(
|
||||
() => ({
|
||||
colWidths,
|
||||
avgCharWidth,
|
||||
wrappedColIdxs,
|
||||
}),
|
||||
[colWidths, avgCharWidth, wrappedColIdxs]
|
||||
);
|
||||
|
||||
const rowHeight = useMemo(() => {
|
||||
// row height is only complicated when there are nested frames or wrapped columns.
|
||||
if (!hasNestedFrames && !hasWrappedCols) {
|
||||
return defaultRowHeight;
|
||||
return defaultHeight;
|
||||
}
|
||||
|
||||
const HPADDING = TABLE.CELL_PADDING;
|
||||
const VPADDING = TABLE.CELL_PADDING;
|
||||
const BORDER_RIGHT = 0.666667;
|
||||
const LINE_HEIGHT = 22;
|
||||
|
||||
const wrapWidths = columnWidths.map((c) => c - 2 * HPADDING - BORDER_RIGHT);
|
||||
const calc = getCellHeightCalculator(ctx, LINE_HEIGHT, defaultRowHeight, VPADDING);
|
||||
|
||||
return (row: TableRow) => {
|
||||
// nested rows
|
||||
if (Number(row.__depth) > 0) {
|
||||
@@ -384,50 +495,25 @@ export function useRowHeight(
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Ensure we have a minimum height (defaultRowHeight) for the nested table even if data is empty
|
||||
const headerCount = row?.data?.meta?.custom?.noHeader ? 0 : 1;
|
||||
// Ensure we have a minimum height (defaultHeight) for the nested table even if data is empty
|
||||
const rowCount = row.data?.length ?? 0;
|
||||
return Math.max(defaultRowHeight, defaultRowHeight * (rowCount + headerCount));
|
||||
return Math.max(defaultHeight, defaultHeight * rowCount + headerHeight);
|
||||
}
|
||||
|
||||
// regular rows
|
||||
let maxLines = 1;
|
||||
let maxLinesIdx = -1;
|
||||
let maxLinesText = '';
|
||||
|
||||
for (let i = 0; i < columnWidths.length; i++) {
|
||||
if (wrappedColIdxs[i]) {
|
||||
const cellTextRaw = fields[i].values[row.__index];
|
||||
if (cellTextRaw != null) {
|
||||
const cellText = String(cellTextRaw);
|
||||
const charsPerLine = wrapWidths[i] / avgCharWidth;
|
||||
const approxLines = cellText.length / charsPerLine;
|
||||
|
||||
if (approxLines > maxLines) {
|
||||
maxLines = approxLines;
|
||||
maxLinesIdx = i;
|
||||
maxLinesText = cellText;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (maxLinesIdx === -1) {
|
||||
return defaultRowHeight;
|
||||
}
|
||||
|
||||
return calc(maxLinesText, wrapWidths[maxLinesIdx]);
|
||||
const { text: maxLinesText, idx: maxLinesIdx } = getMaxWrapCell(fields, row.__index, maxWrapCellOptions);
|
||||
return calcRowHeight(maxLinesText, colWidths[maxLinesIdx], defaultHeight);
|
||||
};
|
||||
}, [
|
||||
avgCharWidth,
|
||||
columnWidths,
|
||||
ctx,
|
||||
defaultRowHeight,
|
||||
calcRowHeight,
|
||||
defaultHeight,
|
||||
expandedRows,
|
||||
fields,
|
||||
hasNestedFrames,
|
||||
hasWrappedCols,
|
||||
wrappedColIdxs,
|
||||
headerHeight,
|
||||
maxWrapCellOptions,
|
||||
colWidths,
|
||||
]);
|
||||
|
||||
return rowHeight;
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
getTextAlign,
|
||||
migrateTableDisplayModeToCellOptions,
|
||||
getColumnTypes,
|
||||
getMaxWrapCell,
|
||||
} from './utils';
|
||||
|
||||
describe('TableNG utils', () => {
|
||||
@@ -974,11 +975,6 @@ describe('TableNG utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCellHeightCalculator', () => {
|
||||
it.todo('returns a cell height calculator');
|
||||
it.todo('returns a minimum height of the default row height');
|
||||
});
|
||||
|
||||
describe('getDefaultRowHeight', () => {
|
||||
const theme = createTheme();
|
||||
|
||||
@@ -1006,4 +1002,117 @@ describe('TableNG utils', () => {
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMaxWrapCell', () => {
|
||||
it('should return the maximum wrap cell length from field state', () => {
|
||||
const field1: Field = {
|
||||
name: 'field1',
|
||||
type: FieldType.string,
|
||||
config: {},
|
||||
values: ['beep boop', 'foo bar baz', 'lorem ipsum dolor sit amet'],
|
||||
};
|
||||
|
||||
const field2: Field = {
|
||||
name: 'field2',
|
||||
type: FieldType.string,
|
||||
config: {},
|
||||
values: ['asdfasdf asdfasdf asdfasdf', 'asdf asdf asdf asdf asdf', ''],
|
||||
};
|
||||
|
||||
const field3: Field = {
|
||||
name: 'field3',
|
||||
type: FieldType.string,
|
||||
config: {},
|
||||
values: ['foo', 'bar', 'baz'],
|
||||
// No alignmentFactors in state
|
||||
};
|
||||
|
||||
const fields = [field1, field2, field3];
|
||||
|
||||
const result = getMaxWrapCell(fields, 0, {
|
||||
colWidths: [30, 50, 100],
|
||||
avgCharWidth: 5,
|
||||
wrappedColIdxs: [true, true, true],
|
||||
});
|
||||
expect(result).toEqual({
|
||||
text: 'asdfasdf asdfasdf asdfasdf',
|
||||
idx: 1,
|
||||
numLines: 2.6,
|
||||
});
|
||||
});
|
||||
|
||||
it('should take colWidths into account when calculating max wrap cell', () => {
|
||||
const fields: Field[] = [
|
||||
{
|
||||
name: 'field',
|
||||
type: FieldType.string,
|
||||
config: {},
|
||||
values: ['short', 'a bit longer text'],
|
||||
},
|
||||
{
|
||||
name: 'field',
|
||||
type: FieldType.string,
|
||||
config: {},
|
||||
values: ['short', 'quite a bit longer text'],
|
||||
},
|
||||
{
|
||||
name: 'field',
|
||||
type: FieldType.string,
|
||||
config: {},
|
||||
values: ['short', 'less text'],
|
||||
},
|
||||
];
|
||||
|
||||
// Simulate a narrow column width that would cause wrapping
|
||||
const colWidths = [50, 1000, 30]; // 50px width
|
||||
const avgCharWidth = 5; // Assume average character width is 5px
|
||||
|
||||
const result = getMaxWrapCell(fields, 1, { colWidths, avgCharWidth, wrappedColIdxs: [true, true, true] });
|
||||
|
||||
// With a 50px width and 5px per character, we can fit 10 characters per line
|
||||
// "the longest text in this field" has 31 characters, so it should wrap to 4 lines
|
||||
expect(result).toEqual({
|
||||
idx: 0,
|
||||
numLines: 1.7,
|
||||
text: 'a bit longer text',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use the display name if the rowIdx is -1 (which is used to calc header height in wrapped rows)', () => {
|
||||
const fields: Field[] = [
|
||||
{
|
||||
name: 'Field with a very long name',
|
||||
type: FieldType.string,
|
||||
config: {},
|
||||
values: ['short', 'a bit longer text'],
|
||||
},
|
||||
{
|
||||
name: 'Name',
|
||||
type: FieldType.string,
|
||||
config: {},
|
||||
values: ['short', 'quite a bit longer text'],
|
||||
},
|
||||
{
|
||||
name: 'Another field',
|
||||
type: FieldType.string,
|
||||
config: {},
|
||||
values: ['short', 'less text'],
|
||||
},
|
||||
];
|
||||
|
||||
// Simulate a narrow column width that would cause wrapping
|
||||
const colWidths = [50, 1000, 30]; // 50px width
|
||||
const avgCharWidth = 5; // Assume average character width is 5px
|
||||
|
||||
const result = getMaxWrapCell(fields, -1, { colWidths, avgCharWidth, wrappedColIdxs: [true, true, true] });
|
||||
|
||||
// With a 50px width and 5px per character, we can fit 10 characters per line
|
||||
// "the longest text in this field" has 31 characters, so it should wrap to 4 lines
|
||||
expect(result).toEqual({ idx: 0, numLines: 2.7, text: 'Field with a very long name' });
|
||||
});
|
||||
|
||||
it.todo('should ignore columns which are not wrapped');
|
||||
|
||||
it.todo('should only apply wrapping on idiomatic break characters (space, -, etc)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Property } from 'csstype';
|
||||
import { SortColumn } from 'react-data-grid';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { varPreLine } from 'uwrap';
|
||||
|
||||
import {
|
||||
FieldType,
|
||||
@@ -29,26 +28,6 @@ import { CellColors, TableRow, TableFieldOptionsType, ColumnTypes, FrameToRowsCo
|
||||
/* ---------------------------- Cell calculations --------------------------- */
|
||||
export type CellHeightCalculator = (text: string, cellWidth: number) => number;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* Returns a function that calculates the height of a cell based on its text content and width.
|
||||
*/
|
||||
export function getCellHeightCalculator(
|
||||
// should be pre-configured with font and letterSpacing
|
||||
ctx: CanvasRenderingContext2D,
|
||||
lineHeight: number,
|
||||
defaultRowHeight: number,
|
||||
padding = 0
|
||||
) {
|
||||
const { count } = varPreLine(ctx);
|
||||
|
||||
return (text: string, cellWidth: number) => {
|
||||
const numLines = count(text, cellWidth);
|
||||
const totalHeight = numLines * lineHeight + 2 * padding;
|
||||
return Math.max(totalHeight, defaultRowHeight);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* Returns the default row height based on the theme and cell height setting.
|
||||
@@ -69,19 +48,94 @@ export function getDefaultRowHeight(theme: GrafanaTheme2, cellHeight?: TableCell
|
||||
return TABLE.CELL_PADDING * 2 + bodyFontSize * lineHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* Returns true if cell inspection (hover to see full content) is enabled for the field.
|
||||
*/
|
||||
export function isCellInspectEnabled(field: Field): boolean {
|
||||
return field.config?.custom?.inspect ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* Returns true if text wrapping should be applied to the cell.
|
||||
*/
|
||||
export function shouldTextWrap(field: Field): boolean {
|
||||
const cellOptions = getCellOptions(field);
|
||||
// @ts-ignore - a handful of cellTypes have boolean wrapText, but not all of them.
|
||||
// we should be very careful to only use boolean type for cellOptions.wrapText.
|
||||
// TBH we will probably move this up to a field option which is showIf rendered anyway,
|
||||
// but that'll be a migration to do, so it needs to happen post-GA.
|
||||
return Boolean(cellOptions?.wrapText);
|
||||
}
|
||||
|
||||
// matches characters which CSS
|
||||
const spaceRegex = /[\s-]/;
|
||||
|
||||
export interface GetMaxWrapCellOptions {
|
||||
colWidths: number[];
|
||||
avgCharWidth: number;
|
||||
wrappedColIdxs: boolean[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* loop through the fields and their values, determine which cell is going to determine the
|
||||
* height of the row based on its content and width, and then return the text, index, and number of lines for that cell.
|
||||
*/
|
||||
export function getMaxWrapCell(
|
||||
fields: Field[],
|
||||
rowIdx: number,
|
||||
{ colWidths, avgCharWidth, wrappedColIdxs }: GetMaxWrapCellOptions
|
||||
): {
|
||||
text: string;
|
||||
idx: number;
|
||||
numLines: number;
|
||||
} {
|
||||
let maxLines = 1;
|
||||
let maxLinesIdx = -1;
|
||||
let maxLinesText = '';
|
||||
|
||||
// TODO: consider changing how we store this, using a record by column key instead of an array
|
||||
for (let i = 0; i < colWidths.length; i++) {
|
||||
if (wrappedColIdxs[i]) {
|
||||
const field = fields[i];
|
||||
// special case: for the header, provide `-1` as the row index.
|
||||
const cellTextRaw = rowIdx === -1 ? getDisplayName(field) : field.values[rowIdx];
|
||||
|
||||
if (cellTextRaw != null) {
|
||||
const cellText = String(cellTextRaw);
|
||||
|
||||
if (spaceRegex.test(cellText)) {
|
||||
const charsPerLine = colWidths[i] / avgCharWidth;
|
||||
const approxLines = cellText.length / charsPerLine;
|
||||
|
||||
if (approxLines > maxLines) {
|
||||
maxLines = approxLines;
|
||||
maxLinesIdx = i;
|
||||
maxLinesText = cellText;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { text: maxLinesText, idx: maxLinesIdx, numLines: maxLines };
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* Returns true if text overflow handling should be applied to the cell.
|
||||
*/
|
||||
export function shouldTextOverflow(
|
||||
fieldType: FieldType,
|
||||
cellType: TableCellDisplayMode,
|
||||
textWrap: boolean,
|
||||
cellInspect: boolean
|
||||
): boolean {
|
||||
// Tech debt: Technically image cells are of type string, which is misleading (kinda?)
|
||||
// so we need to ensure we don't apply overflow hover states fo type image
|
||||
return fieldType === FieldType.string && cellType !== TableCellDisplayMode.Image && !textWrap && !cellInspect;
|
||||
export function shouldTextOverflow(field: Field): boolean {
|
||||
return (
|
||||
field.type === FieldType.string &&
|
||||
// Tech debt: Technically image cells are of type string, which is misleading (kinda?)
|
||||
// so we need to ensure we don't apply overflow hover states for type image
|
||||
getCellOptions(field).type !== TableCellDisplayMode.Image &&
|
||||
!shouldTextWrap(field) &&
|
||||
!isCellInspectEnabled(field)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -43,7 +43,6 @@ export interface Props extends Themeable2 {
|
||||
|
||||
onPinLine?: (row: LogRowModel) => void;
|
||||
pinLineButtonTooltipTitle?: PopoverContent;
|
||||
mode?: 'inline' | 'sidebar';
|
||||
links?: Record<string, LinkModel[]>;
|
||||
}
|
||||
|
||||
@@ -51,7 +50,7 @@ interface LinkModelWithIcon extends LinkModel {
|
||||
icon?: IconName;
|
||||
}
|
||||
|
||||
const useAttributesExtensionLinks = (row: LogRowModel) => {
|
||||
export const useAttributesExtensionLinks = (row: LogRowModel) => {
|
||||
// Stable context for useMemo inside usePluginLinks
|
||||
const context: PluginExtensionResourceAttributesContext = useMemo(() => {
|
||||
return {
|
||||
@@ -121,7 +120,6 @@ class UnThemedLogDetails extends PureComponent<Props> {
|
||||
onPinLine,
|
||||
styles,
|
||||
pinLineButtonTooltipTitle,
|
||||
mode = 'inline',
|
||||
links,
|
||||
} = this.props;
|
||||
const levelStyles = getLogLevelStyles(theme, row.logLevel);
|
||||
@@ -152,14 +150,9 @@ class UnThemedLogDetails extends PureComponent<Props> {
|
||||
return (
|
||||
<tr className={cx(className, styles.logDetails)}>
|
||||
{showDuplicates && <td />}
|
||||
{mode === 'inline' && (
|
||||
<td
|
||||
className={levelClassName}
|
||||
aria-label={t('logs.un-themed-log-details.aria-label-log-level', 'Log level')}
|
||||
/>
|
||||
)}
|
||||
<td className={levelClassName} aria-label={t('logs.un-themed-log-details.aria-label-log-level', 'Log level')} />
|
||||
<td colSpan={4}>
|
||||
<div className={mode === 'inline' ? styles.logDetailsContainer : styles.logDetailsSidebarContainer}>
|
||||
<div className={styles.logDetailsContainer}>
|
||||
<table className={styles.logDetailsTable}>
|
||||
<tbody>
|
||||
{displayedFields && displayedFields.length > 0 && (
|
||||
@@ -168,7 +161,7 @@ class UnThemedLogDetails extends PureComponent<Props> {
|
||||
<td
|
||||
colSpan={100}
|
||||
className={styles.logDetailsHeading}
|
||||
aria-label={t('logs.un-themed-log-details.aria-label-fields', 'Fields')}
|
||||
aria-label={t('logs.un-themed-log-details.aria-label-line', 'Log line')}
|
||||
>
|
||||
<Trans i18nKey="logs.log-details.log-line">Log line</Trans>
|
||||
</td>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { PureComponent } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { LogLabelStatsModel, GrafanaTheme2 } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { stylesFactory, withTheme2, Themeable2 } from '@grafana/ui';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { LogLabelStatsRow } from './LogLabelStatsRow';
|
||||
|
||||
const STATS_ROW_LIMIT = 5;
|
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme2) => {
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
logsStats: css({
|
||||
label: 'logs-stats',
|
||||
@@ -42,9 +42,10 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => {
|
||||
padding: '5px 0px',
|
||||
}),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
interface Props extends Themeable2 {
|
||||
interface Props {
|
||||
className?: string;
|
||||
stats: LogLabelStatsModel[];
|
||||
label: string;
|
||||
value: string;
|
||||
@@ -52,10 +53,9 @@ interface Props extends Themeable2 {
|
||||
isLabel?: boolean;
|
||||
}
|
||||
|
||||
class UnThemedLogLabelStats extends PureComponent<Props> {
|
||||
render() {
|
||||
const { label, rowCount, stats, value, theme, isLabel } = this.props;
|
||||
const style = getStyles(theme);
|
||||
export const LogLabelStats = ({ className, label, rowCount, stats, value, isLabel }: Props) => {
|
||||
const style = useStyles2(getStyles);
|
||||
const rows = useMemo(() => {
|
||||
const topRows = stats.slice(0, STATS_ROW_LIMIT);
|
||||
let activeRow = topRows.find((row) => row.value === value);
|
||||
let otherRows = stats.slice(STATS_ROW_LIMIT);
|
||||
@@ -66,45 +66,45 @@ class UnThemedLogLabelStats extends PureComponent<Props> {
|
||||
activeRow = otherRows.find((row) => row.value === value);
|
||||
otherRows = otherRows.filter((row) => row.value !== value);
|
||||
}
|
||||
return { topRows, otherRows, insertActiveRow, activeRow };
|
||||
}, [stats, value]);
|
||||
|
||||
const otherCount = otherRows.reduce((sum, row) => sum + row.count, 0);
|
||||
const topCount = topRows.reduce((sum, row) => sum + row.count, 0);
|
||||
const total = topCount + otherCount;
|
||||
const otherProportion = otherCount / total;
|
||||
const otherCount = useMemo(() => rows.otherRows.reduce((sum, row) => sum + row.count, 0), [rows.otherRows]);
|
||||
const topCount = useMemo(() => rows.topRows.reduce((sum, row) => sum + row.count, 0), [rows.topRows]);
|
||||
const total = topCount + otherCount;
|
||||
const otherProportion = otherCount / total;
|
||||
|
||||
return (
|
||||
<div className={style.logsStats} data-testid="logLabelStats">
|
||||
<div className={style.logsStatsHeader}>
|
||||
<div className={style.logsStatsTitle}>
|
||||
{isLabel
|
||||
? t(
|
||||
'logs.un-themed-log-label-stats.label-log-stats',
|
||||
'{{label}}: {{total}} of {{rowCount}} rows have that label',
|
||||
{
|
||||
label,
|
||||
total,
|
||||
rowCount,
|
||||
}
|
||||
)
|
||||
: t(
|
||||
'logs.un-themed-log-label-stats.field-log-stats',
|
||||
'{{label}}: {{total}} of {{rowCount}} rows have that field'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.logsStatsBody}>
|
||||
{topRows.map((stat) => (
|
||||
<LogLabelStatsRow key={stat.value} {...stat} active={stat.value === value} />
|
||||
))}
|
||||
{insertActiveRow && activeRow && <LogLabelStatsRow key={activeRow.value} {...activeRow} active />}
|
||||
{otherCount > 0 && (
|
||||
<LogLabelStatsRow key="__OTHERS__" count={otherCount} value="Other" proportion={otherProportion} />
|
||||
)}
|
||||
return (
|
||||
<div className={className ?? style.logsStats} data-testid="logLabelStats">
|
||||
<div className={style.logsStatsHeader}>
|
||||
<div className={style.logsStatsTitle}>
|
||||
{isLabel
|
||||
? t(
|
||||
'logs.un-themed-log-label-stats.label-log-stats',
|
||||
'{{label}}: {{total}} of {{rowCount}} rows have that label',
|
||||
{
|
||||
label,
|
||||
total,
|
||||
rowCount,
|
||||
}
|
||||
)
|
||||
: t(
|
||||
'logs.un-themed-log-label-stats.field-log-stats',
|
||||
'{{label}}: {{total}} of {{rowCount}} rows have that field'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const LogLabelStats = withTheme2(UnThemedLogLabelStats);
|
||||
LogLabelStats.displayName = 'LogLabelStats';
|
||||
<div className={style.logsStatsBody}>
|
||||
{rows.topRows.map((stat) => (
|
||||
<LogLabelStatsRow key={stat.value} {...stat} active={stat.value === value} />
|
||||
))}
|
||||
{rows.insertActiveRow && rows.activeRow && (
|
||||
<LogLabelStatsRow key={rows.activeRow.value} {...rows.activeRow} active />
|
||||
)}
|
||||
{otherCount > 0 && (
|
||||
<LogLabelStatsRow key="__OTHERS__" count={otherCount} value="Other" proportion={otherProportion} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -187,14 +187,6 @@ export const getLogRowStyles = memoizeOne((theme: GrafanaTheme2) => {
|
||||
margin: theme.spacing(2.5, 1, 2.5, 2),
|
||||
cursor: 'default',
|
||||
}),
|
||||
logDetailsSidebarContainer: css({
|
||||
label: 'logs-row-details-table',
|
||||
border: `1px solid ${theme.colors.border.medium}`,
|
||||
padding: theme.spacing(0, 1, 1),
|
||||
borderRadius: theme.shape.radius.default,
|
||||
margin: theme.spacing(0, 1, 0, 1),
|
||||
cursor: 'default',
|
||||
}),
|
||||
logDetailsTable: css({
|
||||
label: 'logs-row-details-table',
|
||||
lineHeight: '18px',
|
||||
|
||||
@@ -528,9 +528,6 @@ export const getStyles = (theme: GrafanaTheme2, virtualization?: LogLineVirtuali
|
||||
gridColumnGap: theme.spacing(FIELD_GAP_MULTIPLIER),
|
||||
whiteSpace: 'pre',
|
||||
paddingBottom: theme.spacing(0.75),
|
||||
'& .field': {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
}),
|
||||
wrappedLogLine: css({
|
||||
alignSelf: 'flex-start',
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import {
|
||||
Field,
|
||||
LogLevel,
|
||||
LogRowModel,
|
||||
FieldType,
|
||||
createDataFrame,
|
||||
DataFrameType,
|
||||
PluginExtensionPoints,
|
||||
toDataFrame,
|
||||
LogsSortOrder,
|
||||
DataFrame,
|
||||
ScopedVars,
|
||||
} from '@grafana/data';
|
||||
import { setPluginLinksHook } from '@grafana/runtime';
|
||||
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
|
||||
import { createLogLine } from '../mocks/logRow';
|
||||
|
||||
import { LogLineDetails, Props } from './LogLineDetails';
|
||||
import { LogListContext, LogListContextData } from './LogListContext';
|
||||
import { defaultValue } from './__mocks__/LogListContext';
|
||||
|
||||
jest.mock('@grafana/runtime', () => {
|
||||
return {
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
usePluginLinks: jest.fn().mockReturnValue({ links: [] }),
|
||||
};
|
||||
});
|
||||
jest.mock('./LogListContext');
|
||||
|
||||
const setup = (
|
||||
propOverrides?: Partial<Props>,
|
||||
rowOverrides?: Partial<LogRowModel>,
|
||||
contextOverrides?: Partial<LogListContextData>
|
||||
) => {
|
||||
const logs = [createLogLine({ logLevel: LogLevel.error, timeEpochMs: 1546297200000, ...rowOverrides })];
|
||||
|
||||
const props: Props = {
|
||||
containerElement: document.createElement('div'),
|
||||
logs,
|
||||
onResize: jest.fn(),
|
||||
...(propOverrides || {}),
|
||||
};
|
||||
|
||||
const contextData: LogListContextData = {
|
||||
...defaultValue,
|
||||
showDetails: logs,
|
||||
...contextOverrides,
|
||||
};
|
||||
|
||||
return render(
|
||||
<LogListContext.Provider value={contextData}>
|
||||
<LogLineDetails {...props} />
|
||||
</LogListContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('LogLineDetails', () => {
|
||||
describe('when fields are present', () => {
|
||||
test('should render the fields and the log line', () => {
|
||||
setup(undefined, { labels: { key1: 'label1', key2: 'label2' } });
|
||||
expect(screen.getByText('Log line')).toBeInTheDocument();
|
||||
expect(screen.getByText('Fields')).toBeInTheDocument();
|
||||
});
|
||||
test('fields should be visible by default', () => {
|
||||
setup(undefined, { labels: { key1: 'label1', key2: 'label2' } });
|
||||
expect(screen.getByText('key1')).toBeInTheDocument();
|
||||
expect(screen.getByText('label1')).toBeInTheDocument();
|
||||
expect(screen.getByText('key2')).toBeInTheDocument();
|
||||
expect(screen.getByText('label2')).toBeInTheDocument();
|
||||
});
|
||||
test('should show an option to display the log line when displayed fields are used', async () => {
|
||||
const onClickShowField = jest.fn();
|
||||
|
||||
setup(
|
||||
undefined,
|
||||
{ labels: { key1: 'label1' } },
|
||||
{ displayedFields: ['key1'], onClickShowField, onClickHideField: jest.fn() }
|
||||
);
|
||||
expect(screen.getByText('key1')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Show log line')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByLabelText('Show log line'));
|
||||
|
||||
expect(onClickShowField).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
test('should show an active option to display the log line when displayed fields are used', async () => {
|
||||
const onClickHideField = jest.fn();
|
||||
|
||||
setup(
|
||||
undefined,
|
||||
{ labels: { key1: 'label1' } },
|
||||
{ displayedFields: ['key1', LOG_LINE_BODY_FIELD_NAME], onClickHideField, onClickShowField: jest.fn() }
|
||||
);
|
||||
expect(screen.getByText('key1')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Hide log line')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByLabelText('Hide log line'));
|
||||
|
||||
expect(onClickHideField).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
test('should not show an option to display the log line when displayed fields are not used', () => {
|
||||
setup(undefined, { labels: { key1: 'label1' } }, { displayedFields: [] });
|
||||
expect(screen.getByText('key1')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Show log line')).not.toBeInTheDocument();
|
||||
});
|
||||
test('should render the filter controls when the callbacks are provided', () => {
|
||||
setup(
|
||||
undefined,
|
||||
{ labels: { key1: 'label1' } },
|
||||
{
|
||||
onClickFilterLabel: () => {},
|
||||
onClickFilterOutLabel: () => {},
|
||||
}
|
||||
);
|
||||
expect(screen.getByLabelText('Filter for value in query A')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Filter out value in query A')).toBeInTheDocument();
|
||||
});
|
||||
describe('Toggleable filters', () => {
|
||||
test('should pass the log row to Explore filter functions', async () => {
|
||||
const onClickFilterLabelMock = jest.fn();
|
||||
const onClickFilterOutLabelMock = jest.fn();
|
||||
const isLabelFilterActiveMock = jest.fn().mockResolvedValue(true);
|
||||
const log = createLogLine({
|
||||
logLevel: LogLevel.error,
|
||||
timeEpochMs: 1546297200000,
|
||||
labels: { key1: 'label1' },
|
||||
});
|
||||
|
||||
setup(
|
||||
{
|
||||
logs: [log],
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
onClickFilterLabel: onClickFilterLabelMock,
|
||||
onClickFilterOutLabel: onClickFilterOutLabelMock,
|
||||
isLabelFilterActive: isLabelFilterActiveMock,
|
||||
showDetails: [log],
|
||||
}
|
||||
);
|
||||
|
||||
expect(isLabelFilterActiveMock).toHaveBeenCalledWith('key1', 'label1', log.dataFrame.refId);
|
||||
|
||||
await userEvent.click(screen.getByLabelText('Filter for value in query A'));
|
||||
expect(onClickFilterLabelMock).toHaveBeenCalledTimes(1);
|
||||
expect(onClickFilterLabelMock).toHaveBeenCalledWith(
|
||||
'key1',
|
||||
'label1',
|
||||
expect.objectContaining({
|
||||
fields: [
|
||||
expect.objectContaining({ values: [0] }),
|
||||
expect.objectContaining({ values: ['line1'] }),
|
||||
expect.objectContaining({ values: [{ app: 'app01' }] }),
|
||||
],
|
||||
length: 1,
|
||||
})
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByLabelText('Filter out value in query A'));
|
||||
expect(onClickFilterOutLabelMock).toHaveBeenCalledTimes(1);
|
||||
expect(onClickFilterOutLabelMock).toHaveBeenCalledWith(
|
||||
'key1',
|
||||
'label1',
|
||||
expect.objectContaining({
|
||||
fields: [
|
||||
expect.objectContaining({ values: [0] }),
|
||||
expect.objectContaining({ values: ['line1'] }),
|
||||
expect.objectContaining({ values: [{ app: 'app01' }] }),
|
||||
],
|
||||
length: 1,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
test('should not render filter controls when the callbacks are not provided', () => {
|
||||
setup(
|
||||
undefined,
|
||||
{ labels: { key1: 'label1' } },
|
||||
{
|
||||
onClickFilterLabel: undefined,
|
||||
onClickFilterOutLabel: undefined,
|
||||
}
|
||||
);
|
||||
expect(screen.queryByLabelText('Filter for value')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Filter out value')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
describe('when the log has no fields to display', () => {
|
||||
test('should render no details available message', () => {
|
||||
setup(undefined, { entry: '' });
|
||||
expect(screen.getByText('No fields to display.')).toBeInTheDocument();
|
||||
});
|
||||
test('should not render headings', () => {
|
||||
setup(undefined, { entry: '' });
|
||||
expect(screen.queryByText('Fields')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Links')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Indexed labels')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Parsed fields')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Structured metadata')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
test('should render fields from the dataframe with links', () => {
|
||||
const entry = 'traceId=1234 msg="some message"';
|
||||
const dataFrame = toDataFrame({
|
||||
fields: [
|
||||
{ name: 'timestamp', config: {}, type: FieldType.time, values: [1] },
|
||||
{ name: 'entry', values: [entry] },
|
||||
// As we have traceId in message already this will shadow it.
|
||||
{
|
||||
name: 'traceId',
|
||||
values: ['1234'],
|
||||
config: { links: [{ title: 'link title', url: 'localhost:3210/${__value.text}' }] },
|
||||
},
|
||||
{ name: 'userId', values: ['5678'] },
|
||||
],
|
||||
});
|
||||
const log = createLogLine(
|
||||
{ entry, dataFrame, entryFieldIndex: 0, rowIndex: 0 },
|
||||
{
|
||||
escape: false,
|
||||
order: LogsSortOrder.Descending,
|
||||
timeZone: 'browser',
|
||||
virtualization: undefined,
|
||||
wrapLogMessage: true,
|
||||
getFieldLinks: (field: Field, rowIndex: number, dataFrame: DataFrame, vars: ScopedVars) => {
|
||||
if (field.config && field.config.links) {
|
||||
return field.config.links.map((link) => {
|
||||
return {
|
||||
href: link.url.replace('${__value.text}', field.values[rowIndex]),
|
||||
title: link.title,
|
||||
target: '_blank',
|
||||
origin: field,
|
||||
};
|
||||
});
|
||||
}
|
||||
return [];
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
setup({ logs: [log] }, undefined, { showDetails: [log] });
|
||||
|
||||
expect(screen.getByText('Fields')).toBeInTheDocument();
|
||||
expect(screen.getByText('Links')).toBeInTheDocument();
|
||||
expect(screen.getByText('traceId')).toBeInTheDocument();
|
||||
expect(screen.getByText('link title')).toBeInTheDocument();
|
||||
expect(screen.getByText('1234')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should show the correct log details fields, links and labels for DataFrameType.LogLines frames', () => {
|
||||
const entry = 'test';
|
||||
const dataFrame = createDataFrame({
|
||||
fields: [
|
||||
{ name: 'timestamp', config: {}, type: FieldType.time, values: [1] },
|
||||
{ name: 'body', type: FieldType.string, values: [entry] },
|
||||
{
|
||||
name: 'labels',
|
||||
type: FieldType.other,
|
||||
values: [
|
||||
{
|
||||
label1: 'value1',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'shouldNotShowFieldName',
|
||||
type: FieldType.string,
|
||||
values: ['shouldNotShowFieldValue'],
|
||||
},
|
||||
{
|
||||
name: 'shouldShowLinkName',
|
||||
type: FieldType.string,
|
||||
values: ['shouldShowLinkValue'],
|
||||
config: { links: [{ title: 'link', url: 'localhost:3210/${__value.text}' }] },
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
type: DataFrameType.LogLines,
|
||||
},
|
||||
});
|
||||
|
||||
const log = createLogLine(
|
||||
{ entry, dataFrame, entryFieldIndex: 0, rowIndex: 0, labels: { label1: 'value1' } },
|
||||
{
|
||||
escape: false,
|
||||
order: LogsSortOrder.Descending,
|
||||
timeZone: 'browser',
|
||||
virtualization: undefined,
|
||||
wrapLogMessage: true,
|
||||
getFieldLinks: (field: Field, rowIndex: number, dataFrame: DataFrame, vars: ScopedVars) => {
|
||||
if (field.config && field.config.links) {
|
||||
return field.config.links.map((link) => {
|
||||
return {
|
||||
href: link.url.replace('${__value.text}', field.values[rowIndex]),
|
||||
title: link.title,
|
||||
target: '_blank',
|
||||
origin: field,
|
||||
};
|
||||
});
|
||||
}
|
||||
return [];
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
setup({ logs: [log] }, undefined, { showDetails: [log] });
|
||||
|
||||
expect(screen.getByText('Log line')).toBeInTheDocument();
|
||||
expect(screen.getByText('Fields')).toBeInTheDocument();
|
||||
expect(screen.getByText('Links')).toBeInTheDocument();
|
||||
|
||||
// Don't show additional fields for DataFrameType.LogLines
|
||||
expect(screen.queryByText('shouldNotShowFieldName')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('shouldNotShowFieldValue')).not.toBeInTheDocument();
|
||||
|
||||
// Show labels and links
|
||||
expect(screen.getByText('label1')).toBeInTheDocument();
|
||||
expect(screen.getByText('value1')).toBeInTheDocument();
|
||||
expect(screen.getByText('shouldShowLinkName')).toBeInTheDocument();
|
||||
expect(screen.getByText('shouldShowLinkValue')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should load plugin links for logs view resource attributes extension point', () => {
|
||||
const usePluginLinksMock = jest.fn().mockReturnValue({ links: [] });
|
||||
setPluginLinksHook(usePluginLinksMock);
|
||||
jest.requireMock('@grafana/runtime').usePluginLinks = usePluginLinksMock;
|
||||
|
||||
const rowOverrides = {
|
||||
datasourceType: 'loki',
|
||||
datasourceUid: 'grafanacloud-logs',
|
||||
labels: { key1: 'label1', key2: 'label2' },
|
||||
};
|
||||
setup(undefined, rowOverrides);
|
||||
|
||||
expect(usePluginLinksMock).toHaveBeenCalledWith({
|
||||
extensionPointId: PluginExtensionPoints.LogsViewResourceAttributes,
|
||||
limitPerPlugin: 10,
|
||||
context: {
|
||||
datasource: {
|
||||
type: 'loki',
|
||||
uid: 'grafanacloud-logs',
|
||||
},
|
||||
attributes: { key1: ['label1'], key2: ['label2'] },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('Label types', () => {
|
||||
const entry = 'test';
|
||||
const labels = {
|
||||
label1: 'value1',
|
||||
label2: 'value2',
|
||||
label3: 'value3',
|
||||
};
|
||||
const dataFrame = createDataFrame({
|
||||
fields: [
|
||||
{ name: 'timestamp', config: {}, type: FieldType.time, values: [1] },
|
||||
{ name: 'body', type: FieldType.string, values: [entry] },
|
||||
{ name: 'id', type: FieldType.string, values: ['1'] },
|
||||
{
|
||||
name: 'labels',
|
||||
type: FieldType.other,
|
||||
values: [labels],
|
||||
},
|
||||
{
|
||||
name: 'labelTypes',
|
||||
type: FieldType.other,
|
||||
values: [
|
||||
{
|
||||
label1: 'I',
|
||||
label2: 'S',
|
||||
label3: 'P',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
type: DataFrameType.LogLines,
|
||||
},
|
||||
});
|
||||
test('should show label types if they are available and supported', () => {
|
||||
setup(undefined, {
|
||||
entry,
|
||||
dataFrame,
|
||||
entryFieldIndex: 0,
|
||||
rowIndex: 0,
|
||||
labels,
|
||||
datasourceType: 'loki',
|
||||
rowId: '1',
|
||||
});
|
||||
|
||||
// Show labels and links
|
||||
expect(screen.getByText('label1')).toBeInTheDocument();
|
||||
expect(screen.getByText('value1')).toBeInTheDocument();
|
||||
expect(screen.getByText('label2')).toBeInTheDocument();
|
||||
expect(screen.getByText('value2')).toBeInTheDocument();
|
||||
expect(screen.getByText('label3')).toBeInTheDocument();
|
||||
expect(screen.getByText('value3')).toBeInTheDocument();
|
||||
expect(screen.getByText('Indexed labels')).toBeInTheDocument();
|
||||
expect(screen.getByText('Parsed fields')).toBeInTheDocument();
|
||||
expect(screen.getByText('Structured metadata')).toBeInTheDocument();
|
||||
});
|
||||
test('should not show label types if they are unavailable or not supported', () => {
|
||||
setup(
|
||||
{},
|
||||
{
|
||||
entry,
|
||||
dataFrame,
|
||||
entryFieldIndex: 0,
|
||||
rowIndex: 0,
|
||||
labels,
|
||||
datasourceType: 'other datasource',
|
||||
rowId: '1',
|
||||
}
|
||||
);
|
||||
|
||||
// Show labels and links
|
||||
expect(screen.getByText('label1')).toBeInTheDocument();
|
||||
expect(screen.getByText('value1')).toBeInTheDocument();
|
||||
expect(screen.getByText('label2')).toBeInTheDocument();
|
||||
expect(screen.getByText('value2')).toBeInTheDocument();
|
||||
expect(screen.getByText('label3')).toBeInTheDocument();
|
||||
expect(screen.getByText('value3')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('Fields')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Indexed labels')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Parsed fields')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Structured metadata')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Should allow to search within fields', async () => {
|
||||
setup(undefined, {
|
||||
entry,
|
||||
dataFrame,
|
||||
entryFieldIndex: 0,
|
||||
rowIndex: 0,
|
||||
labels,
|
||||
datasourceType: 'loki',
|
||||
rowId: '1',
|
||||
});
|
||||
|
||||
expect(screen.getByText('label1')).toBeInTheDocument();
|
||||
expect(screen.getByText('value1')).toBeInTheDocument();
|
||||
expect(screen.getByText('label2')).toBeInTheDocument();
|
||||
expect(screen.getByText('value2')).toBeInTheDocument();
|
||||
expect(screen.getByText('label3')).toBeInTheDocument();
|
||||
expect(screen.getByText('value3')).toBeInTheDocument();
|
||||
|
||||
const input = screen.getByPlaceholderText('Search field names and values');
|
||||
|
||||
await userEvent.type(input, 'something else');
|
||||
|
||||
expect(screen.getAllByText('No results to display.')).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,43 +3,22 @@ import { Resizable } from 're-resizable';
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { getDragStyles, IconButton, useStyles2, useTheme2 } from '@grafana/ui';
|
||||
import { GetFieldLinksFn } from 'app/plugins/panel/logs/types';
|
||||
|
||||
import { LogDetails } from '../LogDetails';
|
||||
import { getLogRowStyles } from '../getLogRowStyles';
|
||||
import { getDragStyles, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { LogLineDetailsComponent } from './LogLineDetailsComponent';
|
||||
import { useLogListContext } from './LogListContext';
|
||||
import { LogListModel } from './processing';
|
||||
import { LOG_LIST_MIN_WIDTH } from './virtualization';
|
||||
|
||||
interface Props {
|
||||
export interface Props {
|
||||
containerElement: HTMLDivElement;
|
||||
getFieldLinks?: GetFieldLinksFn;
|
||||
logOptionsStorageKey?: string;
|
||||
logs: LogListModel[];
|
||||
onResize(): void;
|
||||
}
|
||||
|
||||
export const LogLineDetails = ({ containerElement, getFieldLinks, logs, onResize }: Props) => {
|
||||
const {
|
||||
app,
|
||||
closeDetails,
|
||||
detailsWidth,
|
||||
displayedFields,
|
||||
isLabelFilterActive,
|
||||
onClickFilterLabel,
|
||||
onClickFilterOutLabel,
|
||||
onClickShowField,
|
||||
onClickHideField,
|
||||
onPinLine,
|
||||
pinLineButtonTooltipTitle,
|
||||
setDetailsWidth,
|
||||
showDetails,
|
||||
wrapLogMessage,
|
||||
} = useLogListContext();
|
||||
const getRows = useCallback(() => logs, [logs]);
|
||||
const logRowsStyles = getLogRowStyles(useTheme2());
|
||||
export const LogLineDetails = ({ containerElement, logOptionsStorageKey, logs, onResize }: Props) => {
|
||||
const { detailsWidth, setDetailsWidth, showDetails } = useLogListContext();
|
||||
const styles = useStyles2(getStyles);
|
||||
const dragStyles = useStyles2(getDragStyles);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -53,6 +32,10 @@ export const LogLineDetails = ({ containerElement, getFieldLinks, logs, onResize
|
||||
|
||||
const maxWidth = containerElement.clientWidth - LOG_LIST_MIN_WIDTH;
|
||||
|
||||
if (!showDetails.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Resizable
|
||||
onResize={handleResize}
|
||||
@@ -65,35 +48,7 @@ export const LogLineDetails = ({ containerElement, getFieldLinks, logs, onResize
|
||||
>
|
||||
<div className={styles.container} ref={containerRef}>
|
||||
<div className={styles.scrollContainer}>
|
||||
<IconButton
|
||||
name="times"
|
||||
className={styles.closeIcon}
|
||||
aria-label={t('logs.log-details.close', 'Close log details')}
|
||||
onClick={closeDetails}
|
||||
/>
|
||||
<table width="100%">
|
||||
<tbody>
|
||||
<LogDetails
|
||||
getRows={getRows}
|
||||
mode="sidebar"
|
||||
row={showDetails[0]}
|
||||
showDuplicates={false}
|
||||
styles={logRowsStyles}
|
||||
wrapLogMessage={wrapLogMessage}
|
||||
onPinLine={onPinLine}
|
||||
getFieldLinks={getFieldLinks}
|
||||
onClickFilterLabel={onClickFilterLabel}
|
||||
onClickFilterOutLabel={onClickFilterOutLabel}
|
||||
onClickShowField={onClickShowField}
|
||||
onClickHideField={onClickHideField}
|
||||
hasError={showDetails[0].hasError}
|
||||
displayedFields={displayedFields}
|
||||
app={app}
|
||||
isFilterLabelActive={isLabelFilterActive}
|
||||
pinLineButtonTooltipTitle={pinLineButtonTooltipTitle}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
<LogLineDetailsComponent log={showDetails[0]} logOptionsStorageKey={logOptionsStorageKey} logs={logs} />
|
||||
</div>
|
||||
</div>
|
||||
</Resizable>
|
||||
@@ -104,15 +59,15 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
container: css({
|
||||
overflow: 'auto',
|
||||
height: '100%',
|
||||
boxShadow: theme.shadows.z1,
|
||||
border: `1px solid ${theme.colors.border.medium}`,
|
||||
borderRight: 'none',
|
||||
}),
|
||||
scrollContainer: css({
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
}),
|
||||
closeIcon: css({
|
||||
position: 'absolute',
|
||||
top: theme.spacing(1),
|
||||
right: theme.spacing(1.5),
|
||||
componentWrapper: css({
|
||||
padding: theme.spacing(0, 1, 1, 1),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { camelCase, groupBy } from 'lodash';
|
||||
import { startTransition, useCallback, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { DataFrameType, GrafanaTheme2, store } from '@grafana/data';
|
||||
import { t, Trans } from '@grafana/i18n';
|
||||
import { ControlledCollapse, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { getLabelTypeFromRow } from '../../utils';
|
||||
import { useAttributesExtensionLinks } from '../LogDetails';
|
||||
import { createLogLineLinks } from '../logParser';
|
||||
|
||||
import { LabelWithLinks, LogLineDetailsFields, LogLineDetailsLabelFields } from './LogLineDetailsFields';
|
||||
import { LogLineDetailsHeader } from './LogLineDetailsHeader';
|
||||
import { LogListModel } from './processing';
|
||||
|
||||
interface LogLineDetailsComponentProps {
|
||||
log: LogListModel;
|
||||
logOptionsStorageKey?: string;
|
||||
logs: LogListModel[];
|
||||
}
|
||||
|
||||
export const LogLineDetailsComponent = ({ log, logOptionsStorageKey, logs }: LogLineDetailsComponentProps) => {
|
||||
const [search, setSearch] = useState('');
|
||||
const inputRef = useRef('');
|
||||
const styles = useStyles2(getStyles);
|
||||
const extensionLinks = useAttributesExtensionLinks(log);
|
||||
const fieldsWithLinks = useMemo(() => {
|
||||
const fieldsWithLinks = log.fields.filter((f) => f.links?.length);
|
||||
const displayedFieldsWithLinks = fieldsWithLinks.filter((f) => f.fieldIndex !== log.entryFieldIndex).sort();
|
||||
const hiddenFieldsWithLinks = fieldsWithLinks.filter((f) => f.fieldIndex === log.entryFieldIndex).sort();
|
||||
const fieldsWithLinksFromVariableMap = createLogLineLinks(hiddenFieldsWithLinks);
|
||||
return {
|
||||
links: displayedFieldsWithLinks,
|
||||
linksFromVariableMap: fieldsWithLinksFromVariableMap,
|
||||
};
|
||||
}, [log.entryFieldIndex, log.fields]);
|
||||
const fieldsWithoutLinks =
|
||||
log.dataFrame.meta?.type === DataFrameType.LogLines
|
||||
? // for LogLines frames (dataplane) we don't want to show any additional fields besides already extracted labels and links
|
||||
[]
|
||||
: // for other frames, do not show the log message unless there is a link attached
|
||||
log.fields.filter((f) => f.links?.length === 0 && f.fieldIndex !== log.entryFieldIndex).sort();
|
||||
const labelsWithLinks: LabelWithLinks[] = useMemo(
|
||||
() =>
|
||||
Object.keys(log.labels)
|
||||
.sort()
|
||||
.map((label) => ({
|
||||
key: label,
|
||||
value: log.labels[label],
|
||||
link: extensionLinks?.[label],
|
||||
})),
|
||||
[extensionLinks, log.labels]
|
||||
);
|
||||
const groupedLabels = useMemo(
|
||||
() => groupBy(labelsWithLinks, (label) => getLabelTypeFromRow(label.key, log, true) ?? ''),
|
||||
[labelsWithLinks, log]
|
||||
);
|
||||
const labelGroups = useMemo(() => Object.keys(groupedLabels), [groupedLabels]);
|
||||
|
||||
const logLineOpen = logOptionsStorageKey
|
||||
? store.getBool(`${logOptionsStorageKey}.log-details.logLineOpen`, false)
|
||||
: false;
|
||||
const linksOpen = logOptionsStorageKey ? store.getBool(`${logOptionsStorageKey}.log-details.linksOpen`, true) : true;
|
||||
const fieldsOpen = logOptionsStorageKey
|
||||
? store.getBool(`${logOptionsStorageKey}.log-details.fieldsOpen`, true)
|
||||
: true;
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(option: string, isOpen: boolean) => {
|
||||
console.log(option, isOpen);
|
||||
store.set(`${logOptionsStorageKey}.log-details.${option}`, isOpen);
|
||||
},
|
||||
[logOptionsStorageKey]
|
||||
);
|
||||
|
||||
const handleSearch = useCallback((newSearch: string) => {
|
||||
inputRef.current = newSearch;
|
||||
startTransition(() => {
|
||||
setSearch(inputRef.current);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const noDetails =
|
||||
!fieldsWithLinks.links.length &&
|
||||
!fieldsWithLinks.linksFromVariableMap.length &&
|
||||
!labelGroups.length &&
|
||||
!fieldsWithoutLinks.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<LogLineDetailsHeader log={log} search={search} onSearch={handleSearch} />
|
||||
<div className={styles.componentWrapper}>
|
||||
<ControlledCollapse
|
||||
className={styles.collapsable}
|
||||
label={t('logs.log-line-details.log-line-section', 'Log line')}
|
||||
collapsible
|
||||
isOpen={logLineOpen}
|
||||
onToggle={(isOpen: boolean) => handleToggle('logLineOpen', isOpen)}
|
||||
>
|
||||
<div className={styles.logLineWrapper}>{log.raw}</div>
|
||||
</ControlledCollapse>
|
||||
{fieldsWithLinks.links.length > 0 && (
|
||||
<ControlledCollapse
|
||||
className={styles.collapsable}
|
||||
label={t('logs.log-line-details.links-section', 'Links')}
|
||||
collapsible
|
||||
isOpen={linksOpen}
|
||||
onToggle={(isOpen: boolean) => handleToggle('linksOpen', isOpen)}
|
||||
>
|
||||
<LogLineDetailsFields log={log} logs={logs} fields={fieldsWithLinks.links} search={search} />
|
||||
<LogLineDetailsFields
|
||||
disableActions
|
||||
log={log}
|
||||
logs={logs}
|
||||
fields={fieldsWithLinks.linksFromVariableMap}
|
||||
search={search}
|
||||
/>
|
||||
</ControlledCollapse>
|
||||
)}
|
||||
{labelGroups.map((group) =>
|
||||
group === '' ? (
|
||||
<ControlledCollapse
|
||||
className={styles.collapsable}
|
||||
key={'fields'}
|
||||
label={t('logs.log-line-details.fields-section', 'Fields')}
|
||||
collapsible
|
||||
isOpen={fieldsOpen}
|
||||
onToggle={(isOpen: boolean) => handleToggle('fieldsOpen', isOpen)}
|
||||
>
|
||||
<LogLineDetailsLabelFields log={log} logs={logs} fields={groupedLabels[group]} search={search} />
|
||||
<LogLineDetailsFields log={log} logs={logs} fields={fieldsWithoutLinks} search={search} />
|
||||
</ControlledCollapse>
|
||||
) : (
|
||||
<ControlledCollapse
|
||||
className={styles.collapsable}
|
||||
key={group}
|
||||
label={group}
|
||||
collapsible
|
||||
isOpen={store.getBool(`${logOptionsStorageKey}.log-details.${groupOptionName(group)}`, true)}
|
||||
onToggle={(isOpen: boolean) => handleToggle(groupOptionName(group), isOpen)}
|
||||
>
|
||||
<LogLineDetailsLabelFields log={log} logs={logs} fields={groupedLabels[group]} search={search} />
|
||||
</ControlledCollapse>
|
||||
)
|
||||
)}
|
||||
{!labelGroups.length && fieldsWithoutLinks.length > 0 && (
|
||||
<ControlledCollapse
|
||||
className={styles.collapsable}
|
||||
key={'fields'}
|
||||
label={t('logs.log-line-details.fields-section', 'Fields')}
|
||||
collapsible
|
||||
isOpen={fieldsOpen}
|
||||
onToggle={(isOpen: boolean) => handleToggle('fieldsOpen', isOpen)}
|
||||
>
|
||||
<LogLineDetailsFields log={log} logs={logs} fields={fieldsWithoutLinks} search={search} />
|
||||
</ControlledCollapse>
|
||||
)}
|
||||
{noDetails && <Trans i18nKey="logs.log-line-details.no-details">No fields to display.</Trans>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function groupOptionName(group: string) {
|
||||
return `${camelCase(group)}Open`;
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
collapsable: css({
|
||||
'&:last-of-type': {
|
||||
marginBottom: 0,
|
||||
},
|
||||
}),
|
||||
componentWrapper: css({
|
||||
padding: theme.spacing(0, 1, 1, 1),
|
||||
}),
|
||||
logLineWrapper: css({
|
||||
maxHeight: '50vh',
|
||||
overflow: 'auto',
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,522 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { isEqual } from 'lodash';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { CoreApp, Field, fuzzySearch, GrafanaTheme2, IconName, LinkModel, LogLabelStatsModel } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { ClipboardButton, DataLinkButton, IconButton, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { logRowToSingleRowDataFrame } from '../../logsModel';
|
||||
import { calculateLogsLabelStats, calculateStats } from '../../utils';
|
||||
import { LogLabelStats } from '../LogLabelStats';
|
||||
import { FieldDef } from '../logParser';
|
||||
|
||||
import { useLogListContext } from './LogListContext';
|
||||
import { LogListModel } from './processing';
|
||||
|
||||
interface LogLineDetailsFieldsProps {
|
||||
disableActions?: boolean;
|
||||
fields: FieldDef[];
|
||||
log: LogListModel;
|
||||
logs: LogListModel[];
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export const LogLineDetailsFields = ({ disableActions, fields, log, logs, search }: LogLineDetailsFieldsProps) => {
|
||||
if (!fields.length) {
|
||||
return null;
|
||||
}
|
||||
const styles = useStyles2(getFieldsStyles);
|
||||
const getLogs = useCallback(() => logs, [logs]);
|
||||
const filteredFields = useMemo(() => (search ? filterFields(fields, search) : fields), [fields, search]);
|
||||
|
||||
if (filteredFields.length === 0) {
|
||||
return t('logs.log-line-details.search.no-results', 'No results to display.');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={disableActions ? styles.fieldsTableNoActions : styles.fieldsTable}>
|
||||
{filteredFields.map((field, i) => (
|
||||
<LogLineDetailsField
|
||||
key={`${field.keys[0]}=${field.values[0]}-${i}`}
|
||||
disableActions={disableActions}
|
||||
getLogs={getLogs}
|
||||
fieldIndex={field.fieldIndex}
|
||||
keys={field.keys}
|
||||
links={field.links}
|
||||
log={log}
|
||||
values={field.values}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface LinkModelWithIcon extends LinkModel<Field> {
|
||||
icon?: IconName;
|
||||
}
|
||||
|
||||
export interface LabelWithLinks {
|
||||
key: string;
|
||||
value: string;
|
||||
links?: LinkModelWithIcon[];
|
||||
}
|
||||
|
||||
interface LogLineDetailsLabelFieldsProps {
|
||||
fields: LabelWithLinks[];
|
||||
log: LogListModel;
|
||||
logs: LogListModel[];
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export const LogLineDetailsLabelFields = ({ fields, log, logs, search }: LogLineDetailsLabelFieldsProps) => {
|
||||
if (!fields.length) {
|
||||
return null;
|
||||
}
|
||||
const styles = useStyles2(getFieldsStyles);
|
||||
const getLogs = useCallback(() => logs, [logs]);
|
||||
const filteredFields = useMemo(() => (search ? filterLabels(fields, search) : fields), [fields, search]);
|
||||
|
||||
if (filteredFields.length === 0) {
|
||||
return t('logs.log-line-details.search.no-results', 'No results to display.');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.fieldsTable}>
|
||||
{filteredFields.map((field, i) => (
|
||||
<LogLineDetailsField
|
||||
key={`${field.key}=${field.value}-${i}`}
|
||||
getLogs={getLogs}
|
||||
isLabel
|
||||
keys={[field.key]}
|
||||
links={field.links}
|
||||
log={log}
|
||||
values={[field.value]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getFieldsStyles = (theme: GrafanaTheme2) => ({
|
||||
fieldsTable: css({
|
||||
display: 'grid',
|
||||
gap: theme.spacing(1),
|
||||
gridTemplateColumns: `${theme.spacing(11.5)} minmax(15%, 30%) 1fr`,
|
||||
}),
|
||||
fieldsTableNoActions: css({
|
||||
display: 'grid',
|
||||
gap: theme.spacing(1),
|
||||
gridTemplateColumns: `minmax(15%, 30%) 1fr`,
|
||||
}),
|
||||
});
|
||||
|
||||
interface LogLineDetailsFieldProps {
|
||||
keys: string[];
|
||||
values: string[];
|
||||
disableActions?: boolean;
|
||||
fieldIndex?: number;
|
||||
getLogs(): LogListModel[];
|
||||
isLabel?: boolean;
|
||||
links?: LinkModelWithIcon[];
|
||||
log: LogListModel;
|
||||
}
|
||||
|
||||
export const LogLineDetailsField = ({
|
||||
disableActions = false,
|
||||
fieldIndex,
|
||||
getLogs,
|
||||
isLabel,
|
||||
links,
|
||||
log,
|
||||
keys,
|
||||
values,
|
||||
}: LogLineDetailsFieldProps) => {
|
||||
const [showFieldsStats, setShowFieldStats] = useState(false);
|
||||
const [fieldCount, setFieldCount] = useState(0);
|
||||
const [fieldStats, setFieldStats] = useState<LogLabelStatsModel[] | null>(null);
|
||||
const {
|
||||
app,
|
||||
closeDetails,
|
||||
displayedFields,
|
||||
isLabelFilterActive,
|
||||
onClickFilterLabel,
|
||||
onClickFilterOutLabel,
|
||||
onClickShowField,
|
||||
onClickHideField,
|
||||
onPinLine,
|
||||
pinLineButtonTooltipTitle,
|
||||
} = useLogListContext();
|
||||
|
||||
const styles = useStyles2(getFieldStyles);
|
||||
|
||||
const getStats = useCallback(() => {
|
||||
if (isLabel) {
|
||||
return calculateLogsLabelStats(getLogs(), keys[0]);
|
||||
}
|
||||
if (fieldIndex !== undefined) {
|
||||
return calculateStats(log.dataFrame.fields[fieldIndex].values);
|
||||
}
|
||||
return [];
|
||||
}, [fieldIndex, getLogs, isLabel, keys, log.dataFrame.fields]);
|
||||
|
||||
const updateStats = useCallback(() => {
|
||||
const newStats = getStats();
|
||||
const newCount = newStats.reduce((sum, stat) => sum + stat.count, 0);
|
||||
if (!isEqual(fieldStats, newStats) || fieldCount !== newCount) {
|
||||
setFieldStats(newStats);
|
||||
setFieldCount(newCount);
|
||||
}
|
||||
}, [fieldCount, fieldStats, getStats]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showFieldsStats) {
|
||||
updateStats();
|
||||
}
|
||||
}, [showFieldsStats, updateStats]);
|
||||
|
||||
const showField = useCallback(() => {
|
||||
if (onClickShowField) {
|
||||
onClickShowField(keys[0]);
|
||||
}
|
||||
|
||||
reportInteraction('grafana_explore_logs_log_details_replace_line_clicked', {
|
||||
datasourceType: log.datasourceType,
|
||||
logRowUid: log.uid,
|
||||
type: 'enable',
|
||||
});
|
||||
}, [onClickShowField, keys, log.datasourceType, log.uid]);
|
||||
|
||||
const hideField = useCallback(() => {
|
||||
if (onClickHideField) {
|
||||
onClickHideField(keys[0]);
|
||||
}
|
||||
|
||||
reportInteraction('grafana_explore_logs_log_details_replace_line_clicked', {
|
||||
datasourceType: log.datasourceType,
|
||||
logRowUid: log.uid,
|
||||
type: 'disable',
|
||||
});
|
||||
}, [onClickHideField, keys, log.datasourceType, log.uid]);
|
||||
|
||||
const filterLabel = useCallback(() => {
|
||||
if (onClickFilterLabel) {
|
||||
onClickFilterLabel(keys[0], values[0], logRowToSingleRowDataFrame(log) || undefined);
|
||||
}
|
||||
|
||||
reportInteraction('grafana_explore_logs_log_details_filter_clicked', {
|
||||
datasourceType: log.datasourceType,
|
||||
filterType: 'include',
|
||||
logRowUid: log.uid,
|
||||
});
|
||||
}, [onClickFilterLabel, keys, values, log]);
|
||||
|
||||
const filterOutLabel = useCallback(() => {
|
||||
if (onClickFilterOutLabel) {
|
||||
onClickFilterOutLabel(keys[0], values[0], logRowToSingleRowDataFrame(log) || undefined);
|
||||
}
|
||||
|
||||
reportInteraction('grafana_explore_logs_log_details_filter_clicked', {
|
||||
datasourceType: log.datasourceType,
|
||||
filterType: 'exclude',
|
||||
logRowUid: log.uid,
|
||||
});
|
||||
}, [onClickFilterOutLabel, keys, values, log]);
|
||||
|
||||
const labelFilterActive = useCallback(async () => {
|
||||
if (isLabelFilterActive) {
|
||||
return await isLabelFilterActive(keys[0], values[0], log.dataFrame?.refId);
|
||||
}
|
||||
return false;
|
||||
}, [isLabelFilterActive, keys, values, log.dataFrame?.refId]);
|
||||
|
||||
const showStats = useCallback(() => {
|
||||
setShowFieldStats((showFieldStats: boolean) => !showFieldStats);
|
||||
|
||||
reportInteraction('grafana_explore_logs_log_details_stats_clicked', {
|
||||
dataSourceType: log.datasourceType,
|
||||
fieldType: isLabel ? 'label' : 'detectedField',
|
||||
type: showFieldsStats ? 'close' : 'open',
|
||||
logRowUid: log.uid,
|
||||
app,
|
||||
});
|
||||
}, [app, isLabel, log.datasourceType, log.uid, showFieldsStats]);
|
||||
|
||||
const refIdTooltip = useMemo(
|
||||
() => (app === CoreApp.Explore && log.dataFrame?.refId ? ` in query ${log.dataFrame?.refId}` : ''),
|
||||
[app, log.dataFrame?.refId]
|
||||
);
|
||||
const singleKey = keys.length === 1;
|
||||
const singleValue = values.length === 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.row}>
|
||||
{!disableActions && (
|
||||
<div className={styles.actions}>
|
||||
{onClickFilterLabel && (
|
||||
<AsyncIconButton
|
||||
name="search-plus"
|
||||
onClick={filterLabel}
|
||||
// We purposely want to pass a new function on every render to allow the active state to be updated when log details remains open between updates.
|
||||
isActive={labelFilterActive}
|
||||
tooltipSuffix={refIdTooltip}
|
||||
/>
|
||||
)}
|
||||
{onClickFilterOutLabel && (
|
||||
<IconButton
|
||||
name="search-minus"
|
||||
tooltip={
|
||||
app === CoreApp.Explore && log.dataFrame?.refId
|
||||
? t('logs.log-line-details.fields.filter-out-query', 'Filter out value in query {{query}}', {
|
||||
query: log.dataFrame?.refId,
|
||||
})
|
||||
: t('logs.log-line-details.fields.filter-out', 'Filter out value')
|
||||
}
|
||||
onClick={filterOutLabel}
|
||||
/>
|
||||
)}
|
||||
{singleKey && displayedFields.includes(keys[0]) && (
|
||||
<IconButton
|
||||
variant="primary"
|
||||
tooltip={t('logs.log-line-details.fields.toggle-field-button.hide-this-field', 'Hide this field')}
|
||||
name="eye"
|
||||
onClick={hideField}
|
||||
/>
|
||||
)}
|
||||
{singleKey && !displayedFields.includes(keys[0]) && (
|
||||
<IconButton
|
||||
tooltip={t(
|
||||
'logs.log-line-details.fields.toggle-field-button.field-instead-message',
|
||||
'Show this field instead of the message'
|
||||
)}
|
||||
name="eye"
|
||||
onClick={showField}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
variant={showFieldsStats ? 'primary' : 'secondary'}
|
||||
name="signal"
|
||||
tooltip={t('logs.log-line-details.fields.adhoc-statistics', 'Ad-hoc statistics')}
|
||||
className="stats-button"
|
||||
disabled={!singleKey}
|
||||
onClick={showStats}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.label}>{singleKey ? keys[0] : <MultipleValue values={keys} />}</div>
|
||||
<div className={styles.value}>
|
||||
<div className={styles.valueContainer}>
|
||||
{singleValue ? values[0] : <MultipleValue showCopy={true} values={values} />}
|
||||
{singleValue && <ClipboardButtonWrapper value={values[0]} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{links?.map((link, i) => {
|
||||
if (link.onClick && onPinLine) {
|
||||
const originalOnClick = link.onClick;
|
||||
link.onClick = (e, origin) => {
|
||||
// Pin the line
|
||||
onPinLine(log);
|
||||
|
||||
// Execute the link onClick function
|
||||
originalOnClick(e, origin);
|
||||
|
||||
closeDetails();
|
||||
};
|
||||
}
|
||||
return (
|
||||
<div className={styles.row} key={`${link.title}-${i}`}>
|
||||
<div className={disableActions ? styles.linkNoActions : styles.link}>
|
||||
<DataLinkButton
|
||||
buttonProps={{
|
||||
// Show tooltip message if max number of pinned lines has been reached
|
||||
tooltip:
|
||||
typeof pinLineButtonTooltipTitle === 'object' && link.onClick
|
||||
? pinLineButtonTooltipTitle
|
||||
: undefined,
|
||||
variant: 'secondary',
|
||||
fill: 'outline',
|
||||
...(link.icon && { icon: link.icon }),
|
||||
}}
|
||||
link={link}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{showFieldsStats && fieldStats && (
|
||||
<div className={styles.row}>
|
||||
<div />
|
||||
<div className={disableActions ? undefined : styles.statsColumn}>
|
||||
<LogLabelStats
|
||||
className={styles.stats}
|
||||
stats={fieldStats}
|
||||
label={keys[0]}
|
||||
value={values[0]}
|
||||
rowCount={fieldCount}
|
||||
isLabel={isLabel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getFieldStyles = (theme: GrafanaTheme2) => ({
|
||||
row: css({
|
||||
display: 'contents',
|
||||
}),
|
||||
actions: css({
|
||||
whiteSpace: 'nowrap',
|
||||
}),
|
||||
label: css({
|
||||
overflowWrap: 'break-word',
|
||||
wordBreak: 'break-word',
|
||||
}),
|
||||
value: css({
|
||||
overflowWrap: 'break-word',
|
||||
wordBreak: 'break-word',
|
||||
button: {
|
||||
visibility: 'hidden',
|
||||
},
|
||||
'&:hover': {
|
||||
button: {
|
||||
visibility: 'visible',
|
||||
},
|
||||
},
|
||||
}),
|
||||
link: css({
|
||||
gridColumn: 'span 3',
|
||||
}),
|
||||
linkNoActions: css({
|
||||
gridColumn: 'span 2',
|
||||
}),
|
||||
stats: css({
|
||||
paddingRight: theme.spacing(1),
|
||||
wordBreak: 'break-all',
|
||||
width: '100%',
|
||||
maxWidth: '50vh',
|
||||
}),
|
||||
statsColumn: css({
|
||||
gridColumn: 'span 2',
|
||||
}),
|
||||
valueContainer: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
lineHeight: theme.typography.body.lineHeight,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
}),
|
||||
});
|
||||
|
||||
const ClipboardButtonWrapper = ({ value }: { value: string }) => {
|
||||
const styles = useStyles2(getClipboardButtonStyles);
|
||||
return (
|
||||
<div className={styles.button}>
|
||||
<ClipboardButton
|
||||
getText={() => value}
|
||||
title={t('logs.log-line-details.fields.copy-value-to-clipboard', 'Copy value to clipboard')}
|
||||
fill="text"
|
||||
variant="secondary"
|
||||
icon="copy"
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getClipboardButtonStyles = (theme: GrafanaTheme2) => ({
|
||||
button: css({
|
||||
'& > button': {
|
||||
color: theme.colors.text.secondary,
|
||||
padding: 0,
|
||||
justifyContent: 'center',
|
||||
borderRadius: theme.shape.radius.circle,
|
||||
height: theme.spacing(theme.components.height.sm),
|
||||
width: theme.spacing(theme.components.height.sm),
|
||||
svg: {
|
||||
margin: 0,
|
||||
},
|
||||
|
||||
'span > div': {
|
||||
top: '-5px',
|
||||
'& button': {
|
||||
color: theme.colors.success.main,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const MultipleValue = ({ showCopy, values = [] }: { showCopy?: boolean; values: string[] }) => {
|
||||
if (values.every((val) => val === '')) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<table>
|
||||
<tbody>
|
||||
{values.map((val, i) => {
|
||||
return (
|
||||
<tr key={`${val}-${i}`}>
|
||||
<td>{val}</td>
|
||||
<td>{showCopy && val !== '' && <ClipboardButtonWrapper value={val} />}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
interface AsyncIconButtonProps extends Pick<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> {
|
||||
name: IconName;
|
||||
isActive(): Promise<boolean>;
|
||||
tooltipSuffix: string;
|
||||
}
|
||||
|
||||
const AsyncIconButton = ({ isActive, tooltipSuffix, ...rest }: AsyncIconButtonProps) => {
|
||||
const [active, setActive] = useState(false);
|
||||
const tooltip = active ? 'Remove filter' : 'Filter for value';
|
||||
|
||||
useEffect(() => {
|
||||
isActive().then(setActive);
|
||||
}, [isActive]);
|
||||
|
||||
return <IconButton {...rest} variant={active ? 'primary' : undefined} tooltip={tooltip + tooltipSuffix} />;
|
||||
};
|
||||
|
||||
function filterFields(fields: FieldDef[], search: string) {
|
||||
const keys = fields.map((field) => field.keys.join(' '));
|
||||
const keysIdx = fuzzySearch(keys, search);
|
||||
const values = fields.map((field) => field.values.join(' '));
|
||||
const valuesIdx = fuzzySearch(values, search);
|
||||
|
||||
const results = keysIdx.map((index) => fields[index]);
|
||||
valuesIdx.forEach((index) => {
|
||||
if (!results.includes(fields[index])) {
|
||||
results.push(fields[index]);
|
||||
}
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function filterLabels(labels: LabelWithLinks[], search: string) {
|
||||
const keys = labels.map((field) => field.key);
|
||||
const keysIdx = fuzzySearch(keys, search);
|
||||
const values = labels.map((field) => field.value);
|
||||
const valuesIdx = fuzzySearch(values, search);
|
||||
|
||||
const results = keysIdx.map((index) => labels[index]);
|
||||
valuesIdx.forEach((index) => {
|
||||
if (!results.includes(labels[index])) {
|
||||
results.push(labels[index]);
|
||||
}
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useCallback, useMemo, MouseEvent, useRef, ChangeEvent } from 'react';
|
||||
|
||||
import { colorManipulator, GrafanaTheme2, LogRowModel } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { IconButton, Input, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { copyText, handleOpenLogsContextClick } from '../../utils';
|
||||
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
|
||||
|
||||
import { useLogIsPinned, useLogListContext } from './LogListContext';
|
||||
import { LogListModel } from './processing';
|
||||
|
||||
interface Props {
|
||||
log: LogListModel;
|
||||
search: string;
|
||||
onSearch(newSearch: string): void;
|
||||
}
|
||||
|
||||
export const LogLineDetailsHeader = ({ log, search, onSearch }: Props) => {
|
||||
const {
|
||||
closeDetails,
|
||||
displayedFields,
|
||||
getRowContextQuery,
|
||||
logSupportsContext,
|
||||
onClickHideField,
|
||||
onClickShowField,
|
||||
onOpenContext,
|
||||
onPermalinkClick,
|
||||
onPinLine,
|
||||
onUnpinLine,
|
||||
} = useLogListContext();
|
||||
const pinned = useLogIsPinned(log);
|
||||
const styles = useStyles2(getStyles);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const copyLogLine = useCallback(() => {
|
||||
copyText(log.entry, containerRef);
|
||||
}, [log.entry]);
|
||||
|
||||
const copyLinkToLogLine = useCallback(() => {
|
||||
onPermalinkClick?.(log);
|
||||
}, [log, onPermalinkClick]);
|
||||
|
||||
const togglePinning = useCallback(() => {
|
||||
if (pinned) {
|
||||
onUnpinLine?.(log);
|
||||
} else {
|
||||
onPinLine?.(log);
|
||||
}
|
||||
}, [log, onPinLine, onUnpinLine, pinned]);
|
||||
|
||||
const shouldlogSupportsContext = useMemo(
|
||||
() => (logSupportsContext ? logSupportsContext(log) : false),
|
||||
[log, logSupportsContext]
|
||||
);
|
||||
|
||||
const showContext = useCallback(
|
||||
async (event: MouseEvent<HTMLElement>) => {
|
||||
handleOpenLogsContextClick(event, log, getRowContextQuery, (log: LogRowModel) => onOpenContext?.(log, () => {}));
|
||||
},
|
||||
[onOpenContext, getRowContextQuery, log]
|
||||
);
|
||||
|
||||
const showLogLineToggle = onClickHideField && onClickShowField && displayedFields.length > 0;
|
||||
const logLineDisplayed = displayedFields.includes(LOG_LINE_BODY_FIELD_NAME);
|
||||
|
||||
const toggleLogLine = useCallback(() => {
|
||||
if (logLineDisplayed) {
|
||||
onClickHideField?.(LOG_LINE_BODY_FIELD_NAME);
|
||||
} else {
|
||||
onClickShowField?.(LOG_LINE_BODY_FIELD_NAME);
|
||||
}
|
||||
}, [logLineDisplayed, onClickHideField, onClickShowField]);
|
||||
|
||||
const clearSearch = useMemo(
|
||||
() => (
|
||||
<IconButton
|
||||
name="times"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onSearch('');
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = '';
|
||||
}
|
||||
}}
|
||||
tooltip={t('logs.log-line-details.clear-search', 'Clear')}
|
||||
/>
|
||||
),
|
||||
[onSearch]
|
||||
);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
onSearch(e.target.value);
|
||||
},
|
||||
[onSearch]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.header} ref={containerRef}>
|
||||
<Input
|
||||
onChange={handleSearch}
|
||||
placeholder={t('logs.log-line-details.search-placeholder', 'Search field names and values')}
|
||||
ref={inputRef}
|
||||
suffix={search !== '' ? clearSearch : undefined}
|
||||
/>
|
||||
{showLogLineToggle && (
|
||||
<IconButton
|
||||
tooltip={
|
||||
logLineDisplayed
|
||||
? t('logs.log-line-details.hide-log-line', 'Hide log line')
|
||||
: t('logs.log-line-details.show-log-line', 'Show log line')
|
||||
}
|
||||
tooltipPlacement="top"
|
||||
size="md"
|
||||
name="eye"
|
||||
onClick={toggleLogLine}
|
||||
tabIndex={0}
|
||||
variant={logLineDisplayed ? 'primary' : undefined}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
tooltip={t('logs.log-line-details.copy-to-clipboard', 'Copy to clipboard')}
|
||||
tooltipPlacement="top"
|
||||
size="md"
|
||||
name="copy"
|
||||
onClick={copyLogLine}
|
||||
tabIndex={0}
|
||||
/>
|
||||
{onPermalinkClick && log.rowId !== undefined && log.uid && (
|
||||
<IconButton
|
||||
tooltip={t('logs.log-line-details.copy-shortlink', 'Copy shortlink')}
|
||||
tooltipPlacement="top"
|
||||
size="md"
|
||||
name="share-alt"
|
||||
onClick={copyLinkToLogLine}
|
||||
tabIndex={0}
|
||||
/>
|
||||
)}
|
||||
{pinned && onUnpinLine && (
|
||||
<IconButton
|
||||
size="md"
|
||||
name="gf-pin"
|
||||
onClick={togglePinning}
|
||||
tooltip={t('logs.log-line-details.unpin-line', 'Unpin log')}
|
||||
tooltipPlacement="top"
|
||||
tabIndex={0}
|
||||
variant="primary"
|
||||
/>
|
||||
)}
|
||||
{!pinned && onPinLine && (
|
||||
<IconButton
|
||||
size="md"
|
||||
name="gf-pin"
|
||||
onClick={togglePinning}
|
||||
tooltip={t('logs.log-line-details.pin-line', 'Pin log')}
|
||||
tooltipPlacement="top"
|
||||
tabIndex={0}
|
||||
/>
|
||||
)}
|
||||
{shouldlogSupportsContext && (
|
||||
<IconButton
|
||||
size="md"
|
||||
name="gf-show-context"
|
||||
onClick={showContext}
|
||||
tooltip={t('logs.log-line-details.show-context', 'Show context')}
|
||||
tooltipPlacement="top"
|
||||
tabIndex={0}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
name="times"
|
||||
aria-label={t('logs.log-line-details.close', 'Close log details')}
|
||||
onClick={closeDetails}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
container: css({
|
||||
overflow: 'auto',
|
||||
height: '100%',
|
||||
}),
|
||||
scrollContainer: css({
|
||||
overflow: 'auto',
|
||||
height: '100%',
|
||||
}),
|
||||
header: css({
|
||||
alignItems: 'center',
|
||||
background: theme.colors.background.canvas,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: theme.spacing(0.75),
|
||||
zIndex: theme.zIndex.navbarFixed,
|
||||
height: theme.spacing(5.5),
|
||||
marginBottom: theme.spacing(1),
|
||||
padding: theme.spacing(0.5, 1),
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
}),
|
||||
copyLogButton: css({
|
||||
padding: 0,
|
||||
height: theme.spacing(4),
|
||||
width: theme.spacing(2.5),
|
||||
overflow: 'hidden',
|
||||
'&:hover': {
|
||||
backgroundColor: colorManipulator.alpha(theme.colors.text.primary, 0.12),
|
||||
},
|
||||
}),
|
||||
componentWrapper: css({
|
||||
padding: theme.spacing(0, 1, 1, 1),
|
||||
}),
|
||||
});
|
||||
@@ -191,6 +191,7 @@ export const LogList = ({
|
||||
initialScrollPosition={initialScrollPosition}
|
||||
loading={loading}
|
||||
loadMore={loadMore}
|
||||
logOptionsStorageKey={logOptionsStorageKey}
|
||||
logs={logs}
|
||||
showControls={showControls}
|
||||
timeRange={timeRange}
|
||||
@@ -209,6 +210,7 @@ const LogListComponent = ({
|
||||
initialScrollPosition = 'top',
|
||||
loading,
|
||||
loadMore,
|
||||
logOptionsStorageKey,
|
||||
logs,
|
||||
showControls,
|
||||
timeRange,
|
||||
@@ -459,7 +461,7 @@ const LogListComponent = ({
|
||||
{showDetails.length > 0 && (
|
||||
<LogLineDetails
|
||||
containerElement={containerElement}
|
||||
getFieldLinks={getFieldLinks}
|
||||
logOptionsStorageKey={logOptionsStorageKey}
|
||||
logs={filteredLogs}
|
||||
onResize={handleLogDetailsResize}
|
||||
/>
|
||||
|
||||
@@ -73,7 +73,7 @@ export const defaultValue: LogListContextData = {
|
||||
setWrapLogMessage: jest.fn(),
|
||||
closeDetails: jest.fn(),
|
||||
detailsDisplayed: jest.fn(),
|
||||
detailsWidth: 0,
|
||||
detailsWidth: 300,
|
||||
downloadLogs: jest.fn(),
|
||||
enableLogDetails: false,
|
||||
filterLevels: [],
|
||||
@@ -130,11 +130,12 @@ export const LogListContextProvider = ({
|
||||
onUnpinLine = jest.fn(),
|
||||
permalinkedLogId,
|
||||
pinnedLogs = [],
|
||||
showDetails = [],
|
||||
showTime = true,
|
||||
sortOrder = LogsSortOrder.Descending,
|
||||
syntaxHighlighting = true,
|
||||
wrapLogMessage = true,
|
||||
}: Partial<Props>) => {
|
||||
}: Partial<Props> & { showDetails?: LogListModel[] }) => {
|
||||
const hasLogsWithErrors = logs.some((log) => !!checkLogsError(log));
|
||||
const hasSampledLogs = logs.some((log) => !!checkLogsSampled(log));
|
||||
|
||||
@@ -172,6 +173,7 @@ export const LogListContextProvider = ({
|
||||
setSortOrder: jest.fn(),
|
||||
setSyntaxHighlighting: jest.fn(),
|
||||
setWrapLogMessage: jest.fn(),
|
||||
showDetails,
|
||||
showTime,
|
||||
sortOrder,
|
||||
syntaxHighlighting,
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
Field,
|
||||
LogsMetaItem,
|
||||
} from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { getConfig } from 'app/core/config';
|
||||
|
||||
import { getLogsExtractFields } from '../explore/Logs/LogsTable';
|
||||
@@ -391,35 +392,33 @@ function getLabelTypeFromFrame(labelKey: string, frame: DataFrame, index: number
|
||||
return typeField[labelKey] ?? null;
|
||||
}
|
||||
|
||||
export function getLabelTypeFromRow(label: string, row: LogRowModel) {
|
||||
export function getLabelTypeFromRow(label: string, row: LogRowModel, plural = false) {
|
||||
if (!row.datasourceType) {
|
||||
return null;
|
||||
}
|
||||
const idField = row.dataFrame.fields.find((field) => field.name === 'id');
|
||||
if (!idField) {
|
||||
return null;
|
||||
}
|
||||
const rowIndex = idField.values.findIndex((id) => id === row.rowId);
|
||||
if (rowIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
const labelType = getLabelTypeFromFrame(label, row.dataFrame, rowIndex);
|
||||
const labelType = getLabelTypeFromFrame(label, row.dataFrame, row.rowIndex);
|
||||
if (!labelType) {
|
||||
return null;
|
||||
}
|
||||
return getDataSourceLabelType(labelType, row.datasourceType);
|
||||
return getDataSourceLabelType(labelType, row.datasourceType, plural);
|
||||
}
|
||||
|
||||
function getDataSourceLabelType(labelType: string, datasourceType: string) {
|
||||
function getDataSourceLabelType(labelType: string, datasourceType: string, plural: boolean) {
|
||||
switch (datasourceType) {
|
||||
case 'loki':
|
||||
switch (labelType) {
|
||||
case 'I':
|
||||
return 'Indexed label';
|
||||
return plural
|
||||
? t('logs.fields.type.loki.indexed-label-plural', 'Indexed labels')
|
||||
: t('logs.fields.type.loki.indexed-label', 'Indexed label');
|
||||
case 'S':
|
||||
return 'Structured metadata';
|
||||
return plural
|
||||
? t('logs.fields.type.loki.structured-metadata-plural', 'Structured metadata')
|
||||
: t('logs.fields.type.loki.structured-metadata', 'Structured metadata');
|
||||
case 'P':
|
||||
return 'Parsed label';
|
||||
return plural
|
||||
? t('logs.fields.type.loki.parsed-label-plural', 'Parsed fields')
|
||||
: t('logs.fields.type.loki.parsedl-label', 'Parsed field');
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -102,6 +102,16 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(TablePanel)
|
||||
description: t('table-new.description-column-filter', 'Enables/disables field filters in table'),
|
||||
defaultValue: defaultTableFieldOptions.filterable,
|
||||
})
|
||||
.addBooleanSwitch({
|
||||
path: 'wrapHeaderText',
|
||||
name: t('table.name-wrap-header-text', 'Wrap header text'),
|
||||
description: t(
|
||||
'table.description-wrap-header-text',
|
||||
'Enables text wrapping for the field name in the table header'
|
||||
),
|
||||
category,
|
||||
defaultValue: defaultTableFieldOptions.wrapHeaderText,
|
||||
})
|
||||
.addBooleanSwitch({
|
||||
path: 'hidden',
|
||||
name: t('table-new.name-hide-in-table', 'Hide in table'),
|
||||
|
||||
@@ -8356,6 +8356,18 @@
|
||||
"description-enable-infinite-scrolling": "Experimental. Request more results by scrolling to the bottom of the logs list.",
|
||||
"description-enable-syntax-highlighting": "Use a predefined syntax coloring grammar to highlight relevant parts of the log lines",
|
||||
"description-show-controls": "Display controls to jump to the last or first log line, and filters by log level",
|
||||
"fields": {
|
||||
"type": {
|
||||
"loki": {
|
||||
"indexed-label": "Indexed label",
|
||||
"indexed-label-plural": "Indexed labels",
|
||||
"parsed-label-plural": "Parsed fields",
|
||||
"parsedl-label": "Parsed field",
|
||||
"structured-metadata": "Structured metadata",
|
||||
"structured-metadata-plural": "Structured metadata"
|
||||
}
|
||||
}
|
||||
},
|
||||
"font-size-options": {
|
||||
"label-default": "Default",
|
||||
"label-small": "Small"
|
||||
@@ -8379,7 +8391,6 @@
|
||||
"label-wrap-lines": "Wrap lines"
|
||||
},
|
||||
"log-details": {
|
||||
"close": "Close log details",
|
||||
"fields": "Fields",
|
||||
"links": "Links",
|
||||
"log-line": "Log line",
|
||||
@@ -8405,6 +8416,35 @@
|
||||
"show-more": "show more",
|
||||
"tooltip-error": "Error: {{errorMessage}}"
|
||||
},
|
||||
"log-line-details": {
|
||||
"clear-search": "Clear",
|
||||
"close": "Close log details",
|
||||
"copy-shortlink": "Copy shortlink",
|
||||
"copy-to-clipboard": "Copy to clipboard",
|
||||
"fields": {
|
||||
"adhoc-statistics": "Ad-hoc statistics",
|
||||
"copy-value-to-clipboard": "Copy value to clipboard",
|
||||
"filter-out": "Filter out value",
|
||||
"filter-out-query": "Filter out value in query {{query}}",
|
||||
"toggle-field-button": {
|
||||
"field-instead-message": "Show this field instead of the message",
|
||||
"hide-this-field": "Hide this field"
|
||||
}
|
||||
},
|
||||
"fields-section": "Fields",
|
||||
"hide-log-line": "Hide log line",
|
||||
"links-section": "Links",
|
||||
"log-line-section": "Log line",
|
||||
"no-details": "No fields to display.",
|
||||
"pin-line": "Pin log",
|
||||
"search": {
|
||||
"no-results": "No results to display."
|
||||
},
|
||||
"search-placeholder": "Search field names and values",
|
||||
"show-context": "Show context",
|
||||
"show-log-line": "Show log line",
|
||||
"unpin-line": "Unpin log"
|
||||
},
|
||||
"log-line-menu": {
|
||||
"copy-link": "Copy link to log line",
|
||||
"copy-log": "Copy log line",
|
||||
@@ -8531,6 +8571,7 @@
|
||||
"un-themed-log-details": {
|
||||
"aria-label-data-links": "Data links",
|
||||
"aria-label-fields": "Fields",
|
||||
"aria-label-line": "Log line",
|
||||
"aria-label-log-level": "Log level",
|
||||
"aria-label-no-details": "No details"
|
||||
},
|
||||
@@ -11388,6 +11429,7 @@
|
||||
"description-count-rows": "Display a single count for all data rows",
|
||||
"description-fields": "Select the fields that should be calculated",
|
||||
"description-min-column-width": "The minimum width for column auto resizing",
|
||||
"description-wrap-header-text": "Enables text wrapping for the field name in the table header",
|
||||
"image-cell-options-editor": {
|
||||
"description-alt-text": "Alternative text that will be displayed if an image can't be displayed or for users who use a screen reader",
|
||||
"description-title-text": "Text that will be displayed when the image is hovered by a cursor",
|
||||
@@ -11408,6 +11450,7 @@
|
||||
"name-min-column-width": "Minimum column width",
|
||||
"name-show-table-footer": "Show table footer",
|
||||
"name-show-table-header": "Show table header",
|
||||
"name-wrap-header-text": "Wrap header text",
|
||||
"placeholder-column-width": "auto",
|
||||
"placeholder-fields": "All Numeric Fields"
|
||||
},
|
||||
|
||||
@@ -3542,11 +3542,11 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@grafana/scenes-react@npm:^6.26.1":
|
||||
version: 6.26.1
|
||||
resolution: "@grafana/scenes-react@npm:6.26.1"
|
||||
"@grafana/scenes-react@npm:^6.27.0":
|
||||
version: 6.27.0
|
||||
resolution: "@grafana/scenes-react@npm:6.27.0"
|
||||
dependencies:
|
||||
"@grafana/scenes": "npm:6.26.1"
|
||||
"@grafana/scenes": "npm:6.27.0"
|
||||
lru-cache: "npm:^10.2.2"
|
||||
react-use: "npm:^17.4.0"
|
||||
peerDependencies:
|
||||
@@ -3558,13 +3558,13 @@ __metadata:
|
||||
react: ^18.0.0
|
||||
react-dom: ^18.0.0
|
||||
react-router-dom: ^6.28.0
|
||||
checksum: 10/65ab66edcc19cdd9e05071317885688106c09c72ad6ada1429a3d3bdc0605814493e3a428af9178f90f13f620e10169280c33bbc63fa6c0dd6c40b81378cc547
|
||||
checksum: 10/4e52ad816a5ca05715ed0d317ece8ba8ccf857367f37c637edef11069b8f0db28ed41266f3bc8439ccf6af390d66e2351e17f026eb5cdc5ae516ab9afad6d18b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@grafana/scenes@npm:6.26.1, @grafana/scenes@npm:^6.26.1":
|
||||
version: 6.26.1
|
||||
resolution: "@grafana/scenes@npm:6.26.1"
|
||||
"@grafana/scenes@npm:6.27.0, @grafana/scenes@npm:^6.27.0":
|
||||
version: 6.27.0
|
||||
resolution: "@grafana/scenes@npm:6.27.0"
|
||||
dependencies:
|
||||
"@floating-ui/react": "npm:^0.26.16"
|
||||
"@leeoniya/ufuzzy": "npm:^1.0.16"
|
||||
@@ -3584,7 +3584,7 @@ __metadata:
|
||||
react: ^18.0.0
|
||||
react-dom: ^18.0.0
|
||||
react-router-dom: ^6.28.0
|
||||
checksum: 10/aed5a13a17fe8ed172637cd75edae9d20815321d2145ba0b4a179950969b6a1bcbe732ac42312b7ef34e7454d18725fd23ca58934fad43ce77b1edc2efd62ef7
|
||||
checksum: 10/c8328cc323fe513d5bbb70eea44bcd08ff7b444e4266134151ccef1f038453f99d36dec0785f95bb0565c0c1623f463000721f9b22401d45ba79bdec14112332
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -18202,8 +18202,8 @@ __metadata:
|
||||
"@grafana/plugin-ui": "npm:0.10.7"
|
||||
"@grafana/prometheus": "workspace:*"
|
||||
"@grafana/runtime": "workspace:*"
|
||||
"@grafana/scenes": "npm:^6.26.1"
|
||||
"@grafana/scenes-react": "npm:^6.26.1"
|
||||
"@grafana/scenes": "npm:^6.27.0"
|
||||
"@grafana/scenes-react": "npm:^6.27.0"
|
||||
"@grafana/schema": "workspace:*"
|
||||
"@grafana/sql": "workspace:*"
|
||||
"@grafana/test-utils": "workspace:*"
|
||||
|
||||
Reference in New Issue
Block a user