Merge branch 'main' into hackathon14/api-clients
CodeQL checks / Detect whether code changed (push) Waiting to run Details
CodeQL checks / Analyze (actions) (push) Blocked by required conditions Details
CodeQL checks / Analyze (go) (push) Blocked by required conditions Details
CodeQL checks / Analyze (javascript) (push) Blocked by required conditions Details

This commit is contained in:
Clarity-89 2025-10-07 13:40:17 +02:00
commit 6d9d48ccfd
102 changed files with 5963 additions and 598 deletions

1
.github/CODEOWNERS vendored
View File

@ -88,6 +88,7 @@
/apps/preferences/ @grafana/grafana-app-platform-squad @grafana/grafana-frontend-platform
/apps/shorturl/ @grafana/sharing-squad
/apps/secret/ @grafana/grafana-operator-experience-squad
/apps/scope/ @grafana/grafana-operator-experience-squad
/apps/investigations/ @fcjack @matryer @svennergr
/apps/advisor/ @grafana/plugins-platform-backend
/apps/iam/ @grafana/access-squad

View File

@ -99,6 +99,7 @@ COPY apps/correlations apps/correlations
COPY apps/preferences apps/preferences
COPY apps/provisioning apps/provisioning
COPY apps/secret apps/secret
COPY apps/scope apps/scope
COPY apps/investigations apps/investigations
COPY apps/advisor apps/advisor
COPY apps/dashboard apps/dashboard

42
apps/scope/go.mod Normal file
View File

@ -0,0 +1,42 @@
module github.com/grafana/grafana/apps/scope
go 1.24.6
require (
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20251007081214-26e147d01f0a
k8s.io/apimachinery v0.34.1
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b
)
require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/net v0.44.0 // indirect
golang.org/x/text v0.29.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
)

118
apps/scope/go.sum Normal file
View File

@ -0,0 +1,118 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20251007081214-26e147d01f0a h1:L7xgV9mP6MRF3L2/vDOjNR7heaBPbXPMGTDN9/jXSFQ=
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20251007081214-26e147d01f0a/go.mod h1:OK8NwS87D5YphchOcAsiIWk/feMZ0EzfAGME1Kff860=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4=
k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA=
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts=
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y=
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE=
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=

View File

@ -0,0 +1,6 @@
// +k8s:deepcopy-gen=package
// +k8s:openapi-gen=true
// +k8s:defaulter-gen=TypeMeta
// +groupName=scope.grafana.app
package v0alpha1 // import "github.com/grafana/grafana/apps/pkg/apis/scope/v0alpha1"

View File

@ -0,0 +1,168 @@
package v0alpha1
import (
"fmt"
"time"
"github.com/grafana/grafana/pkg/apimachinery/utils"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
)
const (
GROUP = "scope.grafana.app"
VERSION = "v0alpha1"
APIVERSION = GROUP + "/" + VERSION
)
var ScopeResourceInfo = utils.NewResourceInfo(GROUP, VERSION,
"scopes", "scope", "Scope",
func() runtime.Object { return &Scope{} },
func() runtime.Object { return &ScopeList{} },
utils.TableColumns{
Definition: []metav1.TableColumnDefinition{
{Name: "Name", Type: "string", Format: "name"},
{Name: "Created At", Type: "date"},
{Name: "Title", Type: "string"},
{Name: "Filters", Type: "array"},
},
Reader: func(obj any) ([]interface{}, error) {
m, ok := obj.(*Scope)
if !ok {
return nil, fmt.Errorf("expected scope")
}
return []interface{}{
m.Name,
m.CreationTimestamp.UTC().Format(time.RFC3339),
m.Spec.Title,
m.Spec.Filters,
}, nil
},
}, // default table converter
)
var ScopeDashboardBindingResourceInfo = utils.NewResourceInfo(GROUP, VERSION,
"scopedashboardbindings", "scopedashboardbinding", "ScopeDashboardBinding",
func() runtime.Object { return &ScopeDashboardBinding{} },
func() runtime.Object { return &ScopeDashboardBindingList{} },
utils.TableColumns{
Definition: []metav1.TableColumnDefinition{
{Name: "Name", Type: "string", Format: "name"},
{Name: "Created At", Type: "date"},
{Name: "Dashboard", Type: "string"},
{Name: "Scope", Type: "string"},
},
Reader: func(obj any) ([]interface{}, error) {
m, ok := obj.(*ScopeDashboardBinding)
if !ok {
return nil, fmt.Errorf("expected scope dashboard binding")
}
return []interface{}{
m.Name,
m.CreationTimestamp.UTC().Format(time.RFC3339),
m.Spec.Dashboard,
m.Spec.Scope,
}, nil
},
},
)
var ScopeNavigationResourceInfo = utils.NewResourceInfo(GROUP, VERSION,
"scopenavigations", "scopenavigation", "ScopeNavigation",
func() runtime.Object { return &ScopeNavigation{} },
func() runtime.Object { return &ScopeNavigationList{} },
utils.TableColumns{
Definition: []metav1.TableColumnDefinition{
{Name: "Name", Type: "string", Format: "name"},
{Name: "Created At", Type: "date"},
{Name: "URL", Type: "string"},
{Name: "Scope", Type: "string"},
},
Reader: func(obj any) ([]interface{}, error) {
m, ok := obj.(*ScopeNavigation)
if !ok {
return nil, fmt.Errorf("expected scope navigation")
}
return []interface{}{
m.Name,
m.CreationTimestamp.UTC().Format(time.RFC3339),
m.Spec.URL,
m.Spec.Scope,
}, nil
},
},
)
var ScopeNodeResourceInfo = utils.NewResourceInfo(GROUP, VERSION,
"scopenodes", "scopenode", "ScopeNode",
func() runtime.Object { return &ScopeNode{} },
func() runtime.Object { return &ScopeNodeList{} },
utils.TableColumns{
Definition: []metav1.TableColumnDefinition{
{Name: "Name", Type: "string", Format: "name"},
{Name: "Created At", Type: "date"},
{Name: "Title", Type: "string"},
{Name: "Parent Name", Type: "string"},
{Name: "Node Type", Type: "string"},
{Name: "Link Type", Type: "string"},
{Name: "Link ID", Type: "string"},
},
Reader: func(obj any) ([]interface{}, error) {
m, ok := obj.(*ScopeNode)
if !ok {
return nil, fmt.Errorf("expected scope node")
}
return []interface{}{
m.Name,
m.CreationTimestamp.UTC().Format(time.RFC3339),
m.Spec.Title,
m.Spec.ParentName,
m.Spec.NodeType,
m.Spec.LinkType,
m.Spec.LinkID,
}, nil
},
}, // default table converter
)
var (
// SchemeGroupVersion is group version used to register these objects
SchemeGroupVersion = schema.GroupVersion{Group: GROUP, Version: VERSION}
InternalGroupVersion = schema.GroupVersion{Group: GROUP, Version: runtime.APIVersionInternal}
// SchemaBuilder is used by standard codegen
SchemeBuilder runtime.SchemeBuilder
localSchemeBuilder = &SchemeBuilder
AddToScheme = localSchemeBuilder.AddToScheme
)
func init() {
localSchemeBuilder.Register(func(s *runtime.Scheme) error {
return AddKnownTypes(SchemeGroupVersion, s)
})
}
// Adds the list of known types to the given scheme.
func AddKnownTypes(gv schema.GroupVersion, scheme *runtime.Scheme) error {
scheme.AddKnownTypes(gv,
&Scope{},
&ScopeList{},
&ScopeDashboardBinding{},
&ScopeDashboardBindingList{},
&ScopeNode{},
&ScopeNodeList{},
&FindScopeNodeChildrenResults{},
&FindScopeDashboardBindingsResults{},
&ScopeNavigation{},
&ScopeNavigationList{},
&FindScopeNavigationsResults{},
)
//metav1.AddToGroupVersion(scheme, gv)
return nil
}
// Resource takes an unqualified resource and returns a Group qualified GroupResource
func Resource(resource string) schema.GroupResource {
return SchemeGroupVersion.WithResource(resource).GroupResource()
}

View File

@ -0,0 +1,238 @@
package v0alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
/*
Please keep pkg/promlib/models/query.go and pkg/promlib/models/scope.go in sync
with this file until this package is out of the grafana/grafana module.
*/
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type Scope struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec ScopeSpec `json:"spec,omitempty"`
}
type ScopeSpec struct {
Title string `json:"title"`
// Provides a default path for the scope. This refers to a list of nodes in the selector. This is used to display the title next to the selected scope and expand the selector to the proper path.
// This will override whichever is selected from in the selector.
// The path is a list of node ids, starting at the direct parent of the selected node towards the root.
// +listType=atomic
DefaultPath []string `json:"defaultPath,omitempty"`
// +listType=atomic
Filters []ScopeFilter `json:"filters,omitempty"`
}
type ScopeFilter struct {
Key string `json:"key"`
Value string `json:"value"`
// Values is used for operators that require multiple values (e.g. one-of and not-one-of).
// +listType=atomic
Values []string `json:"values,omitempty"`
Operator FilterOperator `json:"operator"`
}
// Type of the filter operator.
// +enum
type FilterOperator string
// Defines values for FilterOperator.
const (
FilterOperatorEquals FilterOperator = "equals"
FilterOperatorNotEquals FilterOperator = "not-equals"
FilterOperatorRegexMatch FilterOperator = "regex-match"
FilterOperatorRegexNotMatch FilterOperator = "regex-not-match"
FilterOperatorOneOf FilterOperator = "one-of"
FilterOperatorNotOneOf FilterOperator = "not-one-of"
)
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type ScopeList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []Scope `json:"items,omitempty"`
}
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type ScopeDashboardBinding struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec ScopeDashboardBindingSpec `json:"spec,omitempty"`
Status ScopeDashboardBindingStatus `json:"status,omitempty"`
}
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type ScopeDashboardBindingList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []ScopeDashboardBinding `json:"items,omitempty"`
}
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type FindScopeDashboardBindingsResults struct {
metav1.TypeMeta `json:",inline"`
Items []ScopeDashboardBinding `json:"items,omitempty"`
Message string `json:"message,omitempty"`
}
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type ScopeNode struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec ScopeNodeSpec `json:"spec,omitempty"`
}
type ScopeDashboardBindingSpec struct {
Dashboard string `json:"dashboard"`
Scope string `json:"scope"`
}
// Type of the item.
// +enum
// ScopeDashboardBindingStatus contains derived information about a ScopeDashboardBinding.
type ScopeDashboardBindingStatus struct {
// DashboardTitle should be populated and update from the dashboard
DashboardTitle string `json:"dashboardTitle"`
// Groups is used for the grouping of dashboards that are suggested based
// on a scope. The source of truth for this information has not been
// determined yet.
Groups []string `json:"groups,omitempty"`
// DashboardTitleConditions is a list of conditions that are used to determine if the dashboard title is valid.
// +optional
// +listType=map
// +listMapKey=type
DashboardTitleConditions []metav1.Condition `json:"dashboardTitleConditions,omitempty"`
// DashboardTitleConditions is a list of conditions that are used to determine if the list of groups is valid.
// +optional
// +listType=map
// +listMapKey=type
GroupsConditions []metav1.Condition `json:"groupsConditions,omitempty"`
}
type NodeType string
// Defines values for ItemType.
const (
NodeTypeContainer NodeType = "container"
NodeTypeLeaf NodeType = "leaf"
)
// Type of the item.
// +enum
type LinkType string
// Defines values for ItemType.
const (
LinkTypeScope LinkType = "scope"
)
type ScopeNodeSpec struct {
//+optional
ParentName string `json:"parentName,omitempty"`
NodeType NodeType `json:"nodeType"` // container | leaf
Title string `json:"title"`
Description string `json:"description,omitempty"`
DisableMultiSelect bool `json:"disableMultiSelect"`
LinkType LinkType `json:"linkType,omitempty"` // scope (later more things)
LinkID string `json:"linkId,omitempty"` // the k8s name
// ?? should this be a slice of links
}
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type ScopeNodeList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []ScopeNode `json:"items,omitempty"`
}
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type FindScopeNodeChildrenResults struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []ScopeNode `json:"items,omitempty"`
}
// Scoped navigation types
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type ScopeNavigation struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec ScopeNavigationSpec `json:"spec,omitempty"`
Status ScopeNavigationStatus `json:"status,omitempty"`
}
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type ScopeNavigationList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []ScopeNavigation `json:"items,omitempty"`
}
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type FindScopeNavigationsResults struct {
metav1.TypeMeta `json:",inline"`
Items []ScopeNavigation `json:"items,omitempty"`
Message string `json:"message,omitempty"`
}
type ScopeNavigationSpec struct {
URL string `json:"url"`
Scope string `json:"scope"`
}
// Type of the item.
// +enum
// ScopeNavigationStatus contains derived information about a ScopeNavigation.
type ScopeNavigationStatus struct {
// Title should be populated and update from the dashboard
Title string `json:"title"`
// Groups is used for the grouping of dashboards that are suggested based
// on a scope. The source of truth for this information has not been
// determined yet.
Groups []string `json:"groups,omitempty"`
// TitleConditions is a list of conditions that are used to determine if the title is valid.
// +optional
// +listType=map
// +listMapKey=type
TitleConditions []metav1.Condition `json:"titleConditions,omitempty"`
// GroupsConditions is a list of conditions that are used to determine if the list of groups is valid.
// +optional
// +listType=map
// +listMapKey=type
GroupsConditions []metav1.Condition `json:"groupsConditions,omitempty"`
}
// Type of the filter operator.
// +enum
type ScopeNavigationLinkType string
// Defines values for FilterOperator.
const (
ScopeNavigationLinkTypeURL ScopeNavigationLinkType = "url"
)

View File

@ -0,0 +1,519 @@
//go:build !ignore_autogenerated
// +build !ignore_autogenerated
// SPDX-License-Identifier: AGPL-3.0-only
// Code generated by deepcopy-gen. DO NOT EDIT.
package v0alpha1
import (
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
runtime "k8s.io/apimachinery/pkg/runtime"
)
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *FindScopeDashboardBindingsResults) DeepCopyInto(out *FindScopeDashboardBindingsResults) {
*out = *in
out.TypeMeta = in.TypeMeta
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]ScopeDashboardBinding, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FindScopeDashboardBindingsResults.
func (in *FindScopeDashboardBindingsResults) DeepCopy() *FindScopeDashboardBindingsResults {
if in == nil {
return nil
}
out := new(FindScopeDashboardBindingsResults)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *FindScopeDashboardBindingsResults) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *FindScopeNavigationsResults) DeepCopyInto(out *FindScopeNavigationsResults) {
*out = *in
out.TypeMeta = in.TypeMeta
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]ScopeNavigation, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FindScopeNavigationsResults.
func (in *FindScopeNavigationsResults) DeepCopy() *FindScopeNavigationsResults {
if in == nil {
return nil
}
out := new(FindScopeNavigationsResults)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *FindScopeNavigationsResults) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *FindScopeNodeChildrenResults) DeepCopyInto(out *FindScopeNodeChildrenResults) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]ScopeNode, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FindScopeNodeChildrenResults.
func (in *FindScopeNodeChildrenResults) DeepCopy() *FindScopeNodeChildrenResults {
if in == nil {
return nil
}
out := new(FindScopeNodeChildrenResults)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *FindScopeNodeChildrenResults) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Scope) DeepCopyInto(out *Scope) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Scope.
func (in *Scope) DeepCopy() *Scope {
if in == nil {
return nil
}
out := new(Scope)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *Scope) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ScopeDashboardBinding) DeepCopyInto(out *ScopeDashboardBinding) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
out.Spec = in.Spec
in.Status.DeepCopyInto(&out.Status)
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeDashboardBinding.
func (in *ScopeDashboardBinding) DeepCopy() *ScopeDashboardBinding {
if in == nil {
return nil
}
out := new(ScopeDashboardBinding)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *ScopeDashboardBinding) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ScopeDashboardBindingList) DeepCopyInto(out *ScopeDashboardBindingList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]ScopeDashboardBinding, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeDashboardBindingList.
func (in *ScopeDashboardBindingList) DeepCopy() *ScopeDashboardBindingList {
if in == nil {
return nil
}
out := new(ScopeDashboardBindingList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *ScopeDashboardBindingList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ScopeDashboardBindingSpec) DeepCopyInto(out *ScopeDashboardBindingSpec) {
*out = *in
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeDashboardBindingSpec.
func (in *ScopeDashboardBindingSpec) DeepCopy() *ScopeDashboardBindingSpec {
if in == nil {
return nil
}
out := new(ScopeDashboardBindingSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ScopeDashboardBindingStatus) DeepCopyInto(out *ScopeDashboardBindingStatus) {
*out = *in
if in.Groups != nil {
in, out := &in.Groups, &out.Groups
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.DashboardTitleConditions != nil {
in, out := &in.DashboardTitleConditions, &out.DashboardTitleConditions
*out = make([]v1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
if in.GroupsConditions != nil {
in, out := &in.GroupsConditions, &out.GroupsConditions
*out = make([]v1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeDashboardBindingStatus.
func (in *ScopeDashboardBindingStatus) DeepCopy() *ScopeDashboardBindingStatus {
if in == nil {
return nil
}
out := new(ScopeDashboardBindingStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ScopeFilter) DeepCopyInto(out *ScopeFilter) {
*out = *in
if in.Values != nil {
in, out := &in.Values, &out.Values
*out = make([]string, len(*in))
copy(*out, *in)
}
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeFilter.
func (in *ScopeFilter) DeepCopy() *ScopeFilter {
if in == nil {
return nil
}
out := new(ScopeFilter)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ScopeList) DeepCopyInto(out *ScopeList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]Scope, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeList.
func (in *ScopeList) DeepCopy() *ScopeList {
if in == nil {
return nil
}
out := new(ScopeList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *ScopeList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ScopeNavigation) DeepCopyInto(out *ScopeNavigation) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
out.Spec = in.Spec
in.Status.DeepCopyInto(&out.Status)
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeNavigation.
func (in *ScopeNavigation) DeepCopy() *ScopeNavigation {
if in == nil {
return nil
}
out := new(ScopeNavigation)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *ScopeNavigation) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ScopeNavigationList) DeepCopyInto(out *ScopeNavigationList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]ScopeNavigation, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeNavigationList.
func (in *ScopeNavigationList) DeepCopy() *ScopeNavigationList {
if in == nil {
return nil
}
out := new(ScopeNavigationList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *ScopeNavigationList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ScopeNavigationSpec) DeepCopyInto(out *ScopeNavigationSpec) {
*out = *in
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeNavigationSpec.
func (in *ScopeNavigationSpec) DeepCopy() *ScopeNavigationSpec {
if in == nil {
return nil
}
out := new(ScopeNavigationSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ScopeNavigationStatus) DeepCopyInto(out *ScopeNavigationStatus) {
*out = *in
if in.Groups != nil {
in, out := &in.Groups, &out.Groups
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.TitleConditions != nil {
in, out := &in.TitleConditions, &out.TitleConditions
*out = make([]v1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
if in.GroupsConditions != nil {
in, out := &in.GroupsConditions, &out.GroupsConditions
*out = make([]v1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeNavigationStatus.
func (in *ScopeNavigationStatus) DeepCopy() *ScopeNavigationStatus {
if in == nil {
return nil
}
out := new(ScopeNavigationStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ScopeNode) DeepCopyInto(out *ScopeNode) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
out.Spec = in.Spec
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeNode.
func (in *ScopeNode) DeepCopy() *ScopeNode {
if in == nil {
return nil
}
out := new(ScopeNode)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *ScopeNode) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ScopeNodeList) DeepCopyInto(out *ScopeNodeList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]ScopeNode, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeNodeList.
func (in *ScopeNodeList) DeepCopy() *ScopeNodeList {
if in == nil {
return nil
}
out := new(ScopeNodeList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *ScopeNodeList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ScopeNodeSpec) DeepCopyInto(out *ScopeNodeSpec) {
*out = *in
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeNodeSpec.
func (in *ScopeNodeSpec) DeepCopy() *ScopeNodeSpec {
if in == nil {
return nil
}
out := new(ScopeNodeSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ScopeSpec) DeepCopyInto(out *ScopeSpec) {
*out = *in
if in.DefaultPath != nil {
in, out := &in.DefaultPath, &out.DefaultPath
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.Filters != nil {
in, out := &in.Filters, &out.Filters
*out = make([]ScopeFilter, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScopeSpec.
func (in *ScopeSpec) DeepCopy() *ScopeSpec {
if in == nil {
return nil
}
out := new(ScopeSpec)
in.DeepCopyInto(out)
return out
}

View File

@ -0,0 +1,19 @@
//go:build !ignore_autogenerated
// +build !ignore_autogenerated
// SPDX-License-Identifier: AGPL-3.0-only
// Code generated by defaulter-gen. DO NOT EDIT.
package v0alpha1
import (
runtime "k8s.io/apimachinery/pkg/runtime"
)
// RegisterDefaults adds defaulters functions to the given scheme.
// Public to allow building arbitrary schemes.
// All generated defaulters are covering - they call all nested defaulters.
func RegisterDefaults(scheme *runtime.Scheme) error {
return nil
}

View File

@ -0,0 +1,934 @@
//go:build !ignore_autogenerated
// +build !ignore_autogenerated
// SPDX-License-Identifier: AGPL-3.0-only
// Code generated by openapi-gen. DO NOT EDIT.
package v0alpha1
import (
common "k8s.io/kube-openapi/pkg/common"
spec "k8s.io/kube-openapi/pkg/validation/spec"
)
func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition {
return map[string]common.OpenAPIDefinition{
"github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.FindScopeDashboardBindingsResults": schema_pkg_apis_scope_v0alpha1_FindScopeDashboardBindingsResults(ref),
"github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.FindScopeNavigationsResults": schema_pkg_apis_scope_v0alpha1_FindScopeNavigationsResults(ref),
"github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.FindScopeNodeChildrenResults": schema_pkg_apis_scope_v0alpha1_FindScopeNodeChildrenResults(ref),
"github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.Scope": schema_pkg_apis_scope_v0alpha1_Scope(ref),
"github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeDashboardBinding": schema_pkg_apis_scope_v0alpha1_ScopeDashboardBinding(ref),
"github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeDashboardBindingList": schema_pkg_apis_scope_v0alpha1_ScopeDashboardBindingList(ref),
"github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeDashboardBindingSpec": schema_pkg_apis_scope_v0alpha1_ScopeDashboardBindingSpec(ref),
"github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeDashboardBindingStatus": schema_pkg_apis_scope_v0alpha1_ScopeDashboardBindingStatus(ref),
"github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeFilter": schema_pkg_apis_scope_v0alpha1_ScopeFilter(ref),
"github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeList": schema_pkg_apis_scope_v0alpha1_ScopeList(ref),
"github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNavigation": schema_pkg_apis_scope_v0alpha1_ScopeNavigation(ref),
"github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNavigationList": schema_pkg_apis_scope_v0alpha1_ScopeNavigationList(ref),
"github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNavigationSpec": schema_pkg_apis_scope_v0alpha1_ScopeNavigationSpec(ref),
"github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNavigationStatus": schema_pkg_apis_scope_v0alpha1_ScopeNavigationStatus(ref),
"github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNode": schema_pkg_apis_scope_v0alpha1_ScopeNode(ref),
"github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNodeList": schema_pkg_apis_scope_v0alpha1_ScopeNodeList(ref),
"github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNodeSpec": schema_pkg_apis_scope_v0alpha1_ScopeNodeSpec(ref),
"github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeSpec": schema_pkg_apis_scope_v0alpha1_ScopeSpec(ref),
}
}
func schema_pkg_apis_scope_v0alpha1_FindScopeDashboardBindingsResults(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"kind": {
SchemaProps: spec.SchemaProps{
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
Type: []string{"string"},
Format: "",
},
},
"apiVersion": {
SchemaProps: spec.SchemaProps{
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
Type: []string{"string"},
Format: "",
},
},
"items": {
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeDashboardBinding"),
},
},
},
},
},
"message": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "",
},
},
},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeDashboardBinding"},
}
}
func schema_pkg_apis_scope_v0alpha1_FindScopeNavigationsResults(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"kind": {
SchemaProps: spec.SchemaProps{
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
Type: []string{"string"},
Format: "",
},
},
"apiVersion": {
SchemaProps: spec.SchemaProps{
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
Type: []string{"string"},
Format: "",
},
},
"items": {
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNavigation"),
},
},
},
},
},
"message": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "",
},
},
},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNavigation"},
}
}
func schema_pkg_apis_scope_v0alpha1_FindScopeNodeChildrenResults(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"kind": {
SchemaProps: spec.SchemaProps{
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
Type: []string{"string"},
Format: "",
},
},
"apiVersion": {
SchemaProps: spec.SchemaProps{
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
Type: []string{"string"},
Format: "",
},
},
"metadata": {
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"),
},
},
"items": {
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNode"),
},
},
},
},
},
},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNode", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"},
}
}
func schema_pkg_apis_scope_v0alpha1_Scope(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"kind": {
SchemaProps: spec.SchemaProps{
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
Type: []string{"string"},
Format: "",
},
},
"apiVersion": {
SchemaProps: spec.SchemaProps{
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
Type: []string{"string"},
Format: "",
},
},
"metadata": {
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"),
},
},
"spec": {
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeSpec"),
},
},
},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeSpec", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"},
}
}
func schema_pkg_apis_scope_v0alpha1_ScopeDashboardBinding(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"kind": {
SchemaProps: spec.SchemaProps{
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
Type: []string{"string"},
Format: "",
},
},
"apiVersion": {
SchemaProps: spec.SchemaProps{
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
Type: []string{"string"},
Format: "",
},
},
"metadata": {
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"),
},
},
"spec": {
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeDashboardBindingSpec"),
},
},
"status": {
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeDashboardBindingStatus"),
},
},
},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeDashboardBindingSpec", "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeDashboardBindingStatus", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"},
}
}
func schema_pkg_apis_scope_v0alpha1_ScopeDashboardBindingList(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"kind": {
SchemaProps: spec.SchemaProps{
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
Type: []string{"string"},
Format: "",
},
},
"apiVersion": {
SchemaProps: spec.SchemaProps{
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
Type: []string{"string"},
Format: "",
},
},
"metadata": {
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"),
},
},
"items": {
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeDashboardBinding"),
},
},
},
},
},
},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeDashboardBinding", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"},
}
}
func schema_pkg_apis_scope_v0alpha1_ScopeDashboardBindingSpec(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"dashboard": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
"scope": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
Required: []string{"dashboard", "scope"},
},
},
}
}
func schema_pkg_apis_scope_v0alpha1_ScopeDashboardBindingStatus(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Description: "Type of the item. ScopeDashboardBindingStatus contains derived information about a ScopeDashboardBinding.",
Type: []string{"object"},
Properties: map[string]spec.Schema{
"dashboardTitle": {
SchemaProps: spec.SchemaProps{
Description: "DashboardTitle should be populated and update from the dashboard",
Default: "",
Type: []string{"string"},
Format: "",
},
},
"groups": {
SchemaProps: spec.SchemaProps{
Description: "Groups is used for the grouping of dashboards that are suggested based on a scope. The source of truth for this information has not been determined yet.",
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
},
},
"dashboardTitleConditions": {
VendorExtensible: spec.VendorExtensible{
Extensions: spec.Extensions{
"x-kubernetes-list-map-keys": []interface{}{
"type",
},
"x-kubernetes-list-type": "map",
},
},
SchemaProps: spec.SchemaProps{
Description: "DashboardTitleConditions is a list of conditions that are used to determine if the dashboard title is valid.",
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.Condition"),
},
},
},
},
},
"groupsConditions": {
VendorExtensible: spec.VendorExtensible{
Extensions: spec.Extensions{
"x-kubernetes-list-map-keys": []interface{}{
"type",
},
"x-kubernetes-list-type": "map",
},
},
SchemaProps: spec.SchemaProps{
Description: "DashboardTitleConditions is a list of conditions that are used to determine if the list of groups is valid.",
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.Condition"),
},
},
},
},
},
},
Required: []string{"dashboardTitle"},
},
},
Dependencies: []string{
"k8s.io/apimachinery/pkg/apis/meta/v1.Condition"},
}
}
func schema_pkg_apis_scope_v0alpha1_ScopeFilter(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"key": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
"value": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
"values": {
VendorExtensible: spec.VendorExtensible{
Extensions: spec.Extensions{
"x-kubernetes-list-type": "atomic",
},
},
SchemaProps: spec.SchemaProps{
Description: "Values is used for operators that require multiple values (e.g. one-of and not-one-of).",
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
},
},
"operator": {
SchemaProps: spec.SchemaProps{
Description: "Possible enum values:\n - `\"equals\"`\n - `\"not-equals\"`\n - `\"not-one-of\"`\n - `\"one-of\"`\n - `\"regex-match\"`\n - `\"regex-not-match\"`",
Default: "",
Type: []string{"string"},
Format: "",
Enum: []interface{}{"equals", "not-equals", "not-one-of", "one-of", "regex-match", "regex-not-match"},
},
},
},
Required: []string{"key", "value", "operator"},
},
},
}
}
func schema_pkg_apis_scope_v0alpha1_ScopeList(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"kind": {
SchemaProps: spec.SchemaProps{
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
Type: []string{"string"},
Format: "",
},
},
"apiVersion": {
SchemaProps: spec.SchemaProps{
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
Type: []string{"string"},
Format: "",
},
},
"metadata": {
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"),
},
},
"items": {
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.Scope"),
},
},
},
},
},
},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.Scope", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"},
}
}
func schema_pkg_apis_scope_v0alpha1_ScopeNavigation(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"kind": {
SchemaProps: spec.SchemaProps{
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
Type: []string{"string"},
Format: "",
},
},
"apiVersion": {
SchemaProps: spec.SchemaProps{
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
Type: []string{"string"},
Format: "",
},
},
"metadata": {
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"),
},
},
"spec": {
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNavigationSpec"),
},
},
"status": {
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNavigationStatus"),
},
},
},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNavigationSpec", "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNavigationStatus", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"},
}
}
func schema_pkg_apis_scope_v0alpha1_ScopeNavigationList(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"kind": {
SchemaProps: spec.SchemaProps{
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
Type: []string{"string"},
Format: "",
},
},
"apiVersion": {
SchemaProps: spec.SchemaProps{
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
Type: []string{"string"},
Format: "",
},
},
"metadata": {
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"),
},
},
"items": {
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNavigation"),
},
},
},
},
},
},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNavigation", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"},
}
}
func schema_pkg_apis_scope_v0alpha1_ScopeNavigationSpec(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"url": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
"scope": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
Required: []string{"url", "scope"},
},
},
}
}
func schema_pkg_apis_scope_v0alpha1_ScopeNavigationStatus(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Description: "Type of the item. ScopeNavigationStatus contains derived information about a ScopeNavigation.",
Type: []string{"object"},
Properties: map[string]spec.Schema{
"title": {
SchemaProps: spec.SchemaProps{
Description: "Title should be populated and update from the dashboard",
Default: "",
Type: []string{"string"},
Format: "",
},
},
"groups": {
SchemaProps: spec.SchemaProps{
Description: "Groups is used for the grouping of dashboards that are suggested based on a scope. The source of truth for this information has not been determined yet.",
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
},
},
"titleConditions": {
VendorExtensible: spec.VendorExtensible{
Extensions: spec.Extensions{
"x-kubernetes-list-map-keys": []interface{}{
"type",
},
"x-kubernetes-list-type": "map",
},
},
SchemaProps: spec.SchemaProps{
Description: "TitleConditions is a list of conditions that are used to determine if the title is valid.",
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.Condition"),
},
},
},
},
},
"groupsConditions": {
VendorExtensible: spec.VendorExtensible{
Extensions: spec.Extensions{
"x-kubernetes-list-map-keys": []interface{}{
"type",
},
"x-kubernetes-list-type": "map",
},
},
SchemaProps: spec.SchemaProps{
Description: "GroupsConditions is a list of conditions that are used to determine if the list of groups is valid.",
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.Condition"),
},
},
},
},
},
},
Required: []string{"title"},
},
},
Dependencies: []string{
"k8s.io/apimachinery/pkg/apis/meta/v1.Condition"},
}
}
func schema_pkg_apis_scope_v0alpha1_ScopeNode(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"kind": {
SchemaProps: spec.SchemaProps{
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
Type: []string{"string"},
Format: "",
},
},
"apiVersion": {
SchemaProps: spec.SchemaProps{
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
Type: []string{"string"},
Format: "",
},
},
"metadata": {
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"),
},
},
"spec": {
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNodeSpec"),
},
},
},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNodeSpec", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"},
}
}
func schema_pkg_apis_scope_v0alpha1_ScopeNodeList(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"kind": {
SchemaProps: spec.SchemaProps{
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
Type: []string{"string"},
Format: "",
},
},
"apiVersion": {
SchemaProps: spec.SchemaProps{
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
Type: []string{"string"},
Format: "",
},
},
"metadata": {
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"),
},
},
"items": {
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNode"),
},
},
},
},
},
},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeNode", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"},
}
}
func schema_pkg_apis_scope_v0alpha1_ScopeNodeSpec(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"parentName": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "",
},
},
"nodeType": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
"title": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
"description": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "",
},
},
"disableMultiSelect": {
SchemaProps: spec.SchemaProps{
Default: false,
Type: []string{"boolean"},
Format: "",
},
},
"linkType": {
SchemaProps: spec.SchemaProps{
Description: "Possible enum values:\n - `\"scope\"`",
Type: []string{"string"},
Format: "",
Enum: []interface{}{"scope"},
},
},
"linkId": {
SchemaProps: spec.SchemaProps{
Description: "scope (later more things)",
Type: []string{"string"},
Format: "",
},
},
},
Required: []string{"nodeType", "title", "disableMultiSelect"},
},
},
}
}
func schema_pkg_apis_scope_v0alpha1_ScopeSpec(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"title": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
"defaultPath": {
VendorExtensible: spec.VendorExtensible{
Extensions: spec.Extensions{
"x-kubernetes-list-type": "atomic",
},
},
SchemaProps: spec.SchemaProps{
Description: "Provides a default path for the scope. This refers to a list of nodes in the selector. This is used to display the title next to the selected scope and expand the selector to the proper path. This will override whichever is selected from in the selector. The path is a list of node ids, starting at the direct parent of the selected node towards the root.",
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
},
},
"filters": {
VendorExtensible: spec.VendorExtensible{
Extensions: spec.Extensions{
"x-kubernetes-list-type": "atomic",
},
},
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeFilter"),
},
},
},
},
},
},
Required: []string{"title"},
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1.ScopeFilter"},
}
}

View File

@ -0,0 +1,10 @@
API rule violation: list_type_missing,github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1,FindScopeDashboardBindingsResults,Items
API rule violation: list_type_missing,github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1,FindScopeNavigationsResults,Items
API rule violation: list_type_missing,github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1,ScopeDashboardBindingStatus,Groups
API rule violation: list_type_missing,github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1,ScopeNavigationStatus,Groups
API rule violation: names_match,github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1,ScopeNodeSpec,LinkID
API rule violation: streaming_list_type_json_tags,github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1,FindScopeNodeChildrenResults,Items
API rule violation: streaming_list_type_json_tags,github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1,ScopeDashboardBindingList,Items
API rule violation: streaming_list_type_json_tags,github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1,ScopeList,Items
API rule violation: streaming_list_type_json_tags,github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1,ScopeNavigationList,Items
API rule violation: streaming_list_type_json_tags,github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1,ScopeNodeList,Items

View File

@ -1856,11 +1856,6 @@
"count": 1
}
},
"public/app/features/commandPalette/actions/recentScopesActions.ts": {
"react-hooks/rules-of-hooks": {
"count": 1
}
},
"public/app/features/commandPalette/actions/scopeActions.tsx": {
"react-hooks/rules-of-hooks": {
"count": 4
@ -4542,11 +4537,6 @@
"count": 1
}
},
"public/app/plugins/panel/logs/types.ts": {
"no-barrel-files/no-barrel-files": {
"count": 1
}
},
"public/app/plugins/panel/nodeGraph/Edge.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1

2
go.mod
View File

@ -246,6 +246,7 @@ require (
github.com/grafana/grafana/apps/plugins v0.0.0 // @grafana/plugins-platform-backend
github.com/grafana/grafana/apps/preferences v0.0.0 // @grafana/grafana-app-platform-squad
github.com/grafana/grafana/apps/provisioning v0.0.0 // @grafana/grafana-app-platform-squad
github.com/grafana/grafana/apps/scope v0.0.0 // @grafana/grafana-operator-experience-squad
github.com/grafana/grafana/apps/secret v0.0.0 // @grafana/grafana-operator-experience-squad
github.com/grafana/grafana/apps/shorturl v0.0.0 // @grafana/sharing-squad
github.com/grafana/grafana/pkg/aggregator v0.0.0 // @grafana/grafana-app-platform-squad
@ -274,6 +275,7 @@ replace (
github.com/grafana/grafana/apps/plugins => ./apps/plugins
github.com/grafana/grafana/apps/preferences => ./apps/preferences
github.com/grafana/grafana/apps/provisioning => ./apps/provisioning
github.com/grafana/grafana/apps/scope => ./apps/scope
github.com/grafana/grafana/apps/secret => ./apps/secret
github.com/grafana/grafana/apps/shorturl => ./apps/shorturl

View File

@ -18,6 +18,7 @@ use (
./apps/plugins
./apps/preferences
./apps/provisioning
./apps/scope
./apps/secret
./apps/shorturl
./pkg/aggregator

View File

@ -90,6 +90,7 @@ grafana::codegen:run apps/dashboard/pkg
grafana::codegen:run apps/provisioning/pkg
grafana::codegen:run apps/folder/pkg
grafana::codegen:run apps/preferences/pkg
grafana::codegen:run apps/scope/pkg
grafana::codegen:run apps/alerting/alertenrichment/pkg
if [ -d "pkg/extensions/apis" ]; then

View File

@ -1,5 +1,12 @@
import { ThemeColors } from './createColors';
import { ThemeShadows } from './createShadows';
import type { Radii } from './createShape';
import type { ThemeSpacingTokens } from './createSpacing';
interface MenuComponentTokens {
borderRadius: keyof Radii;
padding: ThemeSpacingTokens;
}
/** @beta */
export interface ThemeComponents {
@ -53,6 +60,7 @@ export interface ThemeComponents {
rowHoverBackground: string;
rowSelected: string;
};
menu: MenuComponentTokens;
}
export function createComponents(colors: ThemeColors, shadows: ThemeShadows): ThemeComponents {
@ -71,6 +79,11 @@ export function createComponents(colors: ThemeColors, shadows: ThemeShadows): Th
background: colors.mode === 'dark' ? colors.background.canvas : colors.background.primary,
};
const menu: MenuComponentTokens = {
borderRadius: 'default',
padding: 0.5,
};
return {
height: {
sm: 3,
@ -114,5 +127,6 @@ export function createComponents(colors: ThemeColors, shadows: ThemeShadows): Th
rowHoverBackground: colors.action.hover,
rowSelected: colors.action.selected,
},
menu,
};
}

View File

@ -165,6 +165,8 @@ export type PluginExtensionOpenModalOptions = {
export type PluginExtensionEventHelpers<Context extends object = object> = {
context?: Readonly<Context>;
// The ID of the extension point that triggered this event
extensionPointId: string;
// Opens a modal dialog and renders the provided React component inside it
openModal: (options: PluginExtensionOpenModalOptions) => void;
/**

View File

@ -41,6 +41,7 @@ export interface Options {
showCommonLabels: boolean;
showControls?: boolean;
showLabels: boolean;
showLogAttributes?: boolean;
showLogContextToggle: boolean;
showTime: boolean;
sortOrder: common.LogsSortOrder;

View File

@ -37,7 +37,7 @@ const listFoldersHandler = () =>
const limit = parseInt(url.searchParams.get('limit') ?? '1000', 10);
const page = parseInt(url.searchParams.get('page') ?? '1', 10);
const tree = permission === 'Edit' ? mockTreeThatViewersCanEdit : mockTree;
const tree = permission?.toLowerCase() === 'edit' ? mockTreeThatViewersCanEdit : mockTree;
// reconstruct a folder API response from the flat tree fixture
const folders = tree

View File

@ -14,6 +14,9 @@ export interface ErrorBoundaryApi {
}
interface Props {
/** Name of the error boundary. Used when reporting errors in Faro. */
boundaryName?: string;
children: (r: ErrorBoundaryApi) => ReactNode;
/** Will re-render children after error if recover values changes */
dependencies?: unknown[];
@ -37,10 +40,15 @@ export class ErrorBoundary extends PureComponent<Props, State> {
};
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
const logger = this.props.errorLogger ?? faro?.api?.pushError;
if (logger) {
logger(error);
if (this.props.errorLogger) {
this.props.errorLogger(error);
} else {
faro?.api?.pushError(error, {
type: 'boundary',
context: {
source: this.props.boundaryName ?? 'unknown',
},
});
}
this.setState({ error, errorInfo });
@ -85,6 +93,9 @@ export class ErrorBoundary extends PureComponent<Props, State> {
* @public
*/
export interface ErrorBoundaryAlertProps {
/** Name of the error boundary. Used when reporting errors in Faro. */
boundaryName?: string;
/** Title for the error boundary alert */
title?: string;
@ -107,10 +118,10 @@ export class ErrorBoundaryAlert extends PureComponent<ErrorBoundaryAlertProps> {
};
render() {
const { title, children, style, dependencies, errorLogger } = this.props;
const { title, children, style, dependencies, errorLogger, boundaryName } = this.props;
return (
<ErrorBoundary dependencies={dependencies} errorLogger={errorLogger}>
<ErrorBoundary dependencies={dependencies} errorLogger={errorLogger} boundaryName={boundaryName}>
{({ error, errorInfo }) => {
if (!errorInfo) {
return children;

View File

@ -25,6 +25,7 @@ export interface MenuProps extends React.HTMLAttributes<HTMLDivElement> {
const MenuComp = React.forwardRef<HTMLDivElement, MenuProps>(
({ header, children, ariaLabel, onOpen, onClose, onKeyDown, ...otherProps }, forwardedRef) => {
const styles = useStyles2(getStyles);
const componentTokens = useComponentTokens();
const localRef = useRef<HTMLDivElement>(null);
useImperativeHandle(forwardedRef, () => localRef.current!);
@ -36,12 +37,11 @@ const MenuComp = React.forwardRef<HTMLDivElement, MenuProps>(
{...otherProps}
aria-label={ariaLabel}
backgroundColor="elevated"
borderRadius="default"
borderRadius={componentTokens.borderRadius}
boxShadow="z3"
display="inline-block"
onKeyDown={handleKeys}
paddingX={0.5}
paddingY={0.5}
padding={componentTokens.padding}
ref={localRef}
role="menu"
tabIndex={-1}
@ -70,6 +70,18 @@ export const Menu = Object.assign(MenuComp, {
Group: MenuGroup,
});
const useComponentTokens = () =>
useStyles2((theme: GrafanaTheme2) => {
const {
components: { menu },
} = theme;
return {
padding: menu.padding,
borderRadius: menu.borderRadius,
};
});
const getStyles = (theme: GrafanaTheme2) => {
return {
header: css({

View File

@ -6,7 +6,7 @@ import { GrafanaTheme2, LinkTarget } from '@grafana/data';
import { t } from '@grafana/i18n';
import { useStyles2 } from '../../themes/ThemeContext';
import { getFocusStyles } from '../../themes/mixins';
import { getFocusStyles, getInternalRadius } from '../../themes/mixins';
import { IconName } from '../../types/icon';
import { Icon } from '../Icon/Icon';
import { Stack } from '../Layout/Stack/Stack';
@ -213,6 +213,8 @@ export const MenuItem = React.memo(
MenuItem.displayName = 'MenuItem';
const getStyles = (theme: GrafanaTheme2) => {
const menuPadding = theme.components.menu.padding * theme.spacing.gridSize;
return {
item: css({
background: 'none',
@ -225,7 +227,7 @@ const getStyles = (theme: GrafanaTheme2) => {
justifyContent: 'center',
padding: theme.spacing(0.5, 1.5),
minHeight: theme.spacing(4),
borderRadius: theme.shape.radius.default,
borderRadius: getInternalRadius(theme, menuPadding, { parentBorderWidth: 0 }),
margin: 0,
border: 'none',
width: '100%',

View File

@ -57,4 +57,5 @@ import (
_ "github.com/grafana/tempo/pkg/traceql"
_ "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1"
_ "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1"
)

View File

@ -56,6 +56,8 @@ func (s *store) Get(ctx context.Context, ID int64) (*auth.ExternalSession, error
return externalSession, nil
}
// List returns a list of external sessions that match the given query.
// If the result set contains more than one entry, the entries are sorted by ID in descending order.
func (s *store) List(ctx context.Context, query *auth.ListExternalSessionQuery) ([]*auth.ExternalSession, error) {
ctx, span := s.tracer.Start(ctx, "externalsession.List")
defer span.End()
@ -65,6 +67,10 @@ func (s *store) List(ctx context.Context, query *auth.ListExternalSessionQuery)
externalSession.ID = query.ID
}
if query.UserID != 0 {
externalSession.UserID = query.UserID
}
hash := sha256.New()
if query.SessionID != "" {
@ -80,7 +86,7 @@ func (s *store) List(ctx context.Context, query *auth.ListExternalSessionQuery)
queryResult := make([]*auth.ExternalSession, 0)
err := s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
return sess.Find(&queryResult, externalSession)
return sess.Desc("id").Find(&queryResult, externalSession)
})
if err != nil {
return nil, err

View File

@ -51,6 +51,7 @@ type UpdateExternalSessionCommand struct {
type ListExternalSessionQuery struct {
ID int64
UserID int64
NameID string
SessionID string
}

View File

@ -93,7 +93,11 @@ func (s *OAuthTokenSync) SyncOauthTokenHook(ctx context.Context, id *authn.Ident
updateCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second)
defer cancel()
token, refreshErr := s.service.TryTokenRefresh(updateCtx, id, id.SessionToken)
token, refreshErr := s.service.TryTokenRefresh(updateCtx, id, &oauthtoken.TokenRefreshMetadata{
ExternalSessionID: id.SessionToken.ExternalSessionId,
AuthModule: id.GetAuthenticatedBy(),
AuthID: id.GetAuthID(),
})
if refreshErr != nil {
if errors.Is(refreshErr, context.Canceled) {
return nil, nil
@ -107,7 +111,7 @@ func (s *OAuthTokenSync) SyncOauthTokenHook(ctx context.Context, id *authn.Ident
ctxLogger.Error("Failed to refresh OAuth access token", "id", id.ID, "error", refreshErr)
// log the user out
if err := s.sessionService.RevokeToken(ctx, id.SessionToken, false); err != nil {
if err := s.sessionService.RevokeToken(ctx, id.SessionToken, false); err != nil && !errors.Is(err, auth.ErrUserTokenNotFound) {
ctxLogger.Warn("Failed to revoke session token", "id", id.ID, "tokenId", id.SessionToken.Id, "error", err)
}

View File

@ -25,6 +25,7 @@ import (
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/oauthtoken"
"github.com/grafana/grafana/pkg/services/oauthtoken/oauthtokentest"
)
@ -77,6 +78,14 @@ func TestOAuthTokenSync_SyncOAuthTokenHook(t *testing.T) {
expectRevokeTokenCalled: false,
expectToken: &login.UserAuth{OAuthExpiry: time.Now().Add(10 * time.Minute)},
},
{
desc: "should not invalidate session if token refresh fails with no refresh token",
identity: &authn.Identity{ID: "1", Type: claims.TypeUser, SessionToken: &auth.UserToken{}, AuthenticatedBy: login.AzureADAuthModule},
expectedTryRefreshErr: oauthtoken.ErrNoRefreshTokenFound,
expectTryRefreshTokenCalled: true,
expectRevokeTokenCalled: true,
expectedErr: oauthtoken.ErrNoRefreshTokenFound,
},
// TODO: address coverage of oauthtoken sync
}
@ -89,7 +98,7 @@ func TestOAuthTokenSync_SyncOAuthTokenHook(t *testing.T) {
)
service := &oauthtokentest.MockOauthTokenService{
TryTokenRefreshFunc: func(ctx context.Context, usr identity.Requester, _ *auth.UserToken) (*oauth2.Token, error) {
TryTokenRefreshFunc: func(ctx context.Context, usr identity.Requester, _ *oauthtoken.TokenRefreshMetadata) (*oauth2.Token, error) {
tryRefreshCalled = true
return nil, tt.expectedTryRefreshErr
},

View File

@ -297,7 +297,9 @@ func (c *OAuth) Logout(ctx context.Context, user identity.Requester, sessionToke
ctxLogger := c.log.FromContext(ctx).New("userID", userID)
if err := c.oauthService.InvalidateOAuthTokens(ctx, user, sessionToken); err != nil {
if err := c.oauthService.InvalidateOAuthTokens(ctx, user, &oauthtoken.TokenRefreshMetadata{
ExternalSessionID: sessionToken.ExternalSessionId,
AuthModule: user.GetAuthenticatedBy()}); err != nil {
ctxLogger.Error("Failed to invalidate tokens", "error", err)
}

View File

@ -19,10 +19,12 @@ import (
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/login/social/socialtest"
"github.com/grafana/grafana/pkg/models/usertoken"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/oauthtoken"
"github.com/grafana/grafana/pkg/services/oauthtoken/oauthtokentest"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/setting"
@ -481,7 +483,7 @@ func TestOAuth_Logout(t *testing.T) {
"id_token": "some.id.token",
})
},
InvalidateOAuthTokensFunc: func(_ context.Context, _ identity.Requester, _ *auth.UserToken) error {
InvalidateOAuthTokensFunc: func(_ context.Context, _ identity.Requester, _ *oauthtoken.TokenRefreshMetadata) error {
invalidateTokenCalled = true
return nil
},
@ -492,7 +494,7 @@ func TestOAuth_Logout(t *testing.T) {
}
c := ProvideOAuth(authn.ClientWithPrefix("azuread"), tt.cfg, mockService, fakeSocialSvc, &setting.OSSImpl{Cfg: tt.cfg}, featuremgmt.WithFeatures(), tracing.InitializeTracerForTest())
redirect, ok := c.Logout(context.Background(), &authn.Identity{ID: "1", Type: claims.TypeUser}, nil)
redirect, ok := c.Logout(context.Background(), &authn.Identity{ID: "1", Type: claims.TypeUser}, &usertoken.UserToken{})
assert.Equal(t, tt.expectedOK, ok)
if tt.expectedOK {

View File

@ -760,12 +760,10 @@ func (s *Service) listPermission(ctx context.Context, scopeMap map[string]bool,
cacheHit := false
if t.HasFolderSupport() {
var err error
ok = false
if !req.Options.SkipCache {
tree, ok = s.getCachedFolderTree(ctx, req.Namespace)
cacheHit = true
tree, cacheHit = s.getCachedFolderTree(ctx, req.Namespace)
}
if !ok {
if !cacheHit {
tree, err = s.buildFolderTree(ctx, req.Namespace)
if err != nil {
ctxLogger.Error("could not build folder and dashboard tree", "error", err)

View File

@ -5,6 +5,7 @@ import (
"strings"
)
//go:generate mockery --name AuthInfoService --structname MockAuthInfoService --outpkg authinfotest --filename auth_info_service_mock.go --output ./authinfotest/
type AuthInfoService interface {
GetAuthInfo(ctx context.Context, query *GetAuthInfoQuery) (*UserAuth, error)
GetUserLabels(ctx context.Context, query GetUserLabelsQuery) (map[int64]string, error)

View File

@ -0,0 +1,765 @@
// Code generated by mockery; DO NOT EDIT.
// github.com/vektra/mockery
// template: testify
package authinfotest
import (
"context"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/user"
mock "github.com/stretchr/testify/mock"
)
// NewMockAuthInfoService creates a new instance of MockAuthInfoService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockAuthInfoService(t interface {
mock.TestingT
Cleanup(func())
}) *MockAuthInfoService {
mock := &MockAuthInfoService{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
// MockAuthInfoService is an autogenerated mock type for the AuthInfoService type
type MockAuthInfoService struct {
mock.Mock
}
type MockAuthInfoService_Expecter struct {
mock *mock.Mock
}
func (_m *MockAuthInfoService) EXPECT() *MockAuthInfoService_Expecter {
return &MockAuthInfoService_Expecter{mock: &_m.Mock}
}
// DeleteUserAuthInfo provides a mock function for the type MockAuthInfoService
func (_mock *MockAuthInfoService) DeleteUserAuthInfo(ctx context.Context, userID int64) error {
ret := _mock.Called(ctx, userID)
if len(ret) == 0 {
panic("no return value specified for DeleteUserAuthInfo")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, int64) error); ok {
r0 = returnFunc(ctx, userID)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockAuthInfoService_DeleteUserAuthInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteUserAuthInfo'
type MockAuthInfoService_DeleteUserAuthInfo_Call struct {
*mock.Call
}
// DeleteUserAuthInfo is a helper method to define mock.On call
// - ctx context.Context
// - userID int64
func (_e *MockAuthInfoService_Expecter) DeleteUserAuthInfo(ctx interface{}, userID interface{}) *MockAuthInfoService_DeleteUserAuthInfo_Call {
return &MockAuthInfoService_DeleteUserAuthInfo_Call{Call: _e.mock.On("DeleteUserAuthInfo", ctx, userID)}
}
func (_c *MockAuthInfoService_DeleteUserAuthInfo_Call) Run(run func(ctx context.Context, userID int64)) *MockAuthInfoService_DeleteUserAuthInfo_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 int64
if args[1] != nil {
arg1 = args[1].(int64)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *MockAuthInfoService_DeleteUserAuthInfo_Call) Return(err error) *MockAuthInfoService_DeleteUserAuthInfo_Call {
_c.Call.Return(err)
return _c
}
func (_c *MockAuthInfoService_DeleteUserAuthInfo_Call) RunAndReturn(run func(ctx context.Context, userID int64) error) *MockAuthInfoService_DeleteUserAuthInfo_Call {
_c.Call.Return(run)
return _c
}
// GetAuthInfo provides a mock function for the type MockAuthInfoService
func (_mock *MockAuthInfoService) GetAuthInfo(ctx context.Context, query *login.GetAuthInfoQuery) (*login.UserAuth, error) {
ret := _mock.Called(ctx, query)
if len(ret) == 0 {
panic("no return value specified for GetAuthInfo")
}
var r0 *login.UserAuth
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, *login.GetAuthInfoQuery) (*login.UserAuth, error)); ok {
return returnFunc(ctx, query)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, *login.GetAuthInfoQuery) *login.UserAuth); ok {
r0 = returnFunc(ctx, query)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*login.UserAuth)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context, *login.GetAuthInfoQuery) error); ok {
r1 = returnFunc(ctx, query)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockAuthInfoService_GetAuthInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAuthInfo'
type MockAuthInfoService_GetAuthInfo_Call struct {
*mock.Call
}
// GetAuthInfo is a helper method to define mock.On call
// - ctx context.Context
// - query *login.GetAuthInfoQuery
func (_e *MockAuthInfoService_Expecter) GetAuthInfo(ctx interface{}, query interface{}) *MockAuthInfoService_GetAuthInfo_Call {
return &MockAuthInfoService_GetAuthInfo_Call{Call: _e.mock.On("GetAuthInfo", ctx, query)}
}
func (_c *MockAuthInfoService_GetAuthInfo_Call) Run(run func(ctx context.Context, query *login.GetAuthInfoQuery)) *MockAuthInfoService_GetAuthInfo_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 *login.GetAuthInfoQuery
if args[1] != nil {
arg1 = args[1].(*login.GetAuthInfoQuery)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *MockAuthInfoService_GetAuthInfo_Call) Return(userAuth *login.UserAuth, err error) *MockAuthInfoService_GetAuthInfo_Call {
_c.Call.Return(userAuth, err)
return _c
}
func (_c *MockAuthInfoService_GetAuthInfo_Call) RunAndReturn(run func(ctx context.Context, query *login.GetAuthInfoQuery) (*login.UserAuth, error)) *MockAuthInfoService_GetAuthInfo_Call {
_c.Call.Return(run)
return _c
}
// GetUserLabels provides a mock function for the type MockAuthInfoService
func (_mock *MockAuthInfoService) GetUserLabels(ctx context.Context, query login.GetUserLabelsQuery) (map[int64]string, error) {
ret := _mock.Called(ctx, query)
if len(ret) == 0 {
panic("no return value specified for GetUserLabels")
}
var r0 map[int64]string
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, login.GetUserLabelsQuery) (map[int64]string, error)); ok {
return returnFunc(ctx, query)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, login.GetUserLabelsQuery) map[int64]string); ok {
r0 = returnFunc(ctx, query)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(map[int64]string)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context, login.GetUserLabelsQuery) error); ok {
r1 = returnFunc(ctx, query)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockAuthInfoService_GetUserLabels_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetUserLabels'
type MockAuthInfoService_GetUserLabels_Call struct {
*mock.Call
}
// GetUserLabels is a helper method to define mock.On call
// - ctx context.Context
// - query login.GetUserLabelsQuery
func (_e *MockAuthInfoService_Expecter) GetUserLabels(ctx interface{}, query interface{}) *MockAuthInfoService_GetUserLabels_Call {
return &MockAuthInfoService_GetUserLabels_Call{Call: _e.mock.On("GetUserLabels", ctx, query)}
}
func (_c *MockAuthInfoService_GetUserLabels_Call) Run(run func(ctx context.Context, query login.GetUserLabelsQuery)) *MockAuthInfoService_GetUserLabels_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 login.GetUserLabelsQuery
if args[1] != nil {
arg1 = args[1].(login.GetUserLabelsQuery)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *MockAuthInfoService_GetUserLabels_Call) Return(int64ToString map[int64]string, err error) *MockAuthInfoService_GetUserLabels_Call {
_c.Call.Return(int64ToString, err)
return _c
}
func (_c *MockAuthInfoService_GetUserLabels_Call) RunAndReturn(run func(ctx context.Context, query login.GetUserLabelsQuery) (map[int64]string, error)) *MockAuthInfoService_GetUserLabels_Call {
_c.Call.Return(run)
return _c
}
// SetAuthInfo provides a mock function for the type MockAuthInfoService
func (_mock *MockAuthInfoService) SetAuthInfo(ctx context.Context, cmd *login.SetAuthInfoCommand) error {
ret := _mock.Called(ctx, cmd)
if len(ret) == 0 {
panic("no return value specified for SetAuthInfo")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, *login.SetAuthInfoCommand) error); ok {
r0 = returnFunc(ctx, cmd)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockAuthInfoService_SetAuthInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetAuthInfo'
type MockAuthInfoService_SetAuthInfo_Call struct {
*mock.Call
}
// SetAuthInfo is a helper method to define mock.On call
// - ctx context.Context
// - cmd *login.SetAuthInfoCommand
func (_e *MockAuthInfoService_Expecter) SetAuthInfo(ctx interface{}, cmd interface{}) *MockAuthInfoService_SetAuthInfo_Call {
return &MockAuthInfoService_SetAuthInfo_Call{Call: _e.mock.On("SetAuthInfo", ctx, cmd)}
}
func (_c *MockAuthInfoService_SetAuthInfo_Call) Run(run func(ctx context.Context, cmd *login.SetAuthInfoCommand)) *MockAuthInfoService_SetAuthInfo_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 *login.SetAuthInfoCommand
if args[1] != nil {
arg1 = args[1].(*login.SetAuthInfoCommand)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *MockAuthInfoService_SetAuthInfo_Call) Return(err error) *MockAuthInfoService_SetAuthInfo_Call {
_c.Call.Return(err)
return _c
}
func (_c *MockAuthInfoService_SetAuthInfo_Call) RunAndReturn(run func(ctx context.Context, cmd *login.SetAuthInfoCommand) error) *MockAuthInfoService_SetAuthInfo_Call {
_c.Call.Return(run)
return _c
}
// UpdateAuthInfo provides a mock function for the type MockAuthInfoService
func (_mock *MockAuthInfoService) UpdateAuthInfo(ctx context.Context, cmd *login.UpdateAuthInfoCommand) error {
ret := _mock.Called(ctx, cmd)
if len(ret) == 0 {
panic("no return value specified for UpdateAuthInfo")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, *login.UpdateAuthInfoCommand) error); ok {
r0 = returnFunc(ctx, cmd)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockAuthInfoService_UpdateAuthInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateAuthInfo'
type MockAuthInfoService_UpdateAuthInfo_Call struct {
*mock.Call
}
// UpdateAuthInfo is a helper method to define mock.On call
// - ctx context.Context
// - cmd *login.UpdateAuthInfoCommand
func (_e *MockAuthInfoService_Expecter) UpdateAuthInfo(ctx interface{}, cmd interface{}) *MockAuthInfoService_UpdateAuthInfo_Call {
return &MockAuthInfoService_UpdateAuthInfo_Call{Call: _e.mock.On("UpdateAuthInfo", ctx, cmd)}
}
func (_c *MockAuthInfoService_UpdateAuthInfo_Call) Run(run func(ctx context.Context, cmd *login.UpdateAuthInfoCommand)) *MockAuthInfoService_UpdateAuthInfo_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 *login.UpdateAuthInfoCommand
if args[1] != nil {
arg1 = args[1].(*login.UpdateAuthInfoCommand)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *MockAuthInfoService_UpdateAuthInfo_Call) Return(err error) *MockAuthInfoService_UpdateAuthInfo_Call {
_c.Call.Return(err)
return _c
}
func (_c *MockAuthInfoService_UpdateAuthInfo_Call) RunAndReturn(run func(ctx context.Context, cmd *login.UpdateAuthInfoCommand) error) *MockAuthInfoService_UpdateAuthInfo_Call {
_c.Call.Return(run)
return _c
}
// NewMockStore creates a new instance of MockStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockStore(t interface {
mock.TestingT
Cleanup(func())
}) *MockStore {
mock := &MockStore{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
// MockStore is an autogenerated mock type for the Store type
type MockStore struct {
mock.Mock
}
type MockStore_Expecter struct {
mock *mock.Mock
}
func (_m *MockStore) EXPECT() *MockStore_Expecter {
return &MockStore_Expecter{mock: &_m.Mock}
}
// DeleteUserAuthInfo provides a mock function for the type MockStore
func (_mock *MockStore) DeleteUserAuthInfo(ctx context.Context, userID int64) error {
ret := _mock.Called(ctx, userID)
if len(ret) == 0 {
panic("no return value specified for DeleteUserAuthInfo")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, int64) error); ok {
r0 = returnFunc(ctx, userID)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockStore_DeleteUserAuthInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteUserAuthInfo'
type MockStore_DeleteUserAuthInfo_Call struct {
*mock.Call
}
// DeleteUserAuthInfo is a helper method to define mock.On call
// - ctx context.Context
// - userID int64
func (_e *MockStore_Expecter) DeleteUserAuthInfo(ctx interface{}, userID interface{}) *MockStore_DeleteUserAuthInfo_Call {
return &MockStore_DeleteUserAuthInfo_Call{Call: _e.mock.On("DeleteUserAuthInfo", ctx, userID)}
}
func (_c *MockStore_DeleteUserAuthInfo_Call) Run(run func(ctx context.Context, userID int64)) *MockStore_DeleteUserAuthInfo_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 int64
if args[1] != nil {
arg1 = args[1].(int64)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *MockStore_DeleteUserAuthInfo_Call) Return(err error) *MockStore_DeleteUserAuthInfo_Call {
_c.Call.Return(err)
return _c
}
func (_c *MockStore_DeleteUserAuthInfo_Call) RunAndReturn(run func(ctx context.Context, userID int64) error) *MockStore_DeleteUserAuthInfo_Call {
_c.Call.Return(run)
return _c
}
// GetAuthInfo provides a mock function for the type MockStore
func (_mock *MockStore) GetAuthInfo(ctx context.Context, query *login.GetAuthInfoQuery) (*login.UserAuth, error) {
ret := _mock.Called(ctx, query)
if len(ret) == 0 {
panic("no return value specified for GetAuthInfo")
}
var r0 *login.UserAuth
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, *login.GetAuthInfoQuery) (*login.UserAuth, error)); ok {
return returnFunc(ctx, query)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, *login.GetAuthInfoQuery) *login.UserAuth); ok {
r0 = returnFunc(ctx, query)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*login.UserAuth)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context, *login.GetAuthInfoQuery) error); ok {
r1 = returnFunc(ctx, query)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockStore_GetAuthInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAuthInfo'
type MockStore_GetAuthInfo_Call struct {
*mock.Call
}
// GetAuthInfo is a helper method to define mock.On call
// - ctx context.Context
// - query *login.GetAuthInfoQuery
func (_e *MockStore_Expecter) GetAuthInfo(ctx interface{}, query interface{}) *MockStore_GetAuthInfo_Call {
return &MockStore_GetAuthInfo_Call{Call: _e.mock.On("GetAuthInfo", ctx, query)}
}
func (_c *MockStore_GetAuthInfo_Call) Run(run func(ctx context.Context, query *login.GetAuthInfoQuery)) *MockStore_GetAuthInfo_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 *login.GetAuthInfoQuery
if args[1] != nil {
arg1 = args[1].(*login.GetAuthInfoQuery)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *MockStore_GetAuthInfo_Call) Return(userAuth *login.UserAuth, err error) *MockStore_GetAuthInfo_Call {
_c.Call.Return(userAuth, err)
return _c
}
func (_c *MockStore_GetAuthInfo_Call) RunAndReturn(run func(ctx context.Context, query *login.GetAuthInfoQuery) (*login.UserAuth, error)) *MockStore_GetAuthInfo_Call {
_c.Call.Return(run)
return _c
}
// GetUserLabels provides a mock function for the type MockStore
func (_mock *MockStore) GetUserLabels(ctx context.Context, query login.GetUserLabelsQuery) (map[int64]string, error) {
ret := _mock.Called(ctx, query)
if len(ret) == 0 {
panic("no return value specified for GetUserLabels")
}
var r0 map[int64]string
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, login.GetUserLabelsQuery) (map[int64]string, error)); ok {
return returnFunc(ctx, query)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, login.GetUserLabelsQuery) map[int64]string); ok {
r0 = returnFunc(ctx, query)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(map[int64]string)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context, login.GetUserLabelsQuery) error); ok {
r1 = returnFunc(ctx, query)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockStore_GetUserLabels_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetUserLabels'
type MockStore_GetUserLabels_Call struct {
*mock.Call
}
// GetUserLabels is a helper method to define mock.On call
// - ctx context.Context
// - query login.GetUserLabelsQuery
func (_e *MockStore_Expecter) GetUserLabels(ctx interface{}, query interface{}) *MockStore_GetUserLabels_Call {
return &MockStore_GetUserLabels_Call{Call: _e.mock.On("GetUserLabels", ctx, query)}
}
func (_c *MockStore_GetUserLabels_Call) Run(run func(ctx context.Context, query login.GetUserLabelsQuery)) *MockStore_GetUserLabels_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 login.GetUserLabelsQuery
if args[1] != nil {
arg1 = args[1].(login.GetUserLabelsQuery)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *MockStore_GetUserLabels_Call) Return(int64ToString map[int64]string, err error) *MockStore_GetUserLabels_Call {
_c.Call.Return(int64ToString, err)
return _c
}
func (_c *MockStore_GetUserLabels_Call) RunAndReturn(run func(ctx context.Context, query login.GetUserLabelsQuery) (map[int64]string, error)) *MockStore_GetUserLabels_Call {
_c.Call.Return(run)
return _c
}
// SetAuthInfo provides a mock function for the type MockStore
func (_mock *MockStore) SetAuthInfo(ctx context.Context, cmd *login.SetAuthInfoCommand) error {
ret := _mock.Called(ctx, cmd)
if len(ret) == 0 {
panic("no return value specified for SetAuthInfo")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, *login.SetAuthInfoCommand) error); ok {
r0 = returnFunc(ctx, cmd)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockStore_SetAuthInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetAuthInfo'
type MockStore_SetAuthInfo_Call struct {
*mock.Call
}
// SetAuthInfo is a helper method to define mock.On call
// - ctx context.Context
// - cmd *login.SetAuthInfoCommand
func (_e *MockStore_Expecter) SetAuthInfo(ctx interface{}, cmd interface{}) *MockStore_SetAuthInfo_Call {
return &MockStore_SetAuthInfo_Call{Call: _e.mock.On("SetAuthInfo", ctx, cmd)}
}
func (_c *MockStore_SetAuthInfo_Call) Run(run func(ctx context.Context, cmd *login.SetAuthInfoCommand)) *MockStore_SetAuthInfo_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 *login.SetAuthInfoCommand
if args[1] != nil {
arg1 = args[1].(*login.SetAuthInfoCommand)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *MockStore_SetAuthInfo_Call) Return(err error) *MockStore_SetAuthInfo_Call {
_c.Call.Return(err)
return _c
}
func (_c *MockStore_SetAuthInfo_Call) RunAndReturn(run func(ctx context.Context, cmd *login.SetAuthInfoCommand) error) *MockStore_SetAuthInfo_Call {
_c.Call.Return(run)
return _c
}
// UpdateAuthInfo provides a mock function for the type MockStore
func (_mock *MockStore) UpdateAuthInfo(ctx context.Context, cmd *login.UpdateAuthInfoCommand) error {
ret := _mock.Called(ctx, cmd)
if len(ret) == 0 {
panic("no return value specified for UpdateAuthInfo")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, *login.UpdateAuthInfoCommand) error); ok {
r0 = returnFunc(ctx, cmd)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockStore_UpdateAuthInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateAuthInfo'
type MockStore_UpdateAuthInfo_Call struct {
*mock.Call
}
// UpdateAuthInfo is a helper method to define mock.On call
// - ctx context.Context
// - cmd *login.UpdateAuthInfoCommand
func (_e *MockStore_Expecter) UpdateAuthInfo(ctx interface{}, cmd interface{}) *MockStore_UpdateAuthInfo_Call {
return &MockStore_UpdateAuthInfo_Call{Call: _e.mock.On("UpdateAuthInfo", ctx, cmd)}
}
func (_c *MockStore_UpdateAuthInfo_Call) Run(run func(ctx context.Context, cmd *login.UpdateAuthInfoCommand)) *MockStore_UpdateAuthInfo_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 *login.UpdateAuthInfoCommand
if args[1] != nil {
arg1 = args[1].(*login.UpdateAuthInfoCommand)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *MockStore_UpdateAuthInfo_Call) Return(err error) *MockStore_UpdateAuthInfo_Call {
_c.Call.Return(err)
return _c
}
func (_c *MockStore_UpdateAuthInfo_Call) RunAndReturn(run func(ctx context.Context, cmd *login.UpdateAuthInfoCommand) error) *MockStore_UpdateAuthInfo_Call {
_c.Call.Return(run)
return _c
}
// NewMockUserProtectionService creates a new instance of MockUserProtectionService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockUserProtectionService(t interface {
mock.TestingT
Cleanup(func())
}) *MockUserProtectionService {
mock := &MockUserProtectionService{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
// MockUserProtectionService is an autogenerated mock type for the UserProtectionService type
type MockUserProtectionService struct {
mock.Mock
}
type MockUserProtectionService_Expecter struct {
mock *mock.Mock
}
func (_m *MockUserProtectionService) EXPECT() *MockUserProtectionService_Expecter {
return &MockUserProtectionService_Expecter{mock: &_m.Mock}
}
// AllowUserMapping provides a mock function for the type MockUserProtectionService
func (_mock *MockUserProtectionService) AllowUserMapping(user1 *user.User, authModule string) error {
ret := _mock.Called(user1, authModule)
if len(ret) == 0 {
panic("no return value specified for AllowUserMapping")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(*user.User, string) error); ok {
r0 = returnFunc(user1, authModule)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockUserProtectionService_AllowUserMapping_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AllowUserMapping'
type MockUserProtectionService_AllowUserMapping_Call struct {
*mock.Call
}
// AllowUserMapping is a helper method to define mock.On call
// - user1 *user.User
// - authModule string
func (_e *MockUserProtectionService_Expecter) AllowUserMapping(user1 interface{}, authModule interface{}) *MockUserProtectionService_AllowUserMapping_Call {
return &MockUserProtectionService_AllowUserMapping_Call{Call: _e.mock.On("AllowUserMapping", user1, authModule)}
}
func (_c *MockUserProtectionService_AllowUserMapping_Call) Run(run func(user1 *user.User, authModule string)) *MockUserProtectionService_AllowUserMapping_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 *user.User
if args[0] != nil {
arg0 = args[0].(*user.User)
}
var arg1 string
if args[1] != nil {
arg1 = args[1].(string)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *MockUserProtectionService_AllowUserMapping_Call) Return(err error) *MockUserProtectionService_AllowUserMapping_Call {
_c.Call.Return(err)
return _c
}
func (_c *MockUserProtectionService_AllowUserMapping_Call) RunAndReturn(run func(user1 *user.User, authModule string) error) *MockUserProtectionService_AllowUserMapping_Call {
_c.Call.Return(run)
return _c
}

View File

@ -11,6 +11,7 @@ import (
"github.com/go-jose/go-jose/v4/jwt"
"github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"golang.org/x/oauth2"
@ -57,8 +58,14 @@ var _ OAuthTokenService = (*Service)(nil)
type OAuthTokenService interface {
GetCurrentOAuthToken(context.Context, identity.Requester, *auth.UserToken) *oauth2.Token
IsOAuthPassThruEnabled(*datasources.DataSource) bool
TryTokenRefresh(context.Context, identity.Requester, *auth.UserToken) (*oauth2.Token, error)
InvalidateOAuthTokens(context.Context, identity.Requester, *auth.UserToken) error
TryTokenRefresh(context.Context, identity.Requester, *TokenRefreshMetadata) (*oauth2.Token, error)
InvalidateOAuthTokens(context.Context, identity.Requester, *TokenRefreshMetadata) error
}
type TokenRefreshMetadata struct {
ExternalSessionID int64
AuthModule string
AuthID string
}
func ProvideService(socialService social.Service, authInfoService login.AuthInfoService, cfg *setting.Cfg, registerer prometheus.Registerer,
@ -102,51 +109,71 @@ func (o *Service) GetCurrentOAuthToken(ctx context.Context, usr identity.Request
ctxLogger = ctxLogger.New("userID", userID)
if !strings.HasPrefix(usr.GetAuthenticatedBy(), "oauth_") {
ctxLogger.Warn("The specified user's auth provider is not oauth",
"authmodule", usr.GetAuthenticatedBy())
tokenRefreshMetadata := &TokenRefreshMetadata{
ExternalSessionID: 0,
}
var persistedToken *oauth2.Token
// Find the external session associated with the user and session token
// regardless of the improvedExternalSessionHandling feature toggle,
// because Grafana writes and updates both tables to make the switch
// to the new session handling smoother.
externalSession, err := o.getExternalSession(ctx, usr, userID, sessionToken)
if err != nil && !errors.Is(err, auth.ErrExternalSessionNotFound) {
ctxLogger.Error("Failed to get external session", "error", err)
return nil
}
// If the feature toggle is enabled, an external session is required.
if o.features.IsEnabledGlobally(featuremgmt.FlagImprovedExternalSessionHandling) && (externalSession == nil || errors.Is(err, auth.ErrExternalSessionNotFound)) {
ctxLogger.Error("No external session found for user", "userID", userID)
return nil
}
// externalSession can be nil if Grafana was updated from a version where the
// external session table was not used yet (did not exist) and the user has not logged in since
// the version update (therefore no external session was created for the user yet).
if externalSession != nil {
tokenRefreshMetadata.ExternalSessionID = externalSession.ID
}
authInfo, err := o.AuthInfoService.GetAuthInfo(ctx, &login.GetAuthInfoQuery{
UserId: userID,
})
if err != nil {
if errors.Is(err, user.ErrUserNotFound) {
ctxLogger.Warn("No AuthInfo found for user", "userID", userID)
return nil
}
ctxLogger.Error("Failed to fetch AuthInfo for user", "userID", userID, "error", err)
return nil
}
tokenRefreshMetadata.AuthID = authInfo.AuthId
tokenRefreshMetadata.AuthModule = authInfo.AuthModule
if !strings.HasPrefix(tokenRefreshMetadata.AuthModule, "oauth_") {
ctxLogger.Warn("The specified user's auth provider is not oauth",
"authmodule", tokenRefreshMetadata.AuthModule)
return nil
}
var persistedToken *oauth2.Token
if o.features.IsEnabledGlobally(featuremgmt.FlagImprovedExternalSessionHandling) {
externalSession, err := o.sessionService.GetExternalSession(ctx, sessionToken.ExternalSessionId)
if err != nil {
if errors.Is(err, auth.ErrExternalSessionNotFound) {
return nil
}
ctxLogger.Error("Failed to fetch external session", "error", err)
return nil
}
persistedToken = buildOAuthTokenFromExternalSession(externalSession)
if persistedToken.RefreshToken == "" {
return persistedToken
}
} else {
authInfo, ok, _ := o.hasOAuthEntry(ctx, usr)
if !ok {
return nil
}
if err := checkOAuthRefreshToken(authInfo); err != nil {
if errors.Is(err, ErrNoRefreshTokenFound) {
return buildOAuthTokenFromAuthInfo(authInfo)
}
return nil
}
persistedToken = buildOAuthTokenFromAuthInfo(authInfo)
}
if persistedToken.RefreshToken == "" {
return persistedToken
}
refreshNeeded := needTokenRefresh(ctx, persistedToken)
if !refreshNeeded {
return persistedToken
}
token, err := o.TryTokenRefresh(ctx, usr, sessionToken)
token, err := o.TryTokenRefresh(ctx, usr, tokenRefreshMetadata)
if err != nil {
if errors.Is(err, ErrNoRefreshTokenFound) {
return persistedToken
@ -214,7 +241,7 @@ func (o *Service) hasOAuthEntry(ctx context.Context, usr identity.Requester) (*l
// TryTokenRefresh returns an error in case the OAuth token refresh was unsuccessful
// It uses a server lock to prevent getting the Refresh Token multiple times for a given User
func (o *Service) TryTokenRefresh(ctx context.Context, usr identity.Requester, sessionToken *auth.UserToken) (*oauth2.Token, error) {
func (o *Service) TryTokenRefresh(ctx context.Context, usr identity.Requester, tokenRefreshMetadata *TokenRefreshMetadata) (*oauth2.Token, error) {
ctx, span := o.tracer.Start(ctx, "oauthtoken.TryTokenRefresh")
defer span.End()
@ -239,14 +266,13 @@ func (o *Service) TryTokenRefresh(ctx context.Context, usr identity.Requester, s
ctxLogger = ctxLogger.New("userID", userID)
// get the token's auth provider (f.e. azuread)
currAuthenticator := usr.GetAuthenticatedBy()
if !strings.HasPrefix(currAuthenticator, "oauth") {
ctxLogger.Warn("The specified user's auth provider is not OAuth", "authmodule", currAuthenticator)
if !strings.HasPrefix(tokenRefreshMetadata.AuthModule, "oauth_") {
ctxLogger.Warn("The specified user's auth provider is not oauth",
"authmodule", tokenRefreshMetadata.AuthModule)
return nil, nil
}
provider := strings.TrimPrefix(currAuthenticator, "oauth_")
provider := strings.TrimPrefix(tokenRefreshMetadata.AuthModule, "oauth_")
currentOAuthInfo := o.SocialService.GetOAuthInfoProvider(provider)
if currentOAuthInfo == nil {
ctxLogger.Warn("OAuth provider not found", "provider", provider)
@ -261,7 +287,7 @@ func (o *Service) TryTokenRefresh(ctx context.Context, usr identity.Requester, s
lockKey := fmt.Sprintf("oauth-refresh-token-%d", userID)
if o.features.IsEnabledGlobally(featuremgmt.FlagImprovedExternalSessionHandling) {
lockKey = fmt.Sprintf("oauth-refresh-token-%d-%d", userID, sessionToken.ExternalSessionId)
lockKey = fmt.Sprintf("oauth-refresh-token-%d-%d", userID, tokenRefreshMetadata.ExternalSessionID)
}
lockTimeConfig := serverlock.LockTimeConfig{
@ -290,7 +316,7 @@ func (o *Service) TryTokenRefresh(ctx context.Context, usr identity.Requester, s
var persistedToken *oauth2.Token
var externalSession *auth.ExternalSession
if o.features.IsEnabledGlobally(featuremgmt.FlagImprovedExternalSessionHandling) {
externalSession, err = o.sessionService.GetExternalSession(ctx, sessionToken.ExternalSessionId)
externalSession, err = o.sessionService.GetExternalSession(ctx, tokenRefreshMetadata.ExternalSessionID)
if err != nil {
if errors.Is(err, auth.ErrExternalSessionNotFound) {
ctxLogger.Error("External session was not found for user", "error", err)
@ -321,7 +347,7 @@ func (o *Service) TryTokenRefresh(ctx context.Context, usr identity.Requester, s
return
}
newToken, cmdErr = o.tryGetOrRefreshOAuthToken(ctx, persistedToken, usr, sessionToken)
newToken, cmdErr = o.tryGetOrRefreshOAuthToken(ctx, persistedToken, usr, tokenRefreshMetadata)
}, retryOpt)
if lockErr != nil {
ctxLogger.Error("Failed to obtain token refresh lock", "error", lockErr)
@ -330,14 +356,14 @@ func (o *Service) TryTokenRefresh(ctx context.Context, usr identity.Requester, s
// Silence ErrNoRefreshTokenFound
if errors.Is(cmdErr, ErrNoRefreshTokenFound) {
return nil, nil
return nil, ErrNoRefreshTokenFound
}
return newToken, cmdErr
}
// InvalidateOAuthTokens invalidates the OAuth tokens (access_token, refresh_token) and sets the Expiry to default/zero
func (o *Service) InvalidateOAuthTokens(ctx context.Context, usr identity.Requester, sessionToken *auth.UserToken) error {
func (o *Service) InvalidateOAuthTokens(ctx context.Context, usr identity.Requester, tokenRefreshMetadata *TokenRefreshMetadata) error {
userID, err := usr.GetInternalID()
if err != nil {
logger.Error("Failed to convert user id to int", "id", usr.GetID(), "error", err)
@ -347,7 +373,7 @@ func (o *Service) InvalidateOAuthTokens(ctx context.Context, usr identity.Reques
ctxLogger := logger.FromContext(ctx).New("userID", userID)
if o.features.IsEnabledGlobally(featuremgmt.FlagImprovedExternalSessionHandling) {
err := o.sessionService.UpdateExternalSession(ctx, sessionToken.ExternalSessionId, &auth.UpdateExternalSessionCommand{
err := o.sessionService.UpdateExternalSession(ctx, tokenRefreshMetadata.ExternalSessionID, &auth.UpdateExternalSessionCommand{
Token: &oauth2.Token{},
})
if err != nil {
@ -358,8 +384,8 @@ func (o *Service) InvalidateOAuthTokens(ctx context.Context, usr identity.Reques
return o.AuthInfoService.UpdateAuthInfo(ctx, &login.UpdateAuthInfoCommand{
UserId: userID,
AuthModule: usr.GetAuthenticatedBy(),
AuthId: usr.GetAuthID(),
AuthModule: tokenRefreshMetadata.AuthModule,
AuthId: tokenRefreshMetadata.AuthID,
OAuthToken: &oauth2.Token{
AccessToken: "",
RefreshToken: "",
@ -368,13 +394,14 @@ func (o *Service) InvalidateOAuthTokens(ctx context.Context, usr identity.Reques
})
}
func (o *Service) tryGetOrRefreshOAuthToken(ctx context.Context, persistedToken *oauth2.Token, usr identity.Requester, sessionToken *auth.UserToken) (*oauth2.Token, error) {
func (o *Service) tryGetOrRefreshOAuthToken(ctx context.Context, persistedToken *oauth2.Token, usr identity.Requester, tokenRefreshMetadata *TokenRefreshMetadata) (*oauth2.Token, error) {
ctx, span := o.tracer.Start(ctx, "oauthtoken.tryGetOrRefreshOAuthToken")
defer span.End()
userID, err := usr.GetInternalID()
if err != nil {
logger.Error("Failed to convert user id to int", "id", usr.GetID(), "error", err)
span.SetStatus(codes.Error, "Failed to convert user id to int")
return nil, err
}
@ -382,8 +409,11 @@ func (o *Service) tryGetOrRefreshOAuthToken(ctx context.Context, persistedToken
ctxLogger := logger.FromContext(ctx).New("userID", userID)
// tryGetOrRefreshOAuthToken assumes that the AuthModule has RefreshToken enabled
// which is checked by the caller (TryTokenRefresh)
if persistedToken.RefreshToken == "" {
ctxLogger.Warn("No refresh token available", "authmodule", usr.GetAuthenticatedBy())
ctxLogger.Error("No refresh token available", "authmodule", tokenRefreshMetadata.AuthModule)
span.SetStatus(codes.Error, ErrNoRefreshTokenFound.Error())
return nil, ErrNoRefreshTokenFound
}
@ -392,50 +422,44 @@ func (o *Service) tryGetOrRefreshOAuthToken(ctx context.Context, persistedToken
return persistedToken, nil
}
authProvider := usr.GetAuthenticatedBy()
connect, err := o.SocialService.GetConnector(authProvider)
connect, err := o.SocialService.GetConnector(tokenRefreshMetadata.AuthModule)
if err != nil {
ctxLogger.Error("Failed to get oauth connector", "provider", authProvider, "error", err)
ctxLogger.Error("Failed to get oauth connector", "provider", tokenRefreshMetadata.AuthModule, "error", err)
span.SetStatus(codes.Error, "Failed to get oauth connector: "+err.Error())
return nil, err
}
client, err := o.SocialService.GetOAuthHttpClient(authProvider)
client, err := o.SocialService.GetOAuthHttpClient(tokenRefreshMetadata.AuthModule)
if err != nil {
ctxLogger.Error("Failed to get oauth http client", "provider", authProvider, "error", err)
ctxLogger.Error("Failed to get oauth http client", "provider", tokenRefreshMetadata.AuthModule, "error", err)
span.SetStatus(codes.Error, "Failed to get oauth http client")
return nil, err
}
ctx = context.WithValue(ctx, oauth2.HTTPClient, client)
start := time.Now()
// TokenSource handles refreshing the token if it has expired
token, err := connect.TokenSource(ctx, persistedToken).Token()
token, refreshErr := connect.TokenSource(ctx, persistedToken).Token()
duration := time.Since(start)
o.tokenRefreshDuration.WithLabelValues(authProvider, fmt.Sprintf("%t", err == nil)).Observe(duration.Seconds())
o.tokenRefreshDuration.WithLabelValues(tokenRefreshMetadata.AuthModule, fmt.Sprintf("%t", err == nil)).Observe(duration.Seconds())
if err != nil {
if refreshErr != nil {
span.SetAttributes(attribute.Bool("token_refreshed", false))
ctxLogger.Error("Failed to retrieve oauth access token",
"provider", usr.GetAuthenticatedBy(), "error", err)
"provider", tokenRefreshMetadata.AuthModule, "error", refreshErr)
// token refresh failed, invalidate the old token
if err := o.InvalidateOAuthTokens(ctx, usr, sessionToken); err != nil {
ctxLogger.Warn("Failed to invalidate OAuth tokens", "authID", usr.GetAuthID(), "error", err)
if err := o.InvalidateOAuthTokens(ctx, usr, tokenRefreshMetadata); err != nil {
ctxLogger.Warn("Failed to invalidate OAuth tokens", "authID", tokenRefreshMetadata.AuthID, "error", err)
}
return nil, err
return nil, refreshErr
}
span.SetAttributes(attribute.Bool("token_refreshed", true))
// If the tokens are not the same, update the entry in the DB
if !tokensEq(persistedToken, token) {
updateAuthCommand := &login.UpdateAuthInfoCommand{
UserId: userID,
AuthModule: usr.GetAuthenticatedBy(),
AuthId: usr.GetAuthID(),
OAuthToken: token,
}
if o.Cfg.Env == setting.Dev {
ctxLogger.Debug("Oauth got token",
"auth_module", usr.GetAuthenticatedBy(),
@ -446,17 +470,32 @@ func (o *Service) tryGetOrRefreshOAuthToken(ctx context.Context, persistedToken
}
if !o.features.IsEnabledGlobally(featuremgmt.FlagImprovedExternalSessionHandling) {
updateAuthCommand := &login.UpdateAuthInfoCommand{
UserId: userID,
AuthModule: tokenRefreshMetadata.AuthModule,
AuthId: tokenRefreshMetadata.AuthID,
OAuthToken: token,
}
if err := o.AuthInfoService.UpdateAuthInfo(ctx, updateAuthCommand); err != nil {
ctxLogger.Error("Failed to update auth info during token refresh", "authID", usr.GetAuthID(), "error", err)
ctxLogger.Error("Failed to update auth info during token refresh", "authID", tokenRefreshMetadata.AuthID, "error", err)
span.SetStatus(codes.Error, "Failed to update auth info during token refresh")
return nil, err
}
}
if err := o.sessionService.UpdateExternalSession(ctx, sessionToken.ExternalSessionId, &auth.UpdateExternalSessionCommand{
Token: token,
}); err != nil {
ctxLogger.Error("Failed to update external session during token refresh", "error", err)
return nil, err
// Update the external session with the new token if we the user has an external session,
// regardless of the feature flag state to keep the `user_external_session` table in sync.
// ExternalSessionID should always be set except for some edge cases:
// - when Grafana was updated to a version where the `improvedExternalSessionHandling` feature flag
// was enabled after the user logged in
if tokenRefreshMetadata.ExternalSessionID != 0 {
if err := o.sessionService.UpdateExternalSession(ctx, tokenRefreshMetadata.ExternalSessionID, &auth.UpdateExternalSessionCommand{
Token: token,
}); err != nil {
ctxLogger.Error("Failed to update external session during token refresh", "error", err)
span.SetStatus(codes.Error, "Failed to update external session during token refresh")
return nil, err
}
}
ctxLogger.Debug("Updated oauth info for user")
@ -502,6 +541,11 @@ func needTokenRefresh(ctx context.Context, persistedToken *oauth2.Token) bool {
ctxLogger := logger.FromContext(ctx)
if persistedToken.AccessToken == "" {
ctxLogger.Debug("Access token has been cleared, need to refresh")
return true
}
idTokenExp, err := GetIDTokenExpiry(persistedToken)
if err != nil {
ctxLogger.Warn("Could not get ID Token expiry", "error", err)
@ -552,22 +596,6 @@ func buildOAuthTokenFromExternalSession(externalSession *auth.ExternalSession) *
return token
}
func checkOAuthRefreshToken(authInfo *login.UserAuth) error {
if !strings.Contains(authInfo.AuthModule, "oauth") {
logger.Warn("The specified user's auth provider is not oauth",
"authmodule", authInfo.AuthModule, "userid", authInfo.UserId)
return ErrNotAnOAuthProvider
}
if authInfo.OAuthRefreshToken == "" {
logger.Warn("No refresh token available",
"authmodule", authInfo.AuthModule, "userid", authInfo.UserId)
return ErrNoRefreshTokenFound
}
return nil
}
// GetIDTokenExpiry extracts the expiry time from the ID token
func GetIDTokenExpiry(token *oauth2.Token) (time.Time, error) {
idToken, ok := token.Extra("id_token").(string)
@ -601,3 +629,28 @@ func getExpiryWithSkew(expiry time.Time) (adjustedExpiry time.Time, hasTokenExpi
hasTokenExpired = adjustedExpiry.Before(time.Now())
return
}
// getExternalSession fetches the external session based on the user and session token.
// When using the render module, it fetches the most recent external session for the user
// since the session token ID is not available.
// For regular users, it uses the session token ID to fetch the external session.
func (o *Service) getExternalSession(ctx context.Context, usr identity.Requester, userID int64, sessionToken *auth.UserToken) (*auth.ExternalSession, error) {
if usr.GetAuthenticatedBy() == login.RenderModule {
// When using render module, we don't have the session token ID, so we need to fetch the most recent session
// entry for the user (as it is done with the old flow).
// In the future, we might want to consider passing the session token ID to the render module to make this more robust.
externalSessions, err := o.sessionService.FindExternalSessions(ctx, &auth.ListExternalSessionQuery{UserID: userID})
if err != nil {
return nil, err
}
if len(externalSessions) == 0 || externalSessions[0] == nil {
return nil, auth.ErrExternalSessionNotFound
}
return externalSessions[0], nil
}
// For regular users, we use the session token ID to fetch the external session
return o.sessionService.GetExternalSession(ctx, sessionToken.ExternalSessionId)
}

File diff suppressed because it is too large Load Diff

View File

@ -8,13 +8,14 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/oauthtoken"
)
type MockOauthTokenService struct {
GetCurrentOauthTokenFunc func(ctx context.Context, usr identity.Requester, sessionToken *auth.UserToken) *oauth2.Token
IsOAuthPassThruEnabledFunc func(ds *datasources.DataSource) bool
InvalidateOAuthTokensFunc func(ctx context.Context, usr identity.Requester, sessionToken *auth.UserToken) error
TryTokenRefreshFunc func(ctx context.Context, usr identity.Requester, sessionToken *auth.UserToken) (*oauth2.Token, error)
InvalidateOAuthTokensFunc func(ctx context.Context, usr identity.Requester, metadata *oauthtoken.TokenRefreshMetadata) error
TryTokenRefreshFunc func(ctx context.Context, usr identity.Requester, metadata *oauthtoken.TokenRefreshMetadata) (*oauth2.Token, error)
}
func (m *MockOauthTokenService) GetCurrentOAuthToken(ctx context.Context, usr identity.Requester, sessionToken *auth.UserToken) *oauth2.Token {
@ -31,16 +32,16 @@ func (m *MockOauthTokenService) IsOAuthPassThruEnabled(ds *datasources.DataSourc
return false
}
func (m *MockOauthTokenService) InvalidateOAuthTokens(ctx context.Context, usr identity.Requester, sessionToken *auth.UserToken) error {
func (m *MockOauthTokenService) InvalidateOAuthTokens(ctx context.Context, usr identity.Requester, metadata *oauthtoken.TokenRefreshMetadata) error {
if m.InvalidateOAuthTokensFunc != nil {
return m.InvalidateOAuthTokensFunc(ctx, usr, sessionToken)
return m.InvalidateOAuthTokensFunc(ctx, usr, metadata)
}
return nil
}
func (m *MockOauthTokenService) TryTokenRefresh(ctx context.Context, usr identity.Requester, sessionToken *auth.UserToken) (*oauth2.Token, error) {
func (m *MockOauthTokenService) TryTokenRefresh(ctx context.Context, usr identity.Requester, metadata *oauthtoken.TokenRefreshMetadata) (*oauth2.Token, error) {
if m.TryTokenRefreshFunc != nil {
return m.TryTokenRefreshFunc(ctx, usr, sessionToken)
return m.TryTokenRefreshFunc(ctx, usr, metadata)
}
return nil, nil
}

View File

@ -29,10 +29,10 @@ func (s *Service) IsOAuthPassThruEnabled(ds *datasources.DataSource) bool {
return oauthtoken.IsOAuthPassThruEnabled(ds)
}
func (s *Service) TryTokenRefresh(context.Context, identity.Requester, *auth.UserToken) (*oauth2.Token, error) {
func (s *Service) TryTokenRefresh(context.Context, identity.Requester, *oauthtoken.TokenRefreshMetadata) (*oauth2.Token, error) {
return s.Token, nil
}
func (s *Service) InvalidateOAuthTokens(context.Context, identity.Requester, *auth.UserToken) error {
func (s *Service) InvalidateOAuthTokens(context.Context, identity.Requester, *oauthtoken.TokenRefreshMetadata) error {
return nil
}

View File

@ -9,7 +9,6 @@ import { config } from 'app/core/config';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { getGrafanaSearcher } from 'app/features/search/service/searcher';
import { DashboardSearchItemType } from 'app/features/search/types';
import { PermissionLevelString } from 'app/types/acl';
import { FolderInfo } from 'app/types/folders';
export interface FolderFilterProps {
@ -69,7 +68,7 @@ async function getFoldersAsOptions(
query: searchString,
kind: ['folder'],
limit: 100,
permission: PermissionLevelString.View,
permission: 'view',
});
const options = queryResponse.view.map((item) => ({
@ -89,7 +88,7 @@ async function getFoldersAsOptions(
const params = {
query: searchString,
type: DashboardSearchItemType.DashFolder,
permission: PermissionLevelString.View,
permission: 'view',
};
const searchHits = await getBackendSrv().search(params);

View File

@ -14,7 +14,7 @@ import { getGrafanaSearcher } from 'app/features/search/service/searcher';
import { QueryResponse } from 'app/features/search/service/types';
import { queryResultToViewItem } from 'app/features/search/service/utils';
import { DashboardViewItem } from 'app/features/search/types';
import { PermissionLevelString } from 'app/types/acl';
import { PermissionLevel } from 'app/types/acl';
import { FolderRepo } from './FolderRepo';
import { getDOMId, NestedFolderList } from './NestedFolderList';
@ -57,7 +57,7 @@ export interface NestedFolderPickerProps {
const debouncedSearch = debounce(getSearchResults, 300);
async function getSearchResults(searchQuery: string, permission?: PermissionLevelString) {
async function getSearchResults(searchQuery: string, permission?: PermissionLevel) {
const queryResponse = await getGrafanaSearcher().search({
query: searchQuery,
kind: ['folder'],
@ -98,17 +98,6 @@ export function NestedFolderPicker({
const [error] = useState<Error | undefined>(undefined); // TODO: error not populated anymore
const lastSearchTimestamp = useRef<number>(0);
// Map the permission string union to enum value for compatibility
const permissionLevel = useMemo(() => {
if (permission === 'view') {
return PermissionLevelString.View;
} else if (permission === 'edit') {
return PermissionLevelString.Edit;
}
throw new Error('Invalid permission');
}, [permission]);
const isBrowsing = Boolean(overlayOpen && !(search && searchResults));
const {
emptyFolders,
@ -118,7 +107,7 @@ export function NestedFolderPicker({
} = useFoldersQuery({
isBrowsing,
openFolders: foldersOpenState,
permission: permissionLevel,
permission,
rootFolderUID,
rootFolderItem,
});
@ -132,7 +121,7 @@ export function NestedFolderPicker({
const timestamp = Date.now();
setIsFetchingSearchResults(true);
debouncedSearch(search, permissionLevel).then((queryResponse) => {
debouncedSearch(search, permission).then((queryResponse) => {
// Only keep the results if it's was issued after the most recently resolved search.
// This prevents results showing out of order if first request is slower than later ones.
// We don't need to worry about clearing the isFetching state either - if there's a later
@ -144,7 +133,7 @@ export function NestedFolderPicker({
lastSearchTimestamp.current = timestamp;
}
});
}, [search, permissionLevel]);
}, [search, permission]);
// the order of middleware is important!
const middleware = [

View File

@ -1,6 +1,6 @@
import { config } from '@grafana/runtime';
import { DashboardsTreeItem } from 'app/features/browse-dashboards/types';
import { PermissionLevelString } from 'app/types/acl';
import { PermissionLevel } from 'app/types/acl';
import { useFoldersQueryAppPlatform } from './useFoldersQueryAppPlatform';
import { useFoldersQueryLegacy } from './useFoldersQueryLegacy';
@ -8,7 +8,7 @@ import { useFoldersQueryLegacy } from './useFoldersQueryLegacy';
export interface UseFoldersQueryProps {
isBrowsing: boolean;
openFolders: Record<string, boolean>;
permission?: PermissionLevelString;
permission?: PermissionLevel;
rootFolderUID?: string;
rootFolderItem?: DashboardsTreeItem;
}

View File

@ -3,7 +3,6 @@ import { Navigate } from 'react-router-dom-v5-compat';
import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport';
import { config } from 'app/core/config';
import { GrafanaRouteComponent, RouteDescriptor } from 'app/core/navigation/types';
import { AlertingPageWrapper } from 'app/features/alerting/unified/components/AlertingPageWrapper';
import { AccessControlAction } from 'app/types/accessControl';
import { PERMISSIONS_CONTACT_POINTS } from './unified/components/contact-points/permissions';
@ -338,7 +337,9 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] {
routes.push({
path: '/alerting/triage',
roles: evaluateAccess([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleExternalRead]),
component: () => <AlertingPageWrapper />,
component: importAlertingComponent(
() => import(/* webpackChunkName: "AlertingTriage" */ 'app/features/alerting/unified/triage/Triage')
),
});
}

View File

@ -0,0 +1,59 @@
import { css } from '@emotion/css';
import * as React from 'react';
import { type MergeExclusive } from 'type-fest';
import { GrafanaTheme2 } from '@grafana/data';
import { Label, Stack, useStyles2 } from '@grafana/ui';
interface BaseProps {
id?: string;
}
interface ChildrenProps extends BaseProps {
children: React.ReactNode;
}
interface LabelActionsProps extends BaseProps {
label: string;
actions?: React.ReactNode;
}
type Props = MergeExclusive<ChildrenProps, LabelActionsProps>;
export function EditorColumnHeader({ label, actions, id, children }: Props) {
const styles = useStyles2(editorColumnStyles);
if (children) {
return <div className={styles.container}>{children}</div>;
}
return (
<div className={styles.container}>
<Label className={styles.label} id={id}>
{label}
</Label>
{actions && (
<Stack direction="row" gap={1}>
{actions}
</Stack>
)}
</div>
);
}
const editorColumnStyles = (theme: GrafanaTheme2) => ({
container: css({
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: theme.spacing(1, 2),
backgroundColor: theme.colors.background.secondary,
border: `1px solid ${theme.colors.border.medium}`,
borderTopLeftRadius: theme.shape.radius.default,
borderTopRightRadius: theme.shape.radius.default,
}),
label: css({
margin: 0,
}),
});

View File

@ -1,37 +0,0 @@
import { css } from '@emotion/css';
import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Label, Stack, useStyles2 } from '@grafana/ui';
type Props = { label: string; actions?: React.ReactNode; id?: string };
export function EditorColumnHeader({ label, actions, id }: Props) {
const styles = useStyles2(editorColumnStyles);
return (
<div className={styles.container}>
<Label className={styles.label} id={id}>
{label}
</Label>
<Stack direction="row" gap={1}>
{actions}
</Stack>
</div>
);
}
const editorColumnStyles = (theme: GrafanaTheme2) => ({
container: css({
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: theme.spacing(1, 2),
backgroundColor: theme.colors.background.secondary,
borderBottom: `1px solid ${theme.colors.border.medium}`,
}),
label: css({
margin: 0,
}),
});

View File

@ -8,7 +8,7 @@ import { Trans, t } from '@grafana/i18n';
import { Button, CodeEditor, Dropdown, Menu, Stack, Toggletip, useStyles2 } from '@grafana/ui';
import { TestTemplateAlert } from 'app/plugins/datasource/alertmanager/types';
import { EditorColumnHeader } from '../contact-points/templates/EditorColumnHeader';
import { EditorColumnHeader } from '../EditorColumnHeader';
import { AlertInstanceModalSelector } from './AlertInstanceModalSelector';
import { AlertTemplatePreviewData } from './TemplateData';

View File

@ -32,9 +32,9 @@ import { TestTemplateAlert } from 'app/plugins/datasource/alertmanager/types';
import { AITemplateButtonComponent } from '../../enterprise-components/AI/AIGenTemplateButton/addAITemplateButton';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { makeAMLink, stringifyErrorLike } from '../../utils/misc';
import { EditorColumnHeader } from '../EditorColumnHeader';
import { ProvisionedResource, ProvisioningAlert } from '../Provisioning';
import { Spacer } from '../Spacer';
import { EditorColumnHeader } from '../contact-points/templates/EditorColumnHeader';
import {
NotificationTemplate,
useCreateNotificationTemplate,

View File

@ -10,7 +10,7 @@ import { Alert, Box, Button, CodeEditor, useStyles2 } from '@grafana/ui';
import { TemplatePreviewErrors, TemplatePreviewResponse, TemplatePreviewResult } from '../../api/templateApi';
import { AIFeedbackButtonComponent } from '../../enterprise-components/AI/addAIFeedbackButton';
import { stringifyErrorLike } from '../../utils/misc';
import { EditorColumnHeader } from '../contact-points/templates/EditorColumnHeader';
import { EditorColumnHeader } from '../EditorColumnHeader';
import { usePreviewTemplate } from './usePreviewTemplate';

View File

@ -8,7 +8,7 @@ import { Box, useStyles2 } from '@grafana/ui';
import { useAlertmanager } from 'app/features/alerting/unified/state/AlertmanagerContext';
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
import { EditorColumnHeader } from '../../../contact-points/templates/EditorColumnHeader';
import { EditorColumnHeader } from '../../../EditorColumnHeader';
import { TemplateEditor } from '../../TemplateEditor';
import { TemplatePreview } from '../../TemplatePreview';

View File

@ -0,0 +1,37 @@
import { scaleTime } from 'd3-scale';
import { useMemo } from 'react';
import { useMeasure } from 'react-use';
import { Stack, Text } from '@grafana/ui';
import { Domain } from './types';
interface TimelineProps {
domain: Domain;
}
export const TimelineHeader = ({ domain }: TimelineProps) => {
const [ref, { width }] = useMeasure<HTMLDivElement>();
const ticks = useMemo(() => {
const xScale = scaleTime().domain(domain).range([0, width]).nice(0);
const tickFormatter = xScale.tickFormat();
return xScale.ticks(5).map((value) => ({
value: tickFormatter(value),
xOffset: xScale(value),
}));
}, [domain, width]);
return (
<div ref={ref} style={{ width: '100%' }}>
<Stack flex={1} direction="row" justifyContent="space-between">
{ticks.map((tick) => (
<Text key={`${tick.value}-${tick.xOffset}`} variant="bodySmall" color="secondary">
{tick.value}
</Text>
))}
</Stack>
</div>
);
};

View File

@ -0,0 +1,19 @@
# Triage view
The triage view should serve several purposes and be a central place for users to manage their alert instances.
## Goals
- Observe the current state of their system
- Help correlate alerts with each other
- Be a launchpad for further investigation
## Non-goals
- Managing alert rules
## Technical goals
- Build re-usable components that can be used in other parts of Grafana and plugins
- These should be a mix of presentation components and data components
- Eventually most of this should live in the Grafana Alerting package

View File

@ -0,0 +1,25 @@
import { t } from '@grafana/i18n';
import { UrlSyncContextProvider } from '@grafana/scenes';
import { withErrorBoundary } from '@grafana/ui';
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
import { TriageScene, triageScene } from './scene/TriageScene';
export const TriagePage = () => {
return (
<AlertingPageWrapper
navId="alerting"
subTitle={t('alerting.pages.triage.subtitle', 'Learn about problems in your systems moments after they occur')}
pageNav={{
text: t('alerting.pages.triage.title', 'Triage'),
}}
>
<UrlSyncContextProvider scene={triageScene} updateUrlOnInit={true} createBrowserHistorySteps={true}>
<TriageScene key={triageScene.state.key} />
</UrlSyncContextProvider>
</AlertingPageWrapper>
);
};
export default withErrorBoundary(TriagePage);

View File

@ -0,0 +1,211 @@
import { css, cx } from '@emotion/css';
import { take } from 'lodash';
import { useState } from 'react';
import { useMeasure } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { SceneQueryRunner } from '@grafana/scenes';
import { ScrollContainer, useSplitter, useStyles2 } from '@grafana/ui';
import { DEFAULT_PER_PAGE_PAGINATION } from 'app/core/constants';
import { EditorColumnHeader } from '../components/EditorColumnHeader';
import LoadMoreHelper from '../rule-list/LoadMoreHelper';
import { TimelineHeader } from './Timeline';
import { WorkbenchProvider } from './WorkbenchContext';
import { AlertRuleRow } from './rows/AlertRuleRow';
import { FolderGroupRow } from './rows/FolderGroupRow';
import { GroupRow } from './rows/GroupRow';
import { generateRowKey } from './rows/utils';
import { GenericRowSkeleton } from './scene/AlertRuleInstances';
import { SummaryChartReact } from './scene/SummaryChart';
import { SummaryStatsReact } from './scene/SummaryStats';
import { Domain, Filter, WorkbenchRow } from './types';
type WorkbenchProps = {
domain: Domain;
data: WorkbenchRow[];
groupBy?: string[]; // @TODO proper type
filterBy?: Filter[];
queryRunner: SceneQueryRunner;
};
const initialSize = 1 / 3;
// Helper function to recursively render WorkbenchRow items with children pattern
function renderWorkbenchRow(
row: WorkbenchRow,
leftColumnWidth: number,
domain: Domain,
key: React.Key,
depth = 0
): React.ReactElement {
if (row.type === 'alertRule') {
return <AlertRuleRow key={key} row={row} leftColumnWidth={leftColumnWidth} rowKey={key} depth={depth} />;
} else {
const children = row.rows.map((childRow, childIndex) =>
renderWorkbenchRow(childRow, leftColumnWidth, domain, `${key}-${generateRowKey(childRow, childIndex)}`, depth + 1)
);
// Check if this is a grafana_folder group and use FolderGroupRow
if (row.metadata.label === 'grafana_folder') {
return (
<FolderGroupRow key={key} row={row} leftColumnWidth={leftColumnWidth} rowKey={key} depth={depth}>
{children}
</FolderGroupRow>
);
}
return (
<GroupRow key={key} row={row} leftColumnWidth={leftColumnWidth} rowKey={key} depth={depth}>
{children}
</GroupRow>
);
}
}
/**
* The workbench displays groups of alerts, each group containing metadata and a chart.
* Alerts can be arbitrarily grouped by any number of labels. By default all instances are grouped by alertname.
*
* The page consist of a left column with metadata for the row and a right column with charts.
* Below is a rough layout of the page:
*
* The page is divided into two columns, the size of these columns is determined by the splitter.
* There is a useMeasure hook to measure the size of the left column, which is used to set the width of the group items.
* We do this because each row needs to be a flex container such that if the height of the left colorn changes, the
* right column will also change its height accordingly. This would not be possible if we used a simplified column layout.
*
* This also means we draw the rows _on top_ of the splitter, in other words the contents of the splitter are empty
* and we only use it to determine the width of the left column of the rows that are overlayed on top.
*
* Each group is a row with a left and a right column. Each row consists of two cells (the left and the right cell).
* The left cell contains the metadata for the group, the right cell contains the chart.
Row
Cell Cell
*/
export function Workbench({ domain, data, queryRunner }: WorkbenchProps) {
const styles = useStyles2(getStyles);
const isLoading = !queryRunner.isDataReadyToDisplay();
const [pageIndex, setPageIndex] = useState<number>(1);
// splitter for template and payload editor
const splitter = useSplitter({
direction: 'row',
// if Grafana Alertmanager, split 50/50, otherwise 100/0 because there is no payload editor
initialSize: initialSize,
dragPosition: 'middle',
});
// this will measure the size of the left most column of the splitter, so we can use it to set the width of the group items
const [ref, rect] = useMeasure<HTMLDivElement>();
const leftColumnWidth = rect.width;
const itemsToRender = pageIndex * DEFAULT_PER_PAGE_PAGINATION;
const dataSlice = take(data, itemsToRender);
const hasMore = data.length > itemsToRender;
return (
<div style={{ position: 'relative', display: 'flex', flexGrow: 1, width: '100%', height: '100%' }}>
{/* dummy splitter to handle flex width of group items */}
<div {...splitter.containerProps}>
<div {...splitter.primaryProps}>
<div ref={ref} className={cx(styles.flexFull, styles.minColumnWidth)} />
</div>
<div {...splitter.splitterProps} />
<div {...splitter.secondaryProps}>
<div className={cx(styles.flexFull, styles.minColumnWidth)} />
</div>
</div>
{/* content goes here */}
<div data-testid="groups-container" className={cx(splitter.containerProps.className, styles.groupsContainer)}>
<div className={cx(styles.groupItemWrapper(leftColumnWidth), styles.summaryContainer)}>
<SummaryStatsReact />
<SummaryChartReact />
</div>
<div className={cx(styles.groupItemWrapper(leftColumnWidth), styles.headerContainer)}>
<EditorColumnHeader label={t('alerting.left-column.label-instances', 'Instances')} />
<EditorColumnHeader>
<TimelineHeader domain={domain} />
</EditorColumnHeader>
</div>
{/* Render actual data */}
<div className={styles.virtualizedContainer}>
<WorkbenchProvider leftColumnWidth={leftColumnWidth} domain={domain} queryRunner={queryRunner}>
<ScrollContainer height="100%" width="100%" scrollbarWidth="none" showScrollIndicators>
{isLoading ? (
<>
<GenericRowSkeleton key="skeleton-1" width={leftColumnWidth} depth={0} />
<GenericRowSkeleton key="skeleton-2" width={leftColumnWidth} depth={0} />
<GenericRowSkeleton key="skeleton-3" width={leftColumnWidth} depth={0} />
</>
) : (
dataSlice.map((row, index) => {
const rowKey = generateRowKey(row, index);
return renderWorkbenchRow(row, leftColumnWidth, domain, rowKey);
})
)}
{hasMore && <LoadMoreHelper handleLoad={() => setPageIndex((prevIndex) => prevIndex + 1)} />}
</ScrollContainer>
</WorkbenchProvider>
</div>
</div>
</div>
);
}
export const getStyles = (theme: GrafanaTheme2) => {
const summaryHeight = 200;
return {
groupsContainer: css({
position: 'absolute',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
}),
groupItemWrapper: (width: number) =>
css({
display: 'grid',
gridTemplateColumns: `${width}px auto`,
gap: theme.spacing(2),
}),
virtualizedContainer: css({
display: 'flex',
flex: 1,
overflow: 'hidden', // Let AutoSizer handle the overflow
}),
summaryContainer: css({
gridTemplateRows: summaryHeight,
marginBottom: theme.spacing(2),
}),
headerContainer: css({
top: summaryHeight,
}),
flexFull: css({
flex: 1,
}),
minColumnWidth: css({
minWidth: 300,
}),
};
};

View File

@ -0,0 +1,34 @@
import React, { createContext, useContext } from 'react';
import { SceneQueryRunner } from '@grafana/scenes';
import { Domain } from './types';
interface WorkbenchContextValue {
leftColumnWidth: number;
domain: Domain;
queryRunner: SceneQueryRunner;
}
const WorkbenchContext = createContext<WorkbenchContextValue | undefined>(undefined);
export function useWorkbenchContext(): WorkbenchContextValue {
const context = useContext(WorkbenchContext);
if (!context) {
throw new Error('useWorkbenchContext must be used within a WorkbenchProvider');
}
return context;
}
interface WorkbenchProviderProps {
leftColumnWidth: number;
domain: Domain;
queryRunner: SceneQueryRunner;
children: React.ReactNode;
}
export function WorkbenchProvider({ leftColumnWidth, domain, queryRunner, children }: WorkbenchProviderProps) {
return (
<WorkbenchContext.Provider value={{ leftColumnWidth, domain, queryRunner }}>{children}</WorkbenchContext.Provider>
);
}

View File

@ -0,0 +1,10 @@
import { config } from '@grafana/runtime';
export const VARIABLES = {
groupBy: 'groupBy',
filters: 'filters',
};
export const DATASOURCE_UID = config.unifiedAlerting.stateHistory?.prometheusTargetDatasourceUID;
export const METRIC_NAME = config.unifiedAlerting.stateHistory?.prometheusMetricName ?? 'GRAFANA_ALERTS';
export const DEFAULT_FIELDS = ['alertname', 'grafana_folder', 'grafana_rule_uid', 'alertstate'] as const;

View File

@ -0,0 +1,55 @@
import React from 'react';
import { Stack, Text, TextLink } from '@grafana/ui';
import { MetaText } from '../../components/MetaText';
import { WithReturnButton } from '../../components/WithReturnButton';
import { rulesNav } from '../../utils/navigation';
import { AlertRuleInstances } from '../scene/AlertRuleInstances';
import { AlertRuleSummary } from '../scene/AlertRuleSummary';
import { AlertRuleRow as AlertRuleRowType } from '../types';
import { GenericRow } from './GenericRow';
interface AlertRuleRowProps {
row: AlertRuleRowType;
leftColumnWidth: number;
rowKey: React.Key;
depth?: number;
}
export const AlertRuleRow = ({ row, leftColumnWidth, rowKey, depth = 0 }: AlertRuleRowProps) => {
return (
<GenericRow
key={rowKey}
width={leftColumnWidth}
title={
<WithReturnButton
component={
<TextLink
inline={false}
href={rulesNav.detailsPageLink('grafana', {
ruleSourceName: 'grafana',
uid: row.metadata.ruleUID,
})}
>
{row.metadata.title}
</TextLink>
}
/>
}
metadata={
<Stack direction="row" gap={0.5} alignItems="center">
<MetaText icon="folder" />
<Text variant="bodySmall" color="secondary">
{row.metadata.folder}
</Text>
</Stack>
}
content={<AlertRuleSummary ruleUID={row.metadata.ruleUID} />}
depth={depth}
>
<AlertRuleInstances ruleUID={row.metadata.ruleUID} depth={depth + 1} />
</GenericRow>
);
};

View File

@ -0,0 +1,47 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack, Text, useStyles2 } from '@grafana/ui';
import { MetaText } from '../../components/MetaText';
import { GenericGroupedRow } from '../types';
import { GenericRow } from './GenericRow';
interface FolderGroupRowProps {
row: GenericGroupedRow;
leftColumnWidth: number;
rowKey: React.Key;
depth?: number;
children?: React.ReactNode;
}
export const FolderGroupRow = ({ row, leftColumnWidth, rowKey, depth = 0, children }: FolderGroupRowProps) => {
const styles = useStyles2(getStyles);
return (
<GenericRow
key={rowKey}
width={leftColumnWidth}
title={
<Stack direction="row" gap={0.5} alignItems="center">
<MetaText icon="folder" />
<Text color="primary">{row.metadata.value}</Text>
</Stack>
}
isOpenByDefault={true}
leftColumnClassName={styles.folderGroupRow}
rightColumnClassName={styles.folderGroupRow}
depth={depth}
>
{children}
</GenericRow>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
folderGroupRow: css({
backgroundColor: theme.colors.background.secondary,
}),
});

View File

@ -0,0 +1,132 @@
import { css, cx } from '@emotion/css';
import { ReactNode } from 'react';
import { useToggle } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { IconButton, Stack, useStyles2 } from '@grafana/ui';
import { Spacer } from '../../components/Spacer';
interface GenericRowProps {
width: number;
title: ReactNode;
metadata?: ReactNode;
actions?: ReactNode;
content?: ReactNode;
isOpenByDefault?: boolean;
children?: ReactNode;
// allow overriding / adding styles for the row
leftColumnClassName?: string;
rightColumnClassName?: string;
depth?: number; // for indentation of nested rows
}
export const GenericRow = ({
width,
title,
metadata,
actions,
content,
isOpenByDefault = false,
children,
leftColumnClassName,
rightColumnClassName,
depth = 0,
}: GenericRowProps) => {
const styles = useStyles2(getStyles);
const [isOpen, handleToggle] = useToggle(isOpenByDefault);
const hasChildren = Boolean(children);
const showChildContent = isOpen && hasChildren;
return (
<>
<div className={styles.groupItemWrapper(width)}>
<div className={cx(styles.leftColumn, styles.column, leftColumnClassName)}>
<div className={styles.columnContent(depth)}>
<LeftCell
title={title}
metadata={metadata}
actions={actions}
isOpen={isOpen}
onToggle={hasChildren ? handleToggle : undefined}
/>
</div>
</div>
<div style={{ minWidth: 'min-content', flexGrow: 1 }} className={cx(styles.column, rightColumnClassName)}>
{content && <div className={styles.columnContent()}>{content}</div>}
</div>
</div>
{showChildContent ? children : null}
</>
);
};
interface LeftCellProps {
title: ReactNode;
metadata?: ReactNode;
actions?: ReactNode;
isOpen?: boolean;
onToggle?: () => void;
}
const LeftCell = ({ title, metadata = null, actions = null, isOpen = true, onToggle }: LeftCellProps) => {
const styles = useStyles2(getStyles);
return (
<Stack direction="row" alignItems="center" gap={0.5}>
{onToggle && (
<IconButton
name={isOpen ? 'angle-down' : 'angle-right'}
onClick={() => onToggle()}
className={styles.dropdownIcon}
variant="secondary"
size="md"
aria-label={t('alerting.group-wrapper.toggle', 'Toggle group')}
/>
)}
<Stack direction="column" alignItems="flex-start" gap={0} flex={1}>
<Stack direction="row" alignItems="center" gap={1} width="100%">
{title}
{actions && <Spacer />}
{actions}
</Stack>
{metadata}
</Stack>
</Stack>
);
};
export const getStyles = (theme: GrafanaTheme2) => {
return {
dropdownIcon: css({
alignSelf: 'flex-start',
marginTop: theme.spacing(0.5),
}),
column: css({
display: 'flex',
position: 'relative',
flexBasis: 0,
border: 'solid 1px transparent',
borderBottom: `1px solid ${theme.colors.border.medium}`,
borderLeft: `1px solid ${theme.colors.border.medium}`,
borderRight: `1px solid ${theme.colors.border.medium}`,
}),
leftColumn: css({
overflow: 'hidden',
}),
columnContent: (depth?: number) =>
css({
padding: 5,
width: '100%',
paddingLeft: depth ? `calc(${theme.spacing(depth)} + 5px)` : 5,
}),
groupItemWrapper: (width: number) =>
css({
display: 'grid',
gridTemplateColumns: `${width}px auto`,
gap: theme.spacing(2),
}),
};
};

View File

@ -0,0 +1,42 @@
import { css } from '@emotion/css';
import React from 'react';
import { AlertLabel } from '@grafana/alerting/unstable';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { GenericGroupedRow } from '../types';
import { GenericRow } from './GenericRow';
interface GroupRowProps {
row: GenericGroupedRow;
leftColumnWidth: number;
rowKey: React.Key;
depth?: number;
children?: React.ReactNode;
}
export const GroupRow = ({ row, leftColumnWidth, rowKey, depth = 0, children }: GroupRowProps) => {
const styles = useStyles2(getStyles);
return (
<GenericRow
key={rowKey}
width={leftColumnWidth}
title={<AlertLabel size="sm" labelKey={row.metadata.label} value={row.metadata.value} colorBy="key" />}
isOpenByDefault={true}
leftColumnClassName={styles.groupRow}
rightColumnClassName={styles.groupRow}
depth={depth}
>
{children}
</GenericRow>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
groupRow: css({
backgroundColor: theme.colors.background.secondary,
}),
});

View File

@ -0,0 +1,114 @@
import { css } from '@emotion/css';
import { isEmpty } from 'lodash';
import { useMemo } from 'react';
import { AlertLabels } from '@grafana/alerting/unstable';
import { DataFrame, GrafanaTheme2, Labels, LoadingState, TimeRange } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { SceneDataNode, VizConfigBuilders } from '@grafana/scenes';
import { VizPanel } from '@grafana/scenes-react';
import { GraphDrawStyle, VisibilityMode } from '@grafana/schema';
import {
AxisPlacement,
BarAlignment,
LegendDisplayMode,
StackingMode,
Text,
TooltipDisplayMode,
useStyles2,
} from '@grafana/ui';
import { overrideToFixedColor } from '../../home/Insights';
import { GenericRow } from './GenericRow';
interface Instance {
labels: Labels;
series: DataFrame[];
}
interface InstanceRowProps {
instance: Instance;
commonLabels: Labels;
leftColumnWidth: number;
timeRange: TimeRange;
depth?: number;
}
const chartConfig = VizConfigBuilders.timeseries()
.setCustomFieldConfig('drawStyle', GraphDrawStyle.Bars)
.setCustomFieldConfig('barWidthFactor', 1)
.setCustomFieldConfig('barAlignment', BarAlignment.After)
.setCustomFieldConfig('showPoints', VisibilityMode.Never)
.setCustomFieldConfig('fillOpacity', 60)
.setCustomFieldConfig('lineWidth', 0)
.setCustomFieldConfig('stacking', { mode: StackingMode.None })
.setCustomFieldConfig('axisPlacement', AxisPlacement.Hidden)
.setCustomFieldConfig('axisGridShow', false)
.setOption('tooltip', { mode: TooltipDisplayMode.Multi })
.setOption('legend', {
showLegend: false,
displayMode: LegendDisplayMode.Hidden,
})
.setMin(0)
.setMax(1)
.setOverrides((builder) =>
builder
.matchFieldsWithName('firing')
.overrideColor(overrideToFixedColor('firing'))
.matchFieldsWithName('pending')
.overrideColor(overrideToFixedColor('pending'))
)
.build();
export function InstanceRow({ instance, commonLabels, leftColumnWidth, timeRange, depth = 0 }: InstanceRowProps) {
const styles = useStyles2(getStyles);
const dataProvider = useMemo(
() =>
new SceneDataNode({
data: {
series: instance.series,
state: LoadingState.Done,
timeRange,
},
}),
[instance, timeRange]
);
return (
<GenericRow
width={leftColumnWidth}
title={
isEmpty(instance.labels) ? (
<div className={styles.wrapper}>
<Text color="secondary" variant="bodySmall">
<Trans i18nKey="alerting.triage.no-labels">No labels</Trans>
</Text>
</div>
) : (
<AlertLabels
labels={instance.labels}
displayCommonLabels={true}
labelSets={[instance.labels, commonLabels]}
size="xs"
/>
)
}
content={
<VizPanel title="" hoverHeader={true} viz={chartConfig} dataProvider={dataProvider} displayMode="transparent" />
}
depth={depth}
/>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
wrapper: css({
minHeight: theme.spacing(2.5),
display: 'flex',
alignItems: 'center',
}),
};
};

View File

@ -0,0 +1,13 @@
import { WorkbenchRow } from '../types';
// Generate unique keys for WorkbenchRow items
export function generateRowKey(row: WorkbenchRow, fallbackIndex: number): string {
if (row.type === 'alertRule') {
// Use ruleUID as primary key for AlertRuleRow
return `alert-${row.metadata.ruleUID}`;
} else {
// For GenericGroupedRow, create key from label and value
const groupedRow = row;
return `group-${groupedRow.metadata.label}-${groupedRow.metadata.value}`;
}
}

View File

@ -0,0 +1,113 @@
import { omit } from 'lodash';
import { useMemo } from 'react';
import Skeleton from 'react-loading-skeleton';
import { DataFrame, Labels, findCommonLabels } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { useQueryRunner, useTimeRange } from '@grafana/scenes-react';
import { Box } from '@grafana/ui';
import { useWorkbenchContext } from '../WorkbenchContext';
import { METRIC_NAME } from '../constants';
import { GenericRow } from '../rows/GenericRow';
import { InstanceRow } from '../rows/InstanceRow';
import { getDataQuery } from './utils';
function extractInstancesFromData(series: DataFrame[] | undefined) {
if (!series) {
return [];
}
// 1. Group series by labels, ignoring alertstate
const groups = new Map<string, { labels: Labels; series: DataFrame[] }>();
series.forEach((series) => {
const valueField = series.fields.find((f) => f.type !== 'time');
if (!valueField) {
return;
}
const keyLabels = omit(valueField.labels ?? {}, 'alertstate');
const key = JSON.stringify(keyLabels);
if (!groups.has(key)) {
groups.set(key, { labels: keyLabels, series: [] });
}
groups.get(key)!.series.push(series);
});
return Array.from(groups.values());
}
type AlertRuleInstancesProps = {
ruleUID: string;
depth?: number;
};
export function AlertRuleInstances({ ruleUID, depth = 0 }: AlertRuleInstancesProps) {
const { leftColumnWidth } = useWorkbenchContext();
const [timeRange] = useTimeRange();
const query = getDataQuery(
`count without (alertname, grafana_alertstate, grafana_folder, grafana_rule_uid) (${METRIC_NAME}{grafana_rule_uid="${ruleUID}"})`,
{ format: 'timeseries', legendFormat: '{{alertstate}}' }
);
const queryRunner = useQueryRunner({ queries: [query] });
const isLoading = !queryRunner.isDataReadyToDisplay();
const { data } = queryRunner.useState();
const instances = useMemo(() => extractInstancesFromData(data?.series), [data]);
if (isLoading) {
return <GenericRowSkeleton width={leftColumnWidth} depth={depth} />;
}
if (!instances.length && !isLoading) {
return (
<GenericRow
width={leftColumnWidth}
title={<Trans i18nKey="alerting.triage.alert-instances">Alert instances</Trans>}
depth={depth}
>
<div>
<Trans i18nKey="alerting.triage.no-instances-found">No alert instances found for rule: {ruleUID}</Trans>
</div>
</GenericRow>
);
}
const allSeriesLabels: Labels[] = instances.map((instance) => instance.labels);
const commonLabels = allSeriesLabels.length === 1 ? {} : findCommonLabels(allSeriesLabels);
return (
<>
{instances.map((instance) => (
<InstanceRow
key={JSON.stringify(instance.labels)}
instance={instance}
commonLabels={commonLabels}
leftColumnWidth={leftColumnWidth}
timeRange={timeRange}
depth={depth}
/>
))}
</>
);
}
export function GenericRowSkeleton({ width, depth }: { width: number; depth: number }) {
return (
<GenericRow
width={width}
title={
<Box flex={1}>
<Skeleton width="100%" />
</Box>
}
depth={depth}
content={<Skeleton width="100%" />}
/>
);
}

View File

@ -0,0 +1,93 @@
import { VizConfigBuilders } from '@grafana/scenes';
import { VizPanel, useDataTransformer } from '@grafana/scenes-react';
import {
AxisPlacement,
BarAlignment,
GraphDrawStyle,
LegendDisplayMode,
StackingMode,
TooltipDisplayMode,
VisibilityMode,
} from '@grafana/schema';
import { overrideToFixedColor } from '../../home/Insights';
import { useWorkbenchContext } from '../WorkbenchContext';
/**
* Viz config for the alert rule summary chart - used by the React component
*/
export const alertRuleSummaryVizConfig = VizConfigBuilders.timeseries()
.setCustomFieldConfig('drawStyle', GraphDrawStyle.Bars)
.setCustomFieldConfig('barWidthFactor', 1)
.setCustomFieldConfig('barAlignment', BarAlignment.After)
.setCustomFieldConfig('showPoints', VisibilityMode.Never)
.setCustomFieldConfig('fillOpacity', 60)
.setCustomFieldConfig('lineWidth', 0)
.setCustomFieldConfig('stacking', { mode: StackingMode.None })
.setCustomFieldConfig('axisPlacement', AxisPlacement.Hidden)
.setCustomFieldConfig('axisGridShow', false)
.setMin(0)
.setOption('tooltip', { mode: TooltipDisplayMode.Multi })
.setOption('legend', {
showLegend: false,
displayMode: LegendDisplayMode.Hidden,
})
.setOverrides((builder) =>
builder
.matchFieldsWithName('firing')
.overrideColor(overrideToFixedColor('firing'))
.matchFieldsWithName('pending')
.overrideColor(overrideToFixedColor('pending'))
)
.build();
export function AlertRuleSummary({ ruleUID }: { ruleUID: string }) {
// Use WorkbenchContext to access the parent query runner and reuse its data
const { queryRunner } = useWorkbenchContext();
// Transform parent data to filter by this specific rule and partition by alert state
const transformedData = useDataTransformer({
data: queryRunner,
transformations: [
{
id: 'filterByValue',
options: {
filters: [
{
config: {
id: 'equal',
options: {
value: ruleUID,
},
},
fieldName: 'grafana_rule_uid',
},
],
match: 'any',
type: 'include',
},
},
{
id: 'partitionByValues',
options: {
fields: ['alertstate'],
keepFields: false,
naming: {
asLabels: true,
},
},
},
],
});
return (
<VizPanel
title=""
viz={alertRuleSummaryVizConfig}
dataProvider={transformedData}
hoverHeader={true}
displayMode="transparent"
collapsible={false}
/>
);
}

View File

@ -0,0 +1,54 @@
import { SceneObjectBase, SceneObjectState, VizConfigBuilders } from '@grafana/scenes';
import { VizPanel, useQueryRunner } from '@grafana/scenes-react';
import { BarAlignment, GraphDrawStyle, VisibilityMode } from '@grafana/schema';
import { LegendDisplayMode, StackingMode, TooltipDisplayMode } from '@grafana/ui';
import { overrideToFixedColor } from '../../home/Insights';
import { METRIC_NAME } from '../constants';
import { getDataQuery, useQueryFilter } from './utils';
/**
* Viz config for the summary chart - used by the React component
*/
export const summaryChartVizConfig = VizConfigBuilders.timeseries()
.setCustomFieldConfig('drawStyle', GraphDrawStyle.Bars)
.setCustomFieldConfig('barWidthFactor', 1)
.setCustomFieldConfig('barAlignment', BarAlignment.Center)
.setCustomFieldConfig('fillOpacity', 60)
.setCustomFieldConfig('lineWidth', 0)
.setCustomFieldConfig('stacking', { mode: StackingMode.None })
.setCustomFieldConfig('showPoints', VisibilityMode.Never)
.setOption('legend', {
showLegend: false,
displayMode: LegendDisplayMode.Hidden,
})
.setOption('tooltip', { mode: TooltipDisplayMode.Multi })
.setMin(0)
.setOverrides((builder) =>
builder
.matchFieldsWithName('firing')
.overrideColor(overrideToFixedColor('firing'))
.matchFieldsWithName('pending')
.overrideColor(overrideToFixedColor('pending'))
)
.build();
export function SummaryChartReact() {
const filter = useQueryFilter();
const dataProvider = useQueryRunner({
queries: [
getDataQuery(`count by (alertstate) (${METRIC_NAME}{${filter}})`, {
legendFormat: '{{alertstate}}', // we need this so we can map states to the correct color in the vizConfig
}),
],
});
return <VizPanel title="" viz={summaryChartVizConfig} dataProvider={dataProvider} hoverHeader={true} />;
}
// simple wrapper so we can render the Chart using a Scene parent
export class SummaryChartScene extends SceneObjectBase<SceneObjectState> {
static Component = SummaryChartReact;
}

View File

@ -0,0 +1,65 @@
import { DataFrameView } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { SceneObjectBase, SceneObjectState } from '@grafana/scenes';
import { useQueryRunner } from '@grafana/scenes-react';
import { Stack, Text } from '@grafana/ui';
import { Spacer } from '../../components/Spacer';
import { METRIC_NAME } from '../constants';
import { getDataQuery, useQueryFilter } from './utils';
interface Frame {
alertstate: 'firing' | 'pending';
Value: number;
}
export function SummaryStatsReact() {
const filter = useQueryFilter();
const dataProvider = useQueryRunner({
queries: [
getDataQuery(`count by (alertstate) (${METRIC_NAME}{${filter}})`, {
instant: true,
exemplar: false,
format: 'table',
}),
],
});
const isLoading = !dataProvider.isDataReadyToDisplay;
const data = dataProvider.useState().data;
const firstFrame = data?.series?.at(0);
if (isLoading || !firstFrame) {
return null;
}
const dfv = new DataFrameView<Frame>(firstFrame);
if (dfv.length === 0) {
return null;
}
const firingIndex = dfv.fields.alertstate.values.findIndex((state) => state === 'firing');
const firingCount = dfv.fields.Value.values[firingIndex] ?? 0;
const pendingIndex = dfv.fields.alertstate.values.findIndex((state) => state === 'pending');
const pendingCount = dfv.fields.Value.values[pendingIndex] ?? 0;
return (
<Stack direction="column" alignItems="flex-end" gap={0}>
<Spacer />
<Text color="error">
<Trans i18nKey="alerting.triage.firing-instances-count">{{ firingCount }} firing instances</Trans>
</Text>
<Text color="warning">
<Trans i18nKey="alerting.triage.pending-instances-count">{{ pendingCount }} pending instances</Trans>
</Text>
</Stack>
);
}
// simple wrapper so we can render the Chart using a Scene parent
export class SummaryStatsScene extends SceneObjectBase<SceneObjectState> {
static Component = SummaryStatsReact;
}

View File

@ -0,0 +1,69 @@
import { DashboardCursorSync } from '@grafana/data';
import {
AdHocFiltersVariable,
GroupByVariable,
SceneControlsSpacer,
SceneFlexLayout,
SceneRefreshPicker,
SceneTimePicker,
SceneTimeRange,
SceneVariableSet,
VariableValueSelectors,
behaviors,
} from '@grafana/scenes';
import { EmbeddedSceneWithContext } from '@grafana/scenes-react';
import { DATASOURCE_UID } from '../constants';
import { WorkbenchSceneObject } from './Workbench';
import { defaultTimeRange } from './utils';
const cursorSync = new behaviors.CursorSync({ key: 'triage-cursor-sync', sync: DashboardCursorSync.Crosshair });
export const triageScene = new EmbeddedSceneWithContext({
// this will allow us to share the cursor between all vizualizations
$behaviors: [cursorSync],
controls: [
new VariableValueSelectors({}),
new SceneControlsSpacer(),
new SceneTimePicker({}),
new SceneRefreshPicker({}),
],
$timeRange: new SceneTimeRange(defaultTimeRange),
$variables: new SceneVariableSet({
variables: [
new GroupByVariable({
name: 'groupBy',
label: 'Group by',
datasource: {
type: 'prometheus',
uid: DATASOURCE_UID,
},
allowCustomValue: true,
applyMode: 'manual',
value: 'grafana_folder',
}),
new AdHocFiltersVariable({
name: 'filters',
label: 'Filters',
datasource: {
type: 'prometheus',
uid: DATASOURCE_UID,
},
applyMode: 'manual', // we will construct the label matchers for the PromQL queries ourselves
allowCustomValue: true,
useQueriesAsFilterForOptions: true,
supportsMultiValueOperators: true,
filters: [],
baseFilters: [],
layout: 'combobox',
}),
],
}),
body: new SceneFlexLayout({
direction: 'column',
children: [new WorkbenchSceneObject({})],
}),
});
export const TriageScene = () => <triageScene.Component model={triageScene} />;

View File

@ -0,0 +1,133 @@
import { ArrayValues } from 'type-fest';
import { DataFrame, PanelData } from '@grafana/data';
import { SceneObjectBase, SceneObjectState } from '@grafana/scenes';
import { useQueryRunner, useTimeRange, useVariableValues } from '@grafana/scenes-react';
import { Workbench } from '../Workbench';
import { DEFAULT_FIELDS, METRIC_NAME, VARIABLES } from '../constants';
import { AlertRuleRow, GenericGroupedRow, WorkbenchRow } from '../types';
import { convertTimeRangeToDomain, getDataQuery, useQueryFilter } from './utils';
export class WorkbenchSceneObject extends SceneObjectBase<SceneObjectState> {
public static Component = WorkbenchRenderer;
}
export function WorkbenchRenderer() {
const [timeRange] = useTimeRange();
const domain = convertTimeRangeToDomain(timeRange);
const [groupByKeys = []] = useVariableValues<string>(VARIABLES.groupBy);
const countBy = [...DEFAULT_FIELDS, ...groupByKeys].join(',');
const queryFilter = useQueryFilter();
const runner = useQueryRunner({
queries: [
getDataQuery(`count by (${countBy}) (${METRIC_NAME}{${queryFilter}})`, {
format: 'table',
}),
],
});
const { data } = runner.useState();
const rows = data ? convertToWorkbenchRows(data, groupByKeys) : [];
return <Workbench data={rows} domain={domain} queryRunner={runner} />;
}
type DataPoint = Record<ArrayValues<typeof DEFAULT_FIELDS>, string> & Record<string, string | undefined>;
function createAlertRuleRows(dataPoints: DataPoint[]): AlertRuleRow[] {
const rules = new Map<
string,
{
alertname: string;
folder: string;
ruleUID: string;
}
>();
for (const dp of dataPoints) {
const ruleUID = dp.grafana_rule_uid;
if (!rules.has(ruleUID)) {
rules.set(ruleUID, {
alertname: dp.alertname,
folder: dp.grafana_folder,
ruleUID: ruleUID,
});
}
}
const result: AlertRuleRow[] = [];
for (const rule of rules.values()) {
result.push({
type: 'alertRule',
metadata: {
title: rule.alertname,
folder: rule.folder,
ruleUID: rule.ruleUID,
},
});
}
return result;
}
function groupData(dataPoints: DataPoint[], groupBy: string[], depth: number): WorkbenchRow[] {
if (depth >= groupBy.length) {
return createAlertRuleRows(dataPoints);
}
const groupByKey = groupBy[depth];
const grouped = new Map<string, DataPoint[]>();
for (const dp of dataPoints) {
const key = String(dp[groupByKey] ?? 'undefined');
if (!grouped.has(key)) {
grouped.set(key, []);
}
grouped.get(key)?.push(dp);
}
const result: GenericGroupedRow[] = [];
for (const [value, rows] of grouped.entries()) {
result.push({
type: 'group',
metadata: {
label: groupByKey,
value: value,
},
rows: groupData(rows, groupBy, depth + 1),
});
}
return result;
}
// @TODO narrower types for PanelData! (if possible)
export function convertToWorkbenchRows(data: PanelData, groupBy: string[] = []): WorkbenchRow[] {
if (!data.series.at(0)?.fields.length) {
return [];
}
const frame = data.series[0];
if (!isValidFrame(frame)) {
return [];
}
const allDataPoints = Array.from({ length: frame.length }, (_, i) => {
const dataPoint: DataPoint = Object.create(null);
frame.fields.forEach((field) => {
dataPoint[field.name] = field.values[i];
});
return dataPoint;
});
return groupData(allDataPoints, groupBy, 0);
}
function isValidFrame(frame: DataFrame) {
const requiredFieldNames = ['Time', ...DEFAULT_FIELDS];
const fieldNames = new Set(frame.fields.map((f) => f.name));
return requiredFieldNames.every((name) => fieldNames.has(name));
}

View File

@ -0,0 +1,54 @@
import { TimeRange } from '@grafana/data';
import { SceneDataQuery } from '@grafana/scenes';
import { useVariableValue, useVariableValues } from '@grafana/scenes-react';
import { DataSourceRef } from '@grafana/schema';
import { DATASOURCE_UID, VARIABLES } from '../constants';
import { Domain } from '../types';
export function getDataQuery(expression: string, options?: Partial<SceneDataQuery>): SceneDataQuery {
const datasourceRef: DataSourceRef = {
type: 'prometheus',
uid: DATASOURCE_UID,
};
const query: SceneDataQuery = {
refId: 'query',
expr: expression,
instant: false,
datasource: datasourceRef,
...options,
};
return query;
}
/**
* Turns an array of "groupBy" keys into a Prometheus matcher such as key!="",key2!="" .
* This way we can show only instances that have a label that was grouped on.
*/
export function stringifyGroupFilter(groupBy: string[]) {
return groupBy.map((key) => `${key}!=""`).join(',');
}
export const defaultTimeRange = {
from: 'now-4h',
to: 'now',
} as const;
export function convertTimeRangeToDomain(timeRange: TimeRange): Domain {
return [timeRange.from.toDate(), timeRange.to.toDate()];
}
/**
* This hook will create a Prometheus label matcher string from the "groupBy" and "filters" variables
*/
export function useQueryFilter(): string {
const [groupBy = []] = useVariableValues<string>(VARIABLES.groupBy);
const [filters = ''] = useVariableValue<string>(VARIABLES.filters);
const groupByFilter = stringifyGroupFilter(groupBy);
const queryFilter = [groupByFilter, filters].filter((s) => Boolean(s)).join(',');
return queryFilter;
}

View File

@ -0,0 +1,24 @@
export type Domain = [Date, Date];
export type Filter = [key: string, operator: '=' | '=!', value: string];
export type WorkbenchRow = GenericGroupedRow | AlertRuleRow;
export type TimelineEntry = [timestamp: number, state: 'firing' | 'pending'];
export interface AlertRuleRow {
type: 'alertRule';
metadata: {
title: string;
folder: string;
ruleUID: string;
};
}
export interface GenericGroupedRow {
type: 'group';
metadata: {
label: string;
value: string;
};
rows: WorkbenchRow[];
}

View File

@ -15,7 +15,7 @@ import { isDashboardV2Resource, isV1DashboardCommand, isV2DashboardCommand } fro
import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types';
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
import { dispatch } from 'app/store/store';
import { PermissionLevelString } from 'app/types/acl';
import { PermissionLevel } from 'app/types/acl';
import { SaveDashboardResponseDTO, ImportDashboardResponseDTO } from 'app/types/dashboard';
import { FolderListItemDTO, FolderDTO, DescendantCount, DescendantCountDTO } from 'app/types/folders';
@ -71,7 +71,7 @@ export interface ListFolderQueryArgs {
page: number;
parentUid: string | undefined;
limit: number;
permission?: PermissionLevelString;
permission?: PermissionLevel;
}
export const browseDashboardsAPI = createApi({

View File

@ -5,7 +5,7 @@ import { useScopesServices } from 'app/features/scopes/ScopesContextProvider';
import { CommandPaletteAction } from '../types';
import { RECENT_SCOPES_PRIORITY } from '../values';
export function getRecentScopesActions(): CommandPaletteAction[] {
export function useRecentScopesActions(): CommandPaletteAction[] {
const services = useScopesServices();
if (!(config.featureToggles.scopeFilters && services)) {

View File

@ -7,7 +7,7 @@ import { config } from '@grafana/runtime';
import { ScopesRow } from '../ScopesRow';
import { CommandPaletteAction } from '../types';
import { getRecentScopesActions } from './recentScopesActions';
import { useRecentScopesActions } from './recentScopesActions';
import {
getScopesParentAction,
mapScopeNodeToAction,
@ -16,7 +16,7 @@ import {
} from './scopesUtils';
export function useRegisterRecentScopesActions() {
const recentScopesActions = getRecentScopesActions();
const recentScopesActions = useRecentScopesActions();
useRegisterActions(recentScopesActions, [recentScopesActions]);
}

View File

@ -30,6 +30,7 @@ import {
serializeStateToUrlParam,
urlUtil,
LogLevel,
shallowCompare,
} from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { config, reportInteraction } from '@grafana/runtime';
@ -53,7 +54,7 @@ import { InfiniteScroll } from 'app/features/logs/components/InfiniteScroll';
import { LogRows } from 'app/features/logs/components/LogRows';
import { LogRowContextModal } from 'app/features/logs/components/log-context/LogRowContextModal';
import { LogLineContext } from 'app/features/logs/components/panel/LogLineContext';
import { LogList, LogListControlOptions } from 'app/features/logs/components/panel/LogList';
import { LogList, LogListOptions } from 'app/features/logs/components/panel/LogList';
import { isDedupStrategy, isLogsSortOrder } from 'app/features/logs/components/panel/LogListContext';
import { LogLevelColor, dedupLogRows } from 'app/features/logs/logsModel';
import { getLogLevelFromKey, getLogLevelInfo } from 'app/features/logs/utils';
@ -205,7 +206,8 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
store.get(SETTINGS_KEYS.logsSortOrder) || LogsSortOrder.Descending
);
const [isFlipping, setIsFlipping] = useState<boolean>(false);
const [displayedFields, setDisplayedFields] = useState<string[]>([]);
const [displayedFields, setDisplayedFields] = useState<string[]>(panelState?.logs?.displayedFields ?? []);
const [defaultDisplayedFields, setDefaultDisplayedFields] = useState<string[]>([]);
const [contextOpen, setContextOpen] = useState<boolean>(false);
const [contextRow, setContextRow] = useState<LogRowModel | undefined>(undefined);
const [pinLineButtonTooltipTitle, setPinLineButtonTooltipTitle] = useState<PopoverContent>(PINNED_LOGS_MESSAGE);
@ -280,16 +282,6 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
store.set(visualisationTypeKey, visualisationType);
}, [panelState?.logs?.visualisationType]);
useEffect(() => {
let displayedFields: string[] = [];
if (Array.isArray(panelState?.logs?.displayedFields)) {
displayedFields = panelState?.logs?.displayedFields;
} else if (panelState?.logs?.displayedFields && typeof panelState?.logs?.displayedFields === 'object') {
displayedFields = Object.values(panelState?.logs?.displayedFields);
}
setDisplayedFields(displayedFields);
}, [panelState?.logs?.displayedFields]);
useUnmount(() => {
if (flipOrderTimer) {
window.clearTimeout(flipOrderTimer.current);
@ -346,6 +338,15 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
]
);
useEffect(() => {
if (!shallowCompare(displayedFields, panelState?.logs?.displayedFields ?? [])) {
updatePanelState({
...panelState?.logs,
displayedFields,
});
}
}, [displayedFields, panelState?.logs, updatePanelState]);
// actions
const onLogRowHover = useCallback(
(row?: LogRowModel) => {
@ -544,13 +545,9 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
if (index === -1) {
const updatedDisplayedFields = displayedFields.concat(key);
setDisplayedFields(updatedDisplayedFields);
updatePanelState({
...panelState?.logs,
displayedFields: updatedDisplayedFields,
});
}
},
[displayedFields, panelState?.logs, updatePanelState]
[displayedFields]
);
const hideField = useCallback(
@ -559,22 +556,14 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
if (index > -1) {
const updatedDisplayedFields = displayedFields.filter((k) => key !== k);
setDisplayedFields(updatedDisplayedFields);
updatePanelState({
...panelState?.logs,
displayedFields: updatedDisplayedFields,
});
}
},
[displayedFields, panelState?.logs, updatePanelState]
[displayedFields]
);
const clearDetectedFields = useCallback(() => {
updatePanelState({
...panelState?.logs,
displayedFields: [],
});
const clearDisplayedFields = useCallback(() => {
setDisplayedFields([]);
}, [panelState?.logs, updatePanelState]);
}, []);
const onCloseCallbackRef = useRef<() => void>(() => {});
@ -703,7 +692,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
const visibilityChangedRef = useRef(true);
const onLogOptionsChange = useCallback(
(option: LogListControlOptions, value: string | string[] | boolean) => {
(option: LogListOptions, value: string | string[] | boolean) => {
if (option === 'sortOrder' && isLogsSortOrder(value)) {
sortOrderChanged(value);
} else if (option === 'dedupStrategy' && isDedupStrategy(value)) {
@ -757,6 +746,8 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
return newLevels;
});
} else if (option === 'defaultDisplayedFields' && Array.isArray(value)) {
setDefaultDisplayedFields(value);
}
},
[logsVolumeData?.data, logsVolumeEnabled, sortOrderChanged]
@ -985,7 +976,8 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
dedupStrategy={dedupStrategy}
dedupCount={dedupCount}
displayedFields={displayedFields}
clearDetectedFields={clearDetectedFields}
clearDisplayedFields={clearDisplayedFields}
defaultDisplayedFields={defaultDisplayedFields}
/>
</div>
<div className={cx(styles.logsSection, visualisationType === 'table' ? styles.logsTable : undefined)}>

View File

@ -26,7 +26,8 @@ const defaultProps: LogsMetaRowProps = {
dedupCount: 0,
displayedFields: [],
logRows: [],
clearDetectedFields: jest.fn(),
clearDisplayedFields: jest.fn(),
defaultDisplayedFields: [],
};
const setup = (propOverrides?: object, disableDownload = false) => {
@ -61,7 +62,7 @@ describe('LogsMetaRow', () => {
it('renders a button to clear displayedfields', () => {
const clearSpy = jest.fn();
setup({ displayedFields: ['testField1234'], clearDetectedFields: clearSpy });
setup({ displayedFields: ['testField1234'], clearDisplayedFields: clearSpy });
fireEvent(
screen.getByRole('button', {
name: 'Show original line',

View File

@ -1,7 +1,16 @@
import { css } from '@emotion/css';
import { memo } from 'react';
import { LogsDedupStrategy, LogsMetaItem, LogsMetaKind, LogRowModel, CoreApp, Labels, store } from '@grafana/data';
import {
LogsDedupStrategy,
LogsMetaItem,
LogsMetaKind,
LogRowModel,
CoreApp,
Labels,
store,
shallowCompare,
} from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { config, reportInteraction } from '@grafana/runtime';
import { Button, Dropdown, Menu, ToolbarButton, useStyles2 } from '@grafana/ui';
@ -30,11 +39,20 @@ export type Props = {
dedupCount: number;
displayedFields: string[];
logRows: LogRowModel[];
clearDetectedFields: () => void;
clearDisplayedFields: () => void;
defaultDisplayedFields: string[];
};
export const LogsMetaRow = memo(
({ meta, dedupStrategy, dedupCount, displayedFields, clearDetectedFields, logRows }: Props) => {
({
meta,
dedupStrategy,
dedupCount,
displayedFields,
clearDisplayedFields,
logRows,
defaultDisplayedFields,
}: Props) => {
const style = useStyles2(getStyles);
const logsMetaItem: Array<LogsMetaItem | MetaItemProps> = [...meta];
@ -49,7 +67,7 @@ export const LogsMetaRow = memo(
}
// Add detected fields info
if (displayedFields?.length > 0) {
if (displayedFields?.length > 0 && shallowCompare(displayedFields, defaultDisplayedFields) === false) {
logsMetaItem.push(
{
label: t('explore.logs-meta-row.label.showing-only-selected-fields', 'Showing only selected fields'),
@ -58,8 +76,8 @@ export const LogsMetaRow = memo(
{
label: '',
value: (
<Button variant="primary" fill="outline" size="sm" onClick={clearDetectedFields}>
<Trans i18nKey="explore.logs-meta-row.show-original-line">Show original line</Trans>
<Button variant="primary" fill="outline" size="sm" onClick={clearDisplayedFields}>
{t('explore.logs-meta-row.show-original-line', 'Show original line')}
</Button>
),
}

View File

@ -1,5 +1,6 @@
import { t } from '@grafana/i18n';
import { useTheme2 } from '@grafana/ui';
import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME } from 'app/features/logs/components/otel/formats';
import { getLogsFieldsStyles } from './LogsTableActiveFields';
import { LogsTableEmptyFields } from './LogsTableEmptyFields';
@ -36,7 +37,9 @@ export const LogsTableAvailableFields = (props: {
const theme = useTheme2();
const styles = getLogsFieldsStyles(theme);
const labelKeys = Object.keys(labels).filter((labelName) => valueFilter(labelName));
const labelKeys = Object.keys(labels)
.filter((labelName) => labelName !== OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME)
.filter((labelName) => valueFilter(labelName));
if (labelKeys.length) {
// Otherwise show list with a hardcoded order
return (

View File

@ -20,7 +20,7 @@ import { LogsVisualisationType } from '../../explore/Logs/Logs';
import { ControlledLogsTable } from './ControlledLogsTable';
import { InfiniteScroll } from './InfiniteScroll';
import { LogRows, Props } from './LogRows';
import { LogListControlOptions } from './panel/LogList';
import { LogListOptions } from './panel/LogList';
import { LogListContextProvider, useLogListContext } from './panel/LogListContext';
import { LogListControls } from './panel/LogListControls';
import { ScrollToLogsEvent } from './panel/virtualization';
@ -30,7 +30,7 @@ export interface ControlledLogRowsProps extends Omit<Props, 'scrollElement'> {
logsMeta?: LogsMetaItem[];
loadMoreLogs?: (range: AbsoluteTimeRange) => void;
logOptionsStorageKey?: string;
onLogOptionsChange?: (option: LogListControlOptions, value: string | boolean | string[]) => void;
onLogOptionsChange?: (option: LogListOptions, value: string | boolean | string[]) => void;
range: TimeRange;
filterLevels?: LogLevel[];

View File

@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
import { LOG_LINE_BODY_FIELD_NAME } from './LogDetailsBody';
import { LogLabels, LogLabelsList } from './LogLabels';
import { getNormalizedFieldName } from './panel/processing';
describe('<LogLabels />', () => {
it('renders notice when no labels are found', () => {
@ -96,6 +97,6 @@ describe('<LogLabelsList />', () => {
render(<LogLabelsList labels={['bar', '42', LOG_LINE_BODY_FIELD_NAME]} />);
expect(screen.queryByText('bar')).toBeInTheDocument();
expect(screen.queryByText('42')).toBeInTheDocument();
expect(screen.queryByText('log line')).toBeInTheDocument();
expect(screen.queryByText(getNormalizedFieldName(LOG_LINE_BODY_FIELD_NAME))).toBeInTheDocument();
});
});

View File

@ -5,7 +5,7 @@ import { GrafanaTheme2, Labels } from '@grafana/data';
import { t } from '@grafana/i18n';
import { Button, Icon, Tooltip, useStyles2 } from '@grafana/ui';
import { LOG_LINE_BODY_FIELD_NAME } from './LogDetailsBody';
import { getNormalizedFieldName } from './panel/processing';
// Levels are already encoded in color, filename is a Loki-ism
const HIDDEN_LABELS = ['detected_level', 'level', 'lvl', 'filename'];
@ -111,7 +111,7 @@ export const LogLabelsList = memo(({ labels }: LogLabelsArrayProps) => {
<span className={styles.logsLabels}>
{labels.map((label) => (
<LogLabel key={label} styles={styles} tooltip={label}>
{label === LOG_LINE_BODY_FIELD_NAME ? t('logs.log-labels-list.log-line', 'log line') : label}
{getNormalizedFieldName(label)}
</LogLabel>
))}
</span>

View File

@ -1,7 +1,12 @@
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { createLogLine } from '../mocks/logRow';
import { getDisplayedFieldsForLogs, getOtelFormattedBody, OTEL_PROBE_FIELD } from './formats';
import {
getDisplayedFieldsForLogs,
getOtelAttributesField,
OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME,
OTEL_PROBE_FIELD,
} from './formats';
describe('getDisplayedFieldsForLogs', () => {
test('Does not return displayed fields if not an OTel log line', () => {
@ -18,43 +23,97 @@ describe('getDisplayedFieldsForLogs', () => {
test('Returns displayed fields if the OTel probe field is present', () => {
const log = createLogLine({
labels: { [OTEL_PROBE_FIELD]: '1', telemetry_sdk_language: 'php', scope_name: 'scope' },
labels: { [OTEL_PROBE_FIELD]: '1', telemetry_sdk_language: 'php', thread_name: 'John' },
entry: `place="luna" 1ms 3 KB`,
});
expect(getDisplayedFieldsForLogs([log])).toEqual(['scope_name', LOG_LINE_BODY_FIELD_NAME]);
expect(getDisplayedFieldsForLogs([log])).toEqual([
'thread_name',
LOG_LINE_BODY_FIELD_NAME,
OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME,
]);
expect(log.otelLanguage).toBe('php');
});
test('Returns displayed fields if the OTel probe field is present and the language unknown', () => {
const log = createLogLine({
labels: { [OTEL_PROBE_FIELD]: '1', scope_name: 'scope' },
labels: { [OTEL_PROBE_FIELD]: '1', exception_type: 'fatal', exception_message: 'message' },
entry: `place="luna" 1ms 3 KB`,
});
expect(getDisplayedFieldsForLogs([log])).toEqual(['scope_name', LOG_LINE_BODY_FIELD_NAME]);
expect(getDisplayedFieldsForLogs([log])).toEqual([
'exception_type',
'exception_message',
LOG_LINE_BODY_FIELD_NAME,
OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME,
]);
expect(log.otelLanguage).toBe('unknown');
});
test('Returns the minimal displayed fields if others are not present', () => {
const log = createLogLine({
labels: { [OTEL_PROBE_FIELD]: '1' },
entry: `place="luna" 1ms 3 KB`,
});
expect(getDisplayedFieldsForLogs([log])).toEqual([LOG_LINE_BODY_FIELD_NAME, OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME]);
});
});
describe('getOtelFormattedBody', () => {
test('Does not modify non OTel logs', () => {
const log = createLogLine({ labels: { place: 'luna' }, entry: `place="luna" 1ms 3 KB` });
expect(getOtelFormattedBody(log)).toEqual(`place="luna" 1ms 3 KB`);
});
test('Returns an OTel augmented log line body', () => {
describe('getOtelAttributesField', () => {
test('Builds the OTel attributes fields from the log line fields including and excluding fields', () => {
const log = createLogLine({
labels: {
severity_number: '1',
telemetry_sdk_language: 'php',
scope_name: 'scope',
aws_ignore: 'ignored',
key: 'value',
otel: 'otel',
aws_something: 'nope',
k8s_something: 'nope',
cluster: 'nope',
namespace: 'nope',
pod: 'nope',
vcs_ref_head_name: 'main',
field: 'value',
},
entry: `place="luna" 1ms 3 KB`,
});
expect(getOtelFormattedBody(log)).toEqual(`place="luna" 1ms 3 KB key=value otel=otel`);
expect(getOtelAttributesField(log, true)).toEqual('vcs_ref_head_name=main field=value');
});
test('Correctly matches excluded labels', () => {
const log = createLogLine({
labels: {
aws_something: 'nope',
k8s_something: 'nope',
cluster: 'nope',
namespace: 'nope',
pod: 'nope',
cluster_1: 'yes',
namespace_2: 'yes',
pod_3: 'yes',
vcs_ref_head_name: 'main',
field: 'value',
},
entry: `place="luna" 1ms 3 KB`,
});
expect(getOtelAttributesField(log, true)).toEqual(
'cluster_1=yes namespace_2=yes pod_3=yes vcs_ref_head_name=main field=value'
);
});
test('Removes new lines when wrapping is disabled', () => {
const log = createLogLine({
labels: {
aws_something: 'nope',
k8s_something: 'nope',
cluster: 'nope',
namespace: 'nope',
pod: 'nope',
vcs_ref_head_name: 'ma\nin',
field: 'val\nue',
},
entry: `place="luna" 1ms 3 KB`,
});
expect(getOtelAttributesField(log, false)).toEqual('vcs_ref_head_name=main field=value');
});
});

View File

@ -1,14 +1,15 @@
import { LogRowModel } from '@grafana/data';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { LogListModel } from '../panel/processing';
import { LogListModel, NEWLINES_REGEX } from '../panel/processing';
/**
* The presence of this field along log fields determines OTel origin.
*/
export const OTEL_PROBE_FIELD = 'severity_number';
const OTEL_LANGUAGE_UNKNOWN = 'unknown';
export function identifyOTelLanguages(logs: LogListModel[] | LogRowModel[]): string[] {
function identifyOTelLanguages(logs: LogListModel[] | LogRowModel[]): string[] {
const languagesSet = new Set<string>();
logs.forEach((log) => {
const lang = identifyOTelLanguage(log);
@ -28,7 +29,7 @@ export function identifyOTelLanguage(log: LogListModel | LogRowModel): string |
: undefined;
}
export function getDisplayedFieldsForLanguages(logs: LogListModel[] | LogRowModel[], languages: string[]) {
function getDisplayedFieldsForLanguages(logs: LogListModel[] | LogRowModel[], languages: string[]) {
const displayedFields: string[] = [];
languages.forEach((language) => {
@ -41,7 +42,10 @@ export function getDisplayedFieldsForLanguages(logs: LogListModel[] | LogRowMode
});
return displayedFields.filter(
(field) => field === LOG_LINE_BODY_FIELD_NAME || logs.some((log) => log.labels[field] !== undefined)
(field) =>
field === LOG_LINE_BODY_FIELD_NAME ||
field === OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME ||
logs.some((log) => log.labels[field] !== undefined)
);
}
@ -55,24 +59,34 @@ export function getDisplayFormatForLanguage(language: string) {
}
export function getDefaultOTelDisplayFormat() {
return ['scope_name', 'thread_name', 'exception_type', 'exception_message', LOG_LINE_BODY_FIELD_NAME];
return [
'thread_name',
'exception_type',
'exception_message',
LOG_LINE_BODY_FIELD_NAME,
OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME,
];
}
const OTEL_RESOURCE_ATTRS_REGEX =
/^(aws_|cloud_|cloudfoundry_|container_|deployment_|faas_|gcp_|host_|k8s_|os_|process_|service_|telemetry_)/;
/^(aws_|cloud_|cloudfoundry_|container_|deployment_|faas_|gcp_|host_|k8s_|os_|process_|service_|telemetry_|cluster$|namespace$|pod$)/;
const OTEL_LOG_FIELDS_REGEX =
/^(flags|observed_timestamp|scope_name|severity_number|severity_text|span_id|trace_id|detected_level)$/;
/^(flags|observed_timestamp|severity_number|severity_text|span_id|trace_id|detected_level)$/;
export function getOtelFormattedBody(log: LogListModel) {
if (!log.otelLanguage) {
return log.raw;
}
export const OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME = '___OTEL_LOG_ATTRIBUTES___';
export function getOtelAttributesField(log: LogListModel, wrapLogMessage: boolean) {
const additionalFields = Object.keys(log.labels).filter(
(label) => !OTEL_RESOURCE_ATTRS_REGEX.test(label) && !OTEL_LOG_FIELDS_REGEX.test(label)
);
return (
log.raw +
' ' +
additionalFields.map((field) => (log.labels[field] ? `${field}=${log.labels[field]}` : '')).join(' ')
(label) =>
!OTEL_RESOURCE_ATTRS_REGEX.test(label) &&
!OTEL_LOG_FIELDS_REGEX.test(label) &&
label !== OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME
);
const attributes = additionalFields
.map((field) => (log.labels[field] ? `${field}=${log.labels[field]}` : ''))
.join(' ');
if (!wrapLogMessage) {
return attributes.replace(NEWLINES_REGEX, '');
}
return attributes;
}

View File

@ -34,7 +34,7 @@ describe('HighlightedLogRenderer', () => {
}
);
const { container } = render(<HighlightedLogRenderer log={log} />);
const { container } = render(<HighlightedLogRenderer tokens={log.highlightedBodyTokens} />);
expect(container.innerHTML).toEqual(log.highlightedBody);
});
@ -177,7 +177,7 @@ describe('HighlightedLogRenderer', () => {
}
);
const { container } = render(<HighlightedLogRenderer log={log} />);
const { container } = render(<HighlightedLogRenderer tokens={log.highlightedBodyTokens} />);
expect(container.innerHTML).toEqual(log.highlightedBody);
});
@ -201,7 +201,7 @@ describe('HighlightedLogRenderer', () => {
}
);
const { container } = render(<HighlightedLogRenderer log={log} />);
const { container } = render(<HighlightedLogRenderer tokens={log.highlightedBodyTokens} />);
expect(container.innerHTML).toEqual(log.highlightedBody);
});

View File

@ -1,18 +1,18 @@
import { Token } from 'prismjs';
import { memo } from 'react';
import { LogListModel } from './processing';
export const HighlightedLogRenderer = ({ log }: { log: LogListModel }) => {
export const HighlightedLogRenderer = memo(({ tokens }: { tokens: Array<string | Token> }) => {
return (
<>
{log.highlightedBodyTokens.map((token, i) => (
{tokens.map((token, i) => (
<LogToken token={token} key={i} />
))}
</>
);
};
});
HighlightedLogRenderer.displayName = 'HighlightedLogRenderer';
const LogToken = ({ token }: { token: Token | string }) => {
const LogToken = memo(({ token }: { token: Token | string }) => {
if (typeof token === 'string') {
return token;
}
@ -30,4 +30,5 @@ const LogToken = ({ token }: { token: Token | string }) => {
{typeof token.content === 'string' ? token.content : <LogToken token={token.content} />}
</span>
);
};
});
LogToken.displayName = 'LogToken';

View File

@ -2,9 +2,11 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CoreApp, createTheme, getDefaultTimeRange, LogsDedupStrategy, LogsSortOrder } from '@grafana/data';
import { config } from '@grafana/runtime';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { createLogLine } from '../mocks/logRow';
import { getDisplayedFieldsForLogs, OTEL_PROBE_FIELD } from '../otel/formats';
import { getGridTemplateColumns, getStyles, LogLine, Props } from './LogLine';
import { LogListFontSize } from './LogList';
@ -270,6 +272,55 @@ describe.each(fontSizes)('LogLine', (fontSize: LogListFontSize) => {
expect(screen.getByTestId('ansiLogLine')).toBeInTheDocument();
expect(screen.queryByText(log.entry)).not.toBeInTheDocument();
});
test('Highlights the OTel attributes field when rendered', () => {
const originalState = config.featureToggles.otelLogsFormatting;
config.featureToggles.otelLogsFormatting = true;
log = createLogLine({
labels: { [OTEL_PROBE_FIELD]: '1', service: 'some service' },
entry: `place="luna" 1ms 3 KB`,
});
const displayedFields = getDisplayedFieldsForLogs([log]);
render(
<LogListContextProvider {...contextProps} displayedFields={displayedFields}>
<LogLine {...defaultProps} displayedFields={displayedFields} log={log} />
</LogListContextProvider>
);
expect(screen.getByText('service=')).toBeInTheDocument();
expect(screen.getByText('some service')).toBeInTheDocument();
expect(screen.getByText('place')).toBeInTheDocument();
expect(screen.getByText('1ms')).toBeInTheDocument();
expect(screen.getByText('3 KB')).toBeInTheDocument();
expect(screen.queryByText(`place="luna" 1ms 3 KB`)).not.toBeInTheDocument();
config.featureToggles.otelLogsFormatting = originalState;
});
test('OTel attributes field is not present when the flag is disabled', () => {
const originalState = config.featureToggles.otelLogsFormatting;
config.featureToggles.otelLogsFormatting = false;
log = createLogLine({
labels: { [OTEL_PROBE_FIELD]: '1', service: 'some service' },
entry: `place="luna" 1ms 3 KB`,
});
render(
<LogListContextProvider {...contextProps}>
<LogLine {...defaultProps} log={log} />
</LogListContextProvider>
);
expect(screen.queryByText('service')).not.toBeInTheDocument();
expect(screen.queryByText('some service')).not.toBeInTheDocument();
expect(screen.getByText('place')).toBeInTheDocument();
expect(screen.getByText('1ms')).toBeInTheDocument();
expect(screen.getByText('3 KB')).toBeInTheDocument();
expect(screen.queryByText(`place="luna" 1ms 3 KB`)).not.toBeInTheDocument();
config.featureToggles.otelLogsFormatting = originalState;
});
});
describe('Collapsible log lines', () => {

View File

@ -20,13 +20,14 @@ import { Button, Icon, Tooltip } from '@grafana/ui';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { LogLabels } from '../LogLabels';
import { LogMessageAnsi } from '../LogMessageAnsi';
import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME } from '../otel/formats';
import { HighlightedLogRenderer } from './HighlightedLogRenderer';
import { InlineLogLineDetails } from './LogLineDetails';
import { LogLineMenu } from './LogLineMenu';
import { useLogIsPermalinked, useLogIsPinned, useLogListContext } from './LogListContext';
import { useLogListSearchContext } from './LogListSearchContext';
import { LogListModel } from './processing';
import { getNormalizedFieldName, LogListModel } from './processing';
import {
FIELD_GAP_MULTIPLIER,
getLogLineDOMHeight,
@ -374,6 +375,7 @@ const DisplayedFields = ({
styles: LogLineStyles;
}) => {
const { matchingUids, search } = useLogListSearchContext();
const { syntaxHighlighting } = useLogListContext();
const searchWords = useMemo(() => {
const searchWords = log.searchWords && log.searchWords[0] ? log.searchWords.slice() : [];
@ -386,11 +388,19 @@ const DisplayedFields = ({
return searchWords;
}, [log.searchWords, log.uid, matchingUids, search]);
return displayedFields.map((field) =>
field === LOG_LINE_BODY_FIELD_NAME ? (
<LogLineBody log={log} key={field} styles={styles} />
) : (
<span className="field" title={field} key={field}>
return displayedFields.map((field) => {
if (field === LOG_LINE_BODY_FIELD_NAME) {
return <LogLineBody log={log} key={field} styles={styles} />;
}
if (field === OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME && syntaxHighlighting) {
return (
<span className="field log-syntax-highlight" title={getNormalizedFieldName(field)} key={field}>
<HighlightedLogRenderer tokens={log.highlightedLogAttributesTokens} />
</span>
);
}
return (
<span className="field" title={getNormalizedFieldName(field)} key={field}>
{searchWords ? (
<Highlighter
textToHighlight={log.getDisplayedFieldValue(field)}
@ -402,8 +412,8 @@ const DisplayedFields = ({
log.getDisplayedFieldValue(field)
)}
</span>
)
);
);
});
};
const LogLineBody = ({ log, styles }: { log: LogListModel; styles: LogLineStyles }) => {
@ -444,7 +454,7 @@ const LogLineBody = ({ log, styles }: { log: LogListModel; styles: LogLineStyles
return (
<span className="field log-syntax-highlight">
<HighlightedLogRenderer log={log} />
<HighlightedLogRenderer tokens={log.highlightedBodyTokens} />
</span>
);
};

View File

@ -6,11 +6,10 @@ import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { Card, IconButton, useStyles2 } from '@grafana/ui';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { LogLineDetailsMode } from './LogLineDetails';
import { useLogListContext } from './LogListContext';
import { reportInteractionOnce } from './analytics';
import { getNormalizedFieldName } from './processing';
export const LogLineDetailsDisplayedFields = () => {
const { displayedFields, setDisplayedFields } = useLogListContext();
@ -98,9 +97,7 @@ const DisplayedField = ({
<div ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}>
<Card noMargin className={styles.fieldCard}>
<div className={styles.fieldWrapper}>
<div className={styles.field}>
{field === LOG_LINE_BODY_FIELD_NAME ? t('logs.log-line-details.log-line-field', 'Log line') : field}
</div>
<div className={styles.field}>{getNormalizedFieldName(field)}</div>
{displayedFields.length > 1 && (
<>
<IconButton

View File

@ -12,9 +12,10 @@ import { logRowToSingleRowDataFrame } from '../../logsModel';
import { calculateLogsLabelStats, calculateStats } from '../../utils';
import { LogLabelStats } from '../LogLabelStats';
import { FieldDef } from '../logParser';
import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME } from '../otel/formats';
import { useLogListContext } from './LogListContext';
import { LogListModel } from './processing';
import { LogListModel, getNormalizedFieldName } from './processing';
interface LogLineDetailsFieldsProps {
disableActions?: boolean;
@ -258,12 +259,14 @@ export const LogLineDetailsField = ({
const singleKey = keys.length === 1;
const singleValue = values.length === 1;
const fieldSupportsFilters = keys[0] !== OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME;
return (
<>
<div className={styles.row}>
{!disableActions && (
<div className={styles.actions}>
{onClickFilterLabel && (
{onClickFilterLabel && fieldSupportsFilters && (
<AsyncIconButton
name="search-plus"
onClick={filterLabel}
@ -272,7 +275,7 @@ export const LogLineDetailsField = ({
tooltipSuffix={refIdTooltip}
/>
)}
{onClickFilterOutLabel && (
{onClickFilterOutLabel && fieldSupportsFilters && (
<IconButton
name="search-minus"
tooltip={
@ -313,7 +316,9 @@ export const LogLineDetailsField = ({
/>
</div>
)}
<div className={styles.label}>{singleKey ? keys[0] : <MultipleValue values={keys} />}</div>
<div className={styles.label}>
{singleKey ? getNormalizedFieldName(keys[0]) : <MultipleValue values={keys} />}
</div>
<div className={styles.value}>
<div className={styles.valueContainer}>
{singleValue ? (

View File

@ -33,7 +33,9 @@ export const LogLineDetailsLog = memo(({ log: originalLog, syntaxHighlighting }:
<>
{!syntaxHighlighting && <div className="field no-highlighting">{log.body}</div>}
{syntaxHighlighting && (
<div className="field log-syntax-highlight">{<HighlightedLogRenderer log={log} />}</div>
<div className="field log-syntax-highlight">
{<HighlightedLogRenderer tokens={log.highlightedBodyTokens} />}
</div>
)}
</>
)}

View File

@ -13,7 +13,9 @@ import {
import { config, reportInteraction } from '@grafana/runtime';
import { disablePopoverMenu, enablePopoverMenu, isPopoverMenuDisabled } from '../../utils';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { createLogRow } from '../mocks/logRow';
import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME, OTEL_PROBE_FIELD } from '../otel/formats';
import { LogList, Props } from './LogList';
@ -223,6 +225,67 @@ describe('LogList', () => {
expect(screen.getByText('debug')).toBeInTheDocument();
});
describe('OTel log lines', () => {
const originalState = config.featureToggles.otelLogsFormatting;
test('Does not perform OTel-related actions when the flag is disabled', () => {
config.featureToggles.otelLogsFormatting = false;
const onLogOptionsChange = jest.fn();
const setDisplayedFields = jest.fn();
render(
<LogList {...defaultProps} onLogOptionsChange={onLogOptionsChange} setDisplayedFields={setDisplayedFields} />
);
expect(screen.getByText('log message 1')).toBeInTheDocument();
expect(onLogOptionsChange).not.toHaveBeenCalled();
expect(setDisplayedFields).not.toHaveBeenCalled();
config.featureToggles.otelLogsFormatting = originalState;
});
test('Reports the default displayed fields for non-OTel logs', () => {
config.featureToggles.otelLogsFormatting = true;
const onLogOptionsChange = jest.fn();
const setDisplayedFields = jest.fn();
render(
<LogList {...defaultProps} onLogOptionsChange={onLogOptionsChange} setDisplayedFields={setDisplayedFields} />
);
expect(screen.getByText('log message 1')).toBeInTheDocument();
expect(onLogOptionsChange).toHaveBeenCalledWith('defaultDisplayedFields', []);
// No fields to display, no call
expect(setDisplayedFields).not.toHaveBeenCalled();
config.featureToggles.otelLogsFormatting = originalState;
});
test('Reports the default OTel displayed fields', () => {
config.featureToggles.otelLogsFormatting = true;
const onLogOptionsChange = jest.fn();
const setDisplayedFields = jest.fn();
const logs = [createLogRow({ uid: '1', labels: { [OTEL_PROBE_FIELD]: '1' } })];
render(
<LogList
{...defaultProps}
logs={logs}
onLogOptionsChange={onLogOptionsChange}
setDisplayedFields={setDisplayedFields}
/>
);
expect(screen.getByText('log message 1')).toBeInTheDocument();
expect(onLogOptionsChange).toHaveBeenCalledWith('defaultDisplayedFields', [
LOG_LINE_BODY_FIELD_NAME,
OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME,
]);
expect(setDisplayedFields).toHaveBeenCalledWith([LOG_LINE_BODY_FIELD_NAME, OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME]);
config.featureToggles.otelLogsFormatting = originalState;
});
});
describe('Popover menu', () => {
function setup(overrides: Partial<Props> = {}) {
return render(

View File

@ -66,7 +66,7 @@ export interface Props {
onClickFilterOutString?: (value: string, refId?: string) => void;
onClickShowField?: (key: string) => void;
onClickHideField?: (key: string) => void;
onLogOptionsChange?: (option: LogListControlOptions, value: string | boolean | string[]) => void;
onLogOptionsChange?: (option: LogListOptions, value: string | boolean | string[]) => void;
onLogLineHover?: (row?: LogRowModel) => void;
onPermalinkClick?: (row: LogRowModel) => Promise<void>;
onPinLine?: (row: LogRowModel) => void;
@ -78,6 +78,11 @@ export interface Props {
prettifyJSON?: boolean;
setDisplayedFields?: (displayedFields: string[]) => void;
showControls: boolean;
/**
* Experimental. When OTel logs are displayed, add an extra displayed field with relevant key-value pairs from labels and metadata
* @alpha
*/
showLogAttributes?: boolean;
showTime: boolean;
showUniqueLabels?: boolean;
sortOrder: LogsSortOrder;
@ -90,7 +95,7 @@ export interface Props {
export type LogListFontSize = 'default' | 'small';
export type LogListControlOptions = keyof LogListState | 'wrapLogMessage' | 'prettifyLogMessage';
export type LogListOptions = keyof LogListState | 'wrapLogMessage' | 'prettifyLogMessage' | 'defaultDisplayedFields';
type LogListComponentProps = Omit<
Props,
@ -148,6 +153,7 @@ export const LogList = ({
prettifyJSON = logOptionsStorageKey ? store.getBool(`${logOptionsStorageKey}.prettifyLogMessage`, true) : true,
setDisplayedFields,
showControls,
showLogAttributes,
showTime,
showUniqueLabels,
sortOrder,
@ -193,6 +199,7 @@ export const LogList = ({
prettifyJSON={prettifyJSON}
setDisplayedFields={setDisplayedFields}
showControls={showControls}
showLogAttributes={showLogAttributes}
showTime={showTime}
showUniqueLabels={showUniqueLabels}
sortOrder={sortOrder}

View File

@ -33,7 +33,7 @@ import { getDisplayedFieldsForLogs } from '../otel/formats';
import { LogLineTimestampResolution } from './LogLine';
import { LogLineDetailsMode } from './LogLineDetails';
import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu';
import { LogListControlOptions, LogListFontSize } from './LogList';
import { LogListOptions, LogListFontSize } from './LogList';
import { reportInteractionOnce } from './analytics';
import { LogListModel } from './processing';
import { getScrollbarWidth, LOG_LIST_CONTROLS_WIDTH, LOG_LIST_MIN_WIDTH } from './virtualization';
@ -176,7 +176,7 @@ export interface Props {
onClickFilterOutString?: (value: string, refId?: string) => void;
onClickShowField?: (key: string) => void;
onClickHideField?: (key: string) => void;
onLogOptionsChange?: (option: LogListControlOptions, value: string | boolean | string[]) => void;
onLogOptionsChange?: (option: LogListOptions, value: string | boolean | string[]) => void;
onLogLineHover?: (row?: LogRowModel) => void;
onPermalinkClick?: (row: LogRowModel) => Promise<void>;
onPinLine?: (row: LogRowModel) => void;
@ -188,6 +188,7 @@ export interface Props {
prettifyJSON?: boolean;
setDisplayedFields?: (displayedFields: string[]) => void;
showControls: boolean;
showLogAttributes?: boolean;
showUniqueLabels?: boolean;
showTime: boolean;
sortOrder: LogsSortOrder;
@ -234,6 +235,7 @@ export const LogListContextProvider = ({
prettifyJSON: prettifyJSONProp,
setDisplayedFields,
showControls,
showLogAttributes,
showTime,
showUniqueLabels,
sortOrder,
@ -289,16 +291,28 @@ export const LogListContextProvider = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const otelDisplayedFields = useMemo(() => {
if (!config.featureToggles.otelLogsFormatting || !setDisplayedFields || showLogAttributes === false) {
return [];
}
return getDisplayedFieldsForLogs(logs);
}, [logs, setDisplayedFields, showLogAttributes]);
// OTel displayed fields
useEffect(() => {
if (displayedFields.length > 0 || !config.featureToggles.otelLogsFormatting || !setDisplayedFields) {
if (config.featureToggles.otelLogsFormatting && showLogAttributes !== false) {
onLogOptionsChange?.('defaultDisplayedFields', otelDisplayedFields);
}
}, [onLogOptionsChange, otelDisplayedFields, showLogAttributes]);
useEffect(() => {
if (displayedFields.length > 0 || !setDisplayedFields) {
return;
}
const otelDisplayedFields = getDisplayedFieldsForLogs(logs);
if (otelDisplayedFields.length) {
setDisplayedFields(otelDisplayedFields);
}
}, [displayedFields.length, logs, setDisplayedFields]);
}, [displayedFields.length, otelDisplayedFields, setDisplayedFields]);
// Sync state
useEffect(() => {
@ -404,6 +418,13 @@ export const LogListContextProvider = ({
}));
}, [timestampResolution]);
// Sync showLogAttributes
useEffect(() => {
if (showLogAttributes === false && setDisplayedFields) {
setDisplayedFields([]);
}
}, [setDisplayedFields, showLogAttributes]);
const controlsExpandedFromStore = store.getBool(
`${logOptionsStorageKey}.controlsExpanded`,
getDefaultControlsExpandedMode(containerElement ?? null)

View File

@ -3,6 +3,7 @@ import { config } from '@grafana/runtime';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { createLogLine, createLogRow } from '../mocks/logRow';
import { OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME, OTEL_PROBE_FIELD } from '../otel/formats';
import { LogListFontSize } from './LogList';
import { LogListModel, preProcessLogs } from './processing';
@ -237,6 +238,67 @@ describe('preProcessLogs', () => {
expect(logListModel.body).toBeDefined(); // Triggers parsing
expect(logListModel.isJSON).toBe(false);
});
describe('OTel logs', () => {
const originalState = config.featureToggles.otelLogsFormatting;
test('Does not create the OTel attribute field when not enabled', () => {
config.featureToggles.otelLogsFormatting = false;
const logListModel = createLogLine(
{ entry: 'the log' },
{
escape: false,
order: LogsSortOrder.Descending,
timeZone: 'browser',
wrapLogMessage: true, // wrapped
prettifyJSON: true,
}
);
expect(logListModel.labels[OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME]).toBeUndefined();
expect(logListModel.highlightedLogAttributesTokens).toHaveLength(0);
config.featureToggles.otelLogsFormatting = originalState;
});
test('Does not create the OTel attribute field when is not an OTel log', () => {
config.featureToggles.otelLogsFormatting = false;
const logListModel = createLogLine(
{ entry: 'the log', labels: {} },
{
escape: false,
order: LogsSortOrder.Descending,
timeZone: 'browser',
wrapLogMessage: true, // wrapped
prettifyJSON: true,
}
);
expect(logListModel.labels[OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME]).toBeUndefined();
expect(logListModel.highlightedLogAttributesTokens).toHaveLength(0);
config.featureToggles.otelLogsFormatting = originalState;
});
test('Generates and highlights an OTel log line attributes field', () => {
config.featureToggles.otelLogsFormatting = true;
const logListModel = createLogLine(
{ entry: 'the log', labels: { [OTEL_PROBE_FIELD]: '1', field: 'value' } },
{
escape: false,
order: LogsSortOrder.Descending,
timeZone: 'browser',
wrapLogMessage: true, // wrapped
prettifyJSON: true,
}
);
expect(logListModel.labels[OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME]).toEqual('field=value');
expect(logListModel.highlightedLogAttributesTokens).toHaveLength(2);
config.featureToggles.otelLogsFormatting = originalState;
});
});
});
test('Orders logs', () => {
@ -440,43 +502,3 @@ describe('preProcessLogs', () => {
});
});
});
describe('OTel logs', () => {
let originalOtelLogsFormatting = config.featureToggles.otelLogsFormatting;
afterAll(() => {
config.featureToggles.otelLogsFormatting = originalOtelLogsFormatting;
});
test('Requires a feature flag', () => {
const log = createLogLine({
labels: {
severity_number: '1',
telemetry_sdk_language: 'php',
scope_name: 'scope',
aws_ignore: 'ignored',
key: 'value',
otel: 'otel',
},
entry: `place="luna" 1ms 3 KB`,
});
expect(log.otelLanguage).toBeDefined();
expect(log.body).toEqual(`place="luna" 1ms 3 KB`);
});
test('Augments OTel log lines', () => {
config.featureToggles.otelLogsFormatting = true;
const log = createLogLine({
labels: {
severity_number: '1',
telemetry_sdk_language: 'php',
scope_name: 'scope',
aws_ignore: 'ignored',
key: 'value',
otel: 'otel',
},
entry: `place="luna" 1ms 3 KB`,
});
expect(log.otelLanguage).toBeDefined();
expect(log.body).toEqual(`place="luna" 1ms 3 KB key=value otel=otel`);
});
});

View File

@ -11,19 +11,20 @@ import {
LogsSortOrder,
systemDateFormats,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { GetFieldLinksFn } from 'app/plugins/panel/logs/types';
import { checkLogsError, checkLogsSampled, escapeUnescapedString, sortLogRows } from '../../utils';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { FieldDef, getAllFields } from '../logParser';
import { identifyOTelLanguage, getOtelFormattedBody } from '../otel/formats';
import { identifyOTelLanguage, getOtelAttributesField, OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME } from '../otel/formats';
import { generateLogGrammar, generateTextMatchGrammar } from './grammar';
import { LogLineVirtualization } from './virtualization';
const TRUNCATION_DEFAULT_LENGTH = 50000;
const NEWLINES_REGEX = /(\r\n|\n|\r)/g;
export const NEWLINES_REGEX = /(\r\n|\n|\r)/g;
export class LogListModel implements LogRowModel {
collapsed: boolean | undefined = undefined;
@ -59,6 +60,7 @@ export class LogListModel implements LogRowModel {
private _currentSearch: string | undefined = undefined;
private _grammar?: Grammar;
private _highlightedBody: string | undefined = undefined;
private _highlightedLogAttributesTokens: Array<string | Token> | undefined = undefined;
private _highlightTokens: Array<string | Token> | undefined = undefined;
private _fields: FieldDef[] | undefined = undefined;
private _getFieldLinks: GetFieldLinksFn | undefined = undefined;
@ -114,6 +116,10 @@ export class LogListModel implements LogRowModel {
raw = escapeUnescapedString(raw);
}
this.raw = raw;
if (config.featureToggles.otelLogsFormatting && this.otelLanguage) {
this.labels[OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME] = getOtelAttributesField(this, wrapLogMessage);
}
}
clone() {
@ -137,7 +143,7 @@ export class LogListModel implements LogRowModel {
this.raw = reStringified;
}
} catch (error) {}
const raw = config.featureToggles.otelLogsFormatting && this.otelLanguage ? getOtelFormattedBody(this) : this.raw;
const raw = this.raw;
this._body = this.collapsed
? raw.substring(0, this._virtualization?.getTruncationLength(null) ?? TRUNCATION_DEFAULT_LENGTH)
: raw;
@ -181,6 +187,19 @@ export class LogListModel implements LogRowModel {
return this._highlightTokens;
}
get highlightedLogAttributesTokens() {
if (this._highlightedLogAttributesTokens === undefined) {
const attributes = this.labels[OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME] ?? '';
if (!attributes) {
return [];
}
this._grammar = this._grammar ?? generateLogGrammar(this);
const extraGrammar = generateTextMatchGrammar(this.searchWords, this._currentSearch);
this._highlightedLogAttributesTokens = Prism.tokenize(attributes, { ...extraGrammar, ...this._grammar });
}
return this._highlightedLogAttributesTokens;
}
get isJSON() {
return this._json;
}
@ -250,6 +269,7 @@ export class LogListModel implements LogRowModel {
setCurrentSearch(search: string | undefined) {
this._currentSearch = search;
this._highlightTokens = undefined;
this._highlightedLogAttributesTokens = undefined;
}
}
@ -335,3 +355,12 @@ export function getLevelsFromLogs(logs: LogListModel[]) {
}
return Array.from(levels).filter((level) => level != null);
}
export function getNormalizedFieldName(field: string) {
if (field === LOG_LINE_BODY_FIELD_NAME) {
return t('logs.log-line-details.log-line-field', 'Log line');
} else if (field === OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME) {
return t('logs.log-line-details.log-attributes-field', 'OTel attributes');
}
return field;
}

View File

@ -405,6 +405,7 @@ describe('getPluginExtensions()', () => {
expect.objectContaining({
context,
openModal: expect.any(Function),
extensionPointId: extensionPoint2,
})
);
});

View File

@ -537,6 +537,7 @@ export function getLinkExtensionOnClick(
const helpers: PluginExtensionEventHelpers = {
context,
extensionPointId,
openModal: createOpenModalFunction(config),
openSidebar: (componentTitle, context) => {
appEvents.publish(

View File

@ -2,7 +2,7 @@ import { DataFrame, DataFrameView, FieldType, getDisplayProcessor, SelectableVal
import { config } from '@grafana/runtime';
import { TermCount } from 'app/core/components/TagFilter/TagFilter';
import { backendSrv } from 'app/core/services/backend_srv';
import { PermissionLevelString } from 'app/types/acl';
import { PermissionLevel } from 'app/types/acl';
import { DEFAULT_MAX_VALUES, GENERAL_FOLDER_UID, TYPE_KIND_MAP } from '../constants';
import { DashboardSearchHit, DashboardSearchItemType } from '../types';
@ -21,7 +21,7 @@ interface APIQuery {
folderUIDs?: string[];
sort?: string;
starred?: boolean;
permission?: PermissionLevelString;
permission?: PermissionLevel;
deleted?: boolean;
}

View File

@ -1,6 +1,6 @@
import { DataFrameView, SelectableValue } from '@grafana/data';
import { TermCount } from 'app/core/components/TagFilter/TagFilter';
import { PermissionLevelString } from 'app/types/acl';
import { PermissionLevel } from 'app/types/acl';
import { ManagerKind } from '../../apiserver/types';
@ -39,7 +39,7 @@ export interface SearchQuery {
limit?: number;
from?: number;
starred?: boolean;
permission?: PermissionLevelString;
permission?: PermissionLevel;
deleted?: boolean;
offset?: number;
}

View File

@ -47,6 +47,7 @@ import { LogLabels } from '../../../features/logs/components/LogLabels';
import { LogRows } from '../../../features/logs/components/LogRows';
import { COMMON_LABELS, dataFrameToLogsModel, dedupLogRows } from '../../../features/logs/logsModel';
import type { Options } from './panelcfg.gen';
import {
GetFieldLinksFn,
isCoreApp,
@ -63,7 +64,6 @@ import {
isReactNodeArray,
isSetDisplayedFields,
onNewLogsReceivedType,
Options,
} from './types';
import { useDatasourcesFromTargets } from './useDatasourcesFromTargets';
@ -114,7 +114,7 @@ interface LogsPanelProps extends PanelProps<Options> {
* controlsStorageKey?: string
*
* If controls are enabled, this function is called when a change is made in one of the options from the controls.
* onLogOptionsChange?: (option: LogListControlOptions, value: string | boolean | string[]) => void;
* onLogOptionsChange?: (option: LogListOptions, value: string | boolean | string[]) => void;
*
* When the feature toggle newLogsPanel is enabled, you can pass extra options to the LogLineMenu component.
* These options are an array of items with { label, onClick } or { divider: true } for dividers.
@ -128,6 +128,11 @@ interface LogsPanelProps extends PanelProps<Options> {
*
* When showing timestamps, toggle between showing nanoseconds or milliseconds.
* timestampResolution?: 'ms' | 'ns'
*
* Experimental. When OTel logs are displayed, add an extra displayed field with relevant key-value pairs from labels and metadata.
* Requires the `otelLogsFormatting`.
* @alpha
* showLogAttributes?: boolean
*/
}
interface LogsPermalinkUrlState {
@ -170,6 +175,7 @@ export const LogsPanel = ({
detailsMode: detailsModeProp,
noInteractions,
timestampResolution,
showLogAttributes,
...options
},
height,
@ -609,6 +615,7 @@ export const LogsPanel = ({
prettifyJSON={prettifyLogMessage}
setDisplayedFields={setDisplayedFieldsFn}
showControls={Boolean(showControls)}
showLogAttributes={showLogAttributes}
showTime={showTime}
showUniqueLabels={showLabels}
sortOrder={sortOrder}

View File

@ -119,6 +119,19 @@ export const plugin = new PanelPlugin<Options>(LogsPanel)
defaultValue: false,
});
if (config.featureToggles.otelLogsFormatting) {
builder.addBooleanSwitch({
path: 'showLogAttributes',
name: t('logs.show-log-attributes', 'Display log attributes for OTel logs'),
category,
description: t(
'logs.description-show-log-attributes',
'Experimental. When OTel logs are displayed, add an extra displayed field with relevant key-value pairs from labels and metadata.'
),
defaultValue: true,
});
}
if (config.featureToggles.newLogsPanel) {
builder
.addBooleanSwitch({

View File

@ -40,6 +40,7 @@ composableKinds: PanelCfg: {
dedupStrategy: common.LogsDedupStrategy
enableInfiniteScrolling?: bool
noInteractions?: bool
showLogAttributes?: bool
fontSize?: "default" | "small" @cuetsy(kind="enum", memberNames="default|small")
detailsMode?: "inline" | "sidebar" @cuetsy(kind="enum", memberNames="inline|sidebar")
timestampResolution?: "ms" | "ns" @cuetsy(kind="enum", memberNames="ms|ns")

View File

@ -39,6 +39,7 @@ export interface Options {
showCommonLabels: boolean;
showControls?: boolean;
showLabels: boolean;
showLogAttributes?: boolean;
showLogContextToggle: boolean;
showTime: boolean;
sortOrder: common.LogsSortOrder;

View File

@ -1,7 +1,7 @@
import { VisualizationSuggestionsBuilder, VisualizationSuggestionScore } from '@grafana/data';
import { SuggestionName } from 'app/types/suggestions';
import { Options } from './types';
import { Options } from './panelcfg.gen';
export class LogsPanelSuggestionsSupplier {
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {

View File

@ -2,9 +2,7 @@ import React, { ReactNode } from 'react';
import { CoreApp, DataFrame, Field, LinkModel, ScopedVars } from '@grafana/data';
import { LogLineMenuCustomItem } from 'app/features/logs/components/panel/LogLineMenu';
import { LogListControlOptions } from 'app/features/logs/components/panel/LogList';
export type { Options } from './panelcfg.gen';
import { LogListOptions } from 'app/features/logs/components/panel/LogList';
type onClickFilterLabelType = (key: string, value: string, frame?: DataFrame) => void;
type onClickFilterOutLabelType = (key: string, value: string, frame?: DataFrame) => void;
@ -14,7 +12,7 @@ type filterLabelActiveType = (key: string, value: string, refId?: string) => Pro
type onClickShowFieldType = (value: string) => void;
type onClickHideFieldType = (value: string) => void;
export type onNewLogsReceivedType = (allLogs: DataFrame[], newLogs: DataFrame[]) => void;
type onLogOptionsChangeType = (option: LogListControlOptions, value: string | boolean | string[]) => void;
type onLogOptionsChangeType = (option: LogListOptions, value: string | boolean | string[]) => void;
type setDisplayedFieldsType = (fields: string[]) => void;
export type GetFieldLinksFn = (

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