diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index da6fb835d55..62b9e403826 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/Dockerfile b/Dockerfile index 81d19a46e3c..a5121873b10 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/apps/scope/go.mod b/apps/scope/go.mod new file mode 100644 index 00000000000..435e401adba --- /dev/null +++ b/apps/scope/go.mod @@ -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 +) diff --git a/apps/scope/go.sum b/apps/scope/go.sum new file mode 100644 index 00000000000..25bae6f532a --- /dev/null +++ b/apps/scope/go.sum @@ -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= diff --git a/apps/scope/pkg/apis/scope/v0alpha1/doc.go b/apps/scope/pkg/apis/scope/v0alpha1/doc.go new file mode 100644 index 00000000000..5012479c82d --- /dev/null +++ b/apps/scope/pkg/apis/scope/v0alpha1/doc.go @@ -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" diff --git a/apps/scope/pkg/apis/scope/v0alpha1/register.go b/apps/scope/pkg/apis/scope/v0alpha1/register.go new file mode 100644 index 00000000000..881416421c4 --- /dev/null +++ b/apps/scope/pkg/apis/scope/v0alpha1/register.go @@ -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() +} diff --git a/apps/scope/pkg/apis/scope/v0alpha1/types.go b/apps/scope/pkg/apis/scope/v0alpha1/types.go new file mode 100644 index 00000000000..fcd7812c8da --- /dev/null +++ b/apps/scope/pkg/apis/scope/v0alpha1/types.go @@ -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" +) diff --git a/apps/scope/pkg/apis/scope/v0alpha1/zz_generated.deepcopy.go b/apps/scope/pkg/apis/scope/v0alpha1/zz_generated.deepcopy.go new file mode 100644 index 00000000000..a2be794e5bb --- /dev/null +++ b/apps/scope/pkg/apis/scope/v0alpha1/zz_generated.deepcopy.go @@ -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 +} diff --git a/apps/scope/pkg/apis/scope/v0alpha1/zz_generated.defaults.go b/apps/scope/pkg/apis/scope/v0alpha1/zz_generated.defaults.go new file mode 100644 index 00000000000..238fc2f4edc --- /dev/null +++ b/apps/scope/pkg/apis/scope/v0alpha1/zz_generated.defaults.go @@ -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 +} diff --git a/apps/scope/pkg/apis/scope/v0alpha1/zz_generated.openapi.go b/apps/scope/pkg/apis/scope/v0alpha1/zz_generated.openapi.go new file mode 100644 index 00000000000..3f31343d82a --- /dev/null +++ b/apps/scope/pkg/apis/scope/v0alpha1/zz_generated.openapi.go @@ -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"}, + } +} diff --git a/apps/scope/pkg/apis/scope/v0alpha1/zz_generated.openapi_violation_exceptions.list b/apps/scope/pkg/apis/scope/v0alpha1/zz_generated.openapi_violation_exceptions.list new file mode 100644 index 00000000000..c2130851da8 --- /dev/null +++ b/apps/scope/pkg/apis/scope/v0alpha1/zz_generated.openapi_violation_exceptions.list @@ -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 diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 3b053a347bb..2b360a25ed9 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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 diff --git a/go.mod b/go.mod index e00dc78ec9c..1a66ddb70d2 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.work b/go.work index 2daa0eff86c..efba709a41b 100644 --- a/go.work +++ b/go.work @@ -18,6 +18,7 @@ use ( ./apps/plugins ./apps/preferences ./apps/provisioning + ./apps/scope ./apps/secret ./apps/shorturl ./pkg/aggregator diff --git a/hack/update-codegen.sh b/hack/update-codegen.sh index 0407a15f193..d69ffe48ee8 100755 --- a/hack/update-codegen.sh +++ b/hack/update-codegen.sh @@ -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 diff --git a/packages/grafana-data/src/themes/createComponents.ts b/packages/grafana-data/src/themes/createComponents.ts index a654e102007..5771977d1f8 100644 --- a/packages/grafana-data/src/themes/createComponents.ts +++ b/packages/grafana-data/src/themes/createComponents.ts @@ -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, }; } diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index 08767dcd549..4707c9123dd 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -165,6 +165,8 @@ export type PluginExtensionOpenModalOptions = { export type PluginExtensionEventHelpers = { context?: Readonly; + // 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; /** diff --git a/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts index 4fc8ccf355a..90b4c7194f1 100644 --- a/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts @@ -41,6 +41,7 @@ export interface Options { showCommonLabels: boolean; showControls?: boolean; showLabels: boolean; + showLogAttributes?: boolean; showLogContextToggle: boolean; showTime: boolean; sortOrder: common.LogsSortOrder; diff --git a/packages/grafana-test-utils/src/handlers/api/folders/handlers.ts b/packages/grafana-test-utils/src/handlers/api/folders/handlers.ts index be3f30adeda..220beac70b3 100644 --- a/packages/grafana-test-utils/src/handlers/api/folders/handlers.ts +++ b/packages/grafana-test-utils/src/handlers/api/folders/handlers.ts @@ -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 diff --git a/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.tsx b/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.tsx index 8c50da47478..a1bdc9a945f 100644 --- a/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.tsx +++ b/packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.tsx @@ -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 { }; 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 { * @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 { }; render() { - const { title, children, style, dependencies, errorLogger } = this.props; + const { title, children, style, dependencies, errorLogger, boundaryName } = this.props; return ( - + {({ error, errorInfo }) => { if (!errorInfo) { return children; diff --git a/packages/grafana-ui/src/components/Menu/Menu.tsx b/packages/grafana-ui/src/components/Menu/Menu.tsx index 187943293db..f77a26bb0e7 100644 --- a/packages/grafana-ui/src/components/Menu/Menu.tsx +++ b/packages/grafana-ui/src/components/Menu/Menu.tsx @@ -25,6 +25,7 @@ export interface MenuProps extends React.HTMLAttributes { const MenuComp = React.forwardRef( ({ header, children, ariaLabel, onOpen, onClose, onKeyDown, ...otherProps }, forwardedRef) => { const styles = useStyles2(getStyles); + const componentTokens = useComponentTokens(); const localRef = useRef(null); useImperativeHandle(forwardedRef, () => localRef.current!); @@ -36,12 +37,11 @@ const MenuComp = React.forwardRef( {...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({ diff --git a/packages/grafana-ui/src/components/Menu/MenuItem.tsx b/packages/grafana-ui/src/components/Menu/MenuItem.tsx index 80748c58888..f6619c1912b 100644 --- a/packages/grafana-ui/src/components/Menu/MenuItem.tsx +++ b/packages/grafana-ui/src/components/Menu/MenuItem.tsx @@ -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%', diff --git a/pkg/extensions/enterprise_imports.go b/pkg/extensions/enterprise_imports.go index 2dbdad4a9d6..9c052688d8f 100644 --- a/pkg/extensions/enterprise_imports.go +++ b/pkg/extensions/enterprise_imports.go @@ -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" ) diff --git a/pkg/services/auth/authimpl/external_session_store.go b/pkg/services/auth/authimpl/external_session_store.go index 95e379b79b6..33a43355e59 100644 --- a/pkg/services/auth/authimpl/external_session_store.go +++ b/pkg/services/auth/authimpl/external_session_store.go @@ -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 diff --git a/pkg/services/auth/external_session.go b/pkg/services/auth/external_session.go index e67b5a10259..32e9e1f4236 100644 --- a/pkg/services/auth/external_session.go +++ b/pkg/services/auth/external_session.go @@ -51,6 +51,7 @@ type UpdateExternalSessionCommand struct { type ListExternalSessionQuery struct { ID int64 + UserID int64 NameID string SessionID string } diff --git a/pkg/services/authn/authnimpl/sync/oauth_token_sync.go b/pkg/services/authn/authnimpl/sync/oauth_token_sync.go index a63853648a7..cb2103437ce 100644 --- a/pkg/services/authn/authnimpl/sync/oauth_token_sync.go +++ b/pkg/services/authn/authnimpl/sync/oauth_token_sync.go @@ -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) } diff --git a/pkg/services/authn/authnimpl/sync/oauth_token_sync_test.go b/pkg/services/authn/authnimpl/sync/oauth_token_sync_test.go index 5f5e1303a95..3178f5390f2 100644 --- a/pkg/services/authn/authnimpl/sync/oauth_token_sync_test.go +++ b/pkg/services/authn/authnimpl/sync/oauth_token_sync_test.go @@ -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 }, diff --git a/pkg/services/authn/clients/oauth.go b/pkg/services/authn/clients/oauth.go index 0e8053a39b7..e6d4b67d122 100644 --- a/pkg/services/authn/clients/oauth.go +++ b/pkg/services/authn/clients/oauth.go @@ -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) } diff --git a/pkg/services/authn/clients/oauth_test.go b/pkg/services/authn/clients/oauth_test.go index 2bd536d6544..b6a9a07c488 100644 --- a/pkg/services/authn/clients/oauth_test.go +++ b/pkg/services/authn/clients/oauth_test.go @@ -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 { diff --git a/pkg/services/authz/rbac/service.go b/pkg/services/authz/rbac/service.go index 105f7db7157..9af70a26277 100644 --- a/pkg/services/authz/rbac/service.go +++ b/pkg/services/authz/rbac/service.go @@ -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) diff --git a/pkg/services/login/authinfo.go b/pkg/services/login/authinfo.go index 3e9751d0ea2..095e3390ce9 100644 --- a/pkg/services/login/authinfo.go +++ b/pkg/services/login/authinfo.go @@ -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) diff --git a/pkg/services/login/authinfotest/auth_info_service_mock.go b/pkg/services/login/authinfotest/auth_info_service_mock.go new file mode 100644 index 00000000000..42f9bd60b7f --- /dev/null +++ b/pkg/services/login/authinfotest/auth_info_service_mock.go @@ -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 +} diff --git a/pkg/services/oauthtoken/oauth_token.go b/pkg/services/oauthtoken/oauth_token.go index 0304eca46f1..a3503e06522 100644 --- a/pkg/services/oauthtoken/oauth_token.go +++ b/pkg/services/oauthtoken/oauth_token.go @@ -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) +} diff --git a/pkg/services/oauthtoken/oauth_token_test.go b/pkg/services/oauthtoken/oauth_token_test.go index 487a4f6426e..2dfc3e6e68e 100644 --- a/pkg/services/oauthtoken/oauth_token_test.go +++ b/pkg/services/oauthtoken/oauth_token_test.go @@ -2,7 +2,6 @@ package oauthtoken import ( "context" - "errors" "testing" "time" @@ -18,7 +17,6 @@ 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/auth/authtest" "github.com/grafana/grafana/pkg/services/authn" @@ -38,69 +36,57 @@ func TestMain(m *testing.M) { testsuite.Run(m) } -type FakeAuthInfoStore struct { - login.Store - ExpectedError error - ExpectedOAuth *login.UserAuth -} +var ( + unexpiredTokenWithoutRefresh = &oauth2.Token{ + AccessToken: "testaccess", + Expiry: time.Now().Add(time.Hour), + TokenType: "Bearer", + } -func (f *FakeAuthInfoStore) GetAuthInfo(ctx context.Context, query *login.GetAuthInfoQuery) (*login.UserAuth, error) { - return f.ExpectedOAuth, f.ExpectedError -} + unexpiredTokenWithoutRefreshWithIDToken = unexpiredTokenWithoutRefresh.WithExtra(map[string]interface{}{ + "id_token": UNEXPIRED_ID_TOKEN, + }) -func (f *FakeAuthInfoStore) SetAuthInfo(ctx context.Context, cmd *login.SetAuthInfoCommand) error { - return f.ExpectedError -} - -func (f *FakeAuthInfoStore) UpdateAuthInfo(ctx context.Context, cmd *login.UpdateAuthInfoCommand) error { - f.ExpectedOAuth.OAuthAccessToken = cmd.OAuthToken.AccessToken - f.ExpectedOAuth.OAuthExpiry = cmd.OAuthToken.Expiry - f.ExpectedOAuth.OAuthTokenType = cmd.OAuthToken.TokenType - f.ExpectedOAuth.OAuthRefreshToken = cmd.OAuthToken.RefreshToken - return f.ExpectedError -} - -func (f *FakeAuthInfoStore) DeleteAuthInfo(ctx context.Context, cmd *login.DeleteAuthInfoCommand) error { - return f.ExpectedError -} - -func TestIntegration_TryTokenRefresh(t *testing.T) { - testutil.SkipIntegrationTestInShortMode(t) - - unexpiredToken := &oauth2.Token{ + unexpiredToken = &oauth2.Token{ AccessToken: "testaccess", RefreshToken: "testrefresh", Expiry: time.Now().Add(time.Hour), TokenType: "Bearer", } - unexpiredTokenWithIDToken := unexpiredToken.WithExtra(map[string]interface{}{ + + unexpiredTokenWithIDToken = unexpiredToken.WithExtra(map[string]interface{}{ "id_token": UNEXPIRED_ID_TOKEN, }) - expiredToken := &oauth2.Token{ + expiredToken = &oauth2.Token{ AccessToken: "testaccess", RefreshToken: "testrefresh", Expiry: time.Now().Add(-time.Hour), TokenType: "Bearer", } +) - type environment struct { - sessionService *authtest.MockUserAuthTokenService - authInfoService *authinfotest.FakeService - serverLock *serverlock.ServerLockService - socialConnector *socialtest.MockSocialConnector - socialService *socialtest.FakeSocialService +type environment struct { + sessionService *authtest.MockUserAuthTokenService + authInfoService *authinfotest.MockAuthInfoService + serverLock *serverlock.ServerLockService + socialConnector *socialtest.MockSocialConnector + socialService *socialtest.FakeSocialService - store db.DB - service *Service - } + store db.DB + service *Service +} + +func TestIntegration_TryTokenRefresh(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) type testCase struct { - desc string - identity identity.Requester - setup func(env *environment) - expectedToken *oauth2.Token - expectedErr error + desc string + identity identity.Requester + refreshMetadata *TokenRefreshMetadata + setup func(env *environment) + expectedToken *oauth2.Token + expectedErr error } userIdentity := &authn.Identity{ @@ -122,53 +108,74 @@ func TestIntegration_TryTokenRefresh(t *testing.T) { identity: &authn.Identity{ID: "invalid", Type: claims.TypeUser}, }, { - desc: "should skip token refresh if there's an unexpected error while looking up the user oauth entry, additionally, no error should be returned", - identity: userIdentity, - setup: func(env *environment) { - env.authInfoService.ExpectedError = errors.New("some error") - }, + desc: "should skip token refresh when no oauth provider was found", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.SAMLAuthModule}, }, { - desc: "should skip token refresh if the user doesn't have an oauth entry", - identity: userIdentity, + desc: "should skip token refresh when oauth provider token handling is disabled (UseRefreshToken is false)", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, setup: func(env *environment) { - env.authInfoService.ExpectedUserAuth = &login.UserAuth{ - AuthModule: login.SAMLAuthModule, - } - }, - }, - { - desc: "should skip token refresh when no oauth provider was found", - identity: userIdentity, - setup: func(env *environment) { - env.authInfoService.ExpectedUserAuth = &login.UserAuth{ - AuthModule: login.GenericOAuthModule, - } - }, - }, - { - desc: "should skip token refresh when oauth provider token handling is disabled (UseRefreshToken is false)", - identity: userIdentity, - setup: func(env *environment) { - env.authInfoService.ExpectedUserAuth = &login.UserAuth{ - AuthModule: login.GenericOAuthModule, - } env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ UseRefreshToken: false, } }, }, { - desc: "should skip token refresh when the token is still valid and no id token is present", - identity: userIdentity, + desc: "should skip token refresh if there's an unexpected error while looking up the user auth entry, additionally, no error should be returned", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, setup: func(env *environment) { - env.authInfoService.ExpectedUserAuth = &login.UserAuth{ + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(nil, assert.AnError).Once() + + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + }, + }, + { + desc: "should skip token refresh when there is no refresh token and the provider does not require one", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, + setup: func(env *environment) { + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: false, + } + }, + expectedToken: nil, + }, + { + desc: "should return error when there is no refresh token and provider requires one", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, + setup: func(env *environment) { + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + OAuthAccessToken: expiredToken.AccessToken, + OAuthRefreshToken: "", + OAuthExpiry: expiredToken.Expiry, + }, nil) + + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + }, + expectedToken: nil, + expectedErr: ErrNoRefreshTokenFound, + }, + { + desc: "should skip token refresh when the token is still valid and no id token is present", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, + setup: func(env *environment) { + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ AuthModule: login.GenericOAuthModule, OAuthAccessToken: unexpiredTokenWithIDToken.AccessToken, OAuthRefreshToken: unexpiredTokenWithIDToken.RefreshToken, OAuthExpiry: unexpiredTokenWithIDToken.Expiry, OAuthTokenType: unexpiredTokenWithIDToken.TokenType, - } + }, nil) env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ UseRefreshToken: true, @@ -177,17 +184,18 @@ func TestIntegration_TryTokenRefresh(t *testing.T) { expectedToken: unexpiredToken, }, { - desc: "should not refresh the tokens if access token or id token have not expired yet", - identity: userIdentity, + desc: "should not refresh the tokens if access token or id token have not expired yet", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, setup: func(env *environment) { - env.authInfoService.ExpectedUserAuth = &login.UserAuth{ + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ AuthModule: login.GenericOAuthModule, OAuthIdToken: UNEXPIRED_ID_TOKEN, OAuthAccessToken: unexpiredTokenWithIDToken.AccessToken, OAuthRefreshToken: unexpiredTokenWithIDToken.RefreshToken, OAuthExpiry: unexpiredTokenWithIDToken.Expiry, OAuthTokenType: unexpiredTokenWithIDToken.TokenType, - } + }, nil) env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ UseRefreshToken: true, @@ -196,33 +204,14 @@ func TestIntegration_TryTokenRefresh(t *testing.T) { expectedToken: unexpiredTokenWithIDToken, }, { - desc: "should skip token refresh when there is no refresh token", - identity: userIdentity, - setup: func(env *environment) { - env.authInfoService.ExpectedUserAuth = &login.UserAuth{ - AuthModule: login.GenericOAuthModule, - OAuthAccessToken: unexpiredTokenWithIDToken.AccessToken, - OAuthRefreshToken: "", - OAuthExpiry: unexpiredTokenWithIDToken.Expiry, - } - env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ - UseRefreshToken: true, - } - }, - expectedToken: &oauth2.Token{ - AccessToken: unexpiredTokenWithIDToken.AccessToken, - RefreshToken: "", - Expiry: unexpiredTokenWithIDToken.Expiry, - }, - }, - { - desc: "should do token refresh when the token is expired", - identity: userIdentity, + desc: "should do token refresh when the token is expired", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, setup: func(env *environment) { env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ UseRefreshToken: true, } - env.authInfoService.ExpectedUserAuth = &login.UserAuth{ + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ AuthModule: login.GenericOAuthModule, AuthId: "subject", UserId: 1, @@ -231,7 +220,16 @@ func TestIntegration_TryTokenRefresh(t *testing.T) { OAuthExpiry: expiredToken.Expiry, OAuthTokenType: expiredToken.TokenType, OAuthIdToken: EXPIRED_ID_TOKEN, - } + }, nil) + + env.authInfoService.On("UpdateAuthInfo", mock.Anything, mock.MatchedBy(func(cmd *login.UpdateAuthInfoCommand) bool { + return cmd.UserId == 1234 && cmd.AuthModule == login.GenericOAuthModule && + cmd.OAuthToken.AccessToken == unexpiredTokenWithIDToken.AccessToken && + cmd.OAuthToken.RefreshToken == unexpiredTokenWithIDToken.RefreshToken && + cmd.OAuthToken.Expiry.Equal(unexpiredTokenWithIDToken.Expiry) && + cmd.OAuthToken.TokenType == unexpiredTokenWithIDToken.TokenType + })).Return(nil).Once() + env.sessionService.On("UpdateExternalSession", mock.Anything, int64(1), mock.MatchedBy(verifyUpdateExternalSessionCommand(unexpiredTokenWithIDToken))).Return(nil).Once() env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(unexpiredTokenWithIDToken)).Once() @@ -239,13 +237,14 @@ func TestIntegration_TryTokenRefresh(t *testing.T) { expectedToken: unexpiredTokenWithIDToken, }, { - desc: "should refresh token when the id token is expired", - identity: &authn.Identity{ID: "1234", Type: claims.TypeUser, AuthenticatedBy: login.GenericOAuthModule}, + desc: "should refresh token when the id token is expired", + identity: &authn.Identity{ID: "1234", Type: claims.TypeUser, AuthenticatedBy: login.GenericOAuthModule}, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, setup: func(env *environment) { env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ UseRefreshToken: true, } - env.authInfoService.ExpectedUserAuth = &login.UserAuth{ + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ AuthModule: login.GenericOAuthModule, AuthId: "subject", UserId: 1, @@ -254,7 +253,16 @@ func TestIntegration_TryTokenRefresh(t *testing.T) { OAuthExpiry: unexpiredTokenWithIDToken.Expiry, OAuthTokenType: unexpiredTokenWithIDToken.TokenType, OAuthIdToken: EXPIRED_ID_TOKEN, - } + }, nil) + + env.authInfoService.On("UpdateAuthInfo", mock.Anything, mock.MatchedBy(func(cmd *login.UpdateAuthInfoCommand) bool { + return cmd.UserId == 1234 && cmd.AuthModule == login.GenericOAuthModule && + cmd.OAuthToken.AccessToken == unexpiredTokenWithIDToken.AccessToken && + cmd.OAuthToken.RefreshToken == unexpiredTokenWithIDToken.RefreshToken && + cmd.OAuthToken.Expiry.Equal(unexpiredTokenWithIDToken.Expiry) && + cmd.OAuthToken.TokenType == unexpiredTokenWithIDToken.TokenType + })).Return(nil).Once() + env.sessionService.On("UpdateExternalSession", mock.Anything, int64(1), mock.MatchedBy(verifyUpdateExternalSessionCommand(unexpiredTokenWithIDToken))).Return(nil).Once() env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(unexpiredTokenWithIDToken)).Once() @@ -262,22 +270,14 @@ func TestIntegration_TryTokenRefresh(t *testing.T) { expectedToken: unexpiredTokenWithIDToken, }, { - desc: "should return ErrRetriesExhausted when lock cannot be acquired", - identity: &authn.Identity{ID: "1234", Type: claims.TypeUser, AuthenticatedBy: login.GenericOAuthModule}, + desc: "should return ErrRetriesExhausted when lock cannot be acquired", + identity: &authn.Identity{ID: "1234", Type: claims.TypeUser, AuthenticatedBy: login.GenericOAuthModule}, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, setup: func(env *environment) { env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ UseRefreshToken: true, } - env.authInfoService.ExpectedUserAuth = &login.UserAuth{ - AuthModule: login.GenericOAuthModule, - AuthId: "subject", - UserId: 1234, - OAuthAccessToken: unexpiredTokenWithIDToken.AccessToken, - OAuthRefreshToken: unexpiredTokenWithIDToken.RefreshToken, - OAuthExpiry: unexpiredTokenWithIDToken.Expiry, - OAuthTokenType: unexpiredTokenWithIDToken.TokenType, - OAuthIdToken: EXPIRED_ID_TOKEN, - } + _ = env.store.WithDbSession(context.Background(), func(sess *db.Session) error { _, err := sess.Exec(`INSERT INTO server_lock (operation_uid, last_execution, version) VALUES (?, ?, ?)`, "oauth-refresh-token-1234", time.Now().Add(2*time.Second).Unix(), 0) return err @@ -285,6 +285,42 @@ func TestIntegration_TryTokenRefresh(t *testing.T) { }, expectedErr: ErrRetriesExhausted, }, + { + desc: "should be able to refresh token when the caller is render service and the access token is expired", + identity: &authn.Identity{ + AuthenticatedBy: login.RenderModule, + ID: "1", + Type: claims.TypeUser, + }, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, + setup: func(env *environment) { + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.MatchedBy(func(query *login.GetAuthInfoQuery) bool { + return query.UserId == 1 + })).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + AuthId: "subject", + UserId: 1, + OAuthAccessToken: expiredToken.AccessToken, + OAuthRefreshToken: expiredToken.RefreshToken, + OAuthExpiry: expiredToken.Expiry, + OAuthTokenType: expiredToken.TokenType, + OAuthIdToken: EXPIRED_ID_TOKEN, + }, nil).Once() + env.authInfoService.On("UpdateAuthInfo", mock.Anything, mock.MatchedBy(func(cmd *login.UpdateAuthInfoCommand) bool { + return cmd.UserId == 1 && cmd.AuthModule == login.GenericOAuthModule && + cmd.OAuthToken.AccessToken == unexpiredTokenWithIDToken.AccessToken && + cmd.OAuthToken.RefreshToken == unexpiredTokenWithIDToken.RefreshToken && + cmd.OAuthToken.Expiry.Equal(unexpiredTokenWithIDToken.Expiry) && + cmd.OAuthToken.TokenType == unexpiredTokenWithIDToken.TokenType + })).Return(nil).Once() + env.sessionService.On("UpdateExternalSession", mock.Anything, int64(1), mock.MatchedBy(verifyUpdateExternalSessionCommand(unexpiredTokenWithIDToken))).Return(nil).Once() + env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(unexpiredTokenWithIDToken)).Once() + }, + expectedToken: unexpiredTokenWithIDToken, + }, } for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { @@ -294,7 +330,7 @@ func TestIntegration_TryTokenRefresh(t *testing.T) { env := environment{ sessionService: authtest.NewMockUserAuthTokenService(t), - authInfoService: &authinfotest.FakeService{}, + authInfoService: authinfotest.NewMockAuthInfoService(t), serverLock: serverlock.ProvideService(store, tracing.InitializeTracerForTest()), socialConnector: socialConnector, socialService: &socialtest.FakeSocialService{ @@ -319,7 +355,7 @@ func TestIntegration_TryTokenRefresh(t *testing.T) { ) // token refresh - actualToken, err := env.service.TryTokenRefresh(context.Background(), tt.identity, &usertoken.UserToken{ExternalSessionId: 1}) + actualToken, err := env.service.TryTokenRefresh(context.Background(), tt.identity, tt.refreshMetadata) if tt.expectedErr != nil { assert.ErrorIs(t, err, tt.expectedErr) @@ -347,45 +383,19 @@ func TestIntegration_TryTokenRefresh(t *testing.T) { func TestIntegration_TryTokenRefresh_WithExternalSessions(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) - unexpiredToken := &oauth2.Token{ - AccessToken: "testaccess", - RefreshToken: "testrefresh", - Expiry: time.Now().Add(time.Hour), - TokenType: "Bearer", - } - unexpiredTokenWithIDToken := unexpiredToken.WithExtra(map[string]interface{}{ - "id_token": UNEXPIRED_ID_TOKEN, - }) - - expiredToken := &oauth2.Token{ - AccessToken: "testaccess", - RefreshToken: "testrefresh", - Expiry: time.Now().Add(-time.Hour), - TokenType: "Bearer", - } - userIdentity := &authn.Identity{ AuthenticatedBy: login.GenericOAuthModule, ID: "1234", Type: claims.TypeUser, } - type environment struct { - sessionService *authtest.MockUserAuthTokenService - serverLock *serverlock.ServerLockService - socialConnector *socialtest.MockSocialConnector - socialService *socialtest.FakeSocialService - - store db.DB - service *Service - } - type testCase struct { - desc string - identity identity.Requester - setup func(env *environment) - expectedToken *oauth2.Token - expectedErr error + desc string + identity identity.Requester + refreshMetadata *TokenRefreshMetadata + setup func(env *environment) + expectedToken *oauth2.Token + expectedErr error } tests := []testCase{ @@ -401,8 +411,14 @@ func TestIntegration_TryTokenRefresh_WithExternalSessions(t *testing.T) { identity: &authn.Identity{ID: "invalid", Type: claims.TypeUser}, }, { - desc: "should skip token refresh if there's an unexpected error while looking up the user oauth entry, additionally, no error should be returned", - identity: userIdentity, + desc: "should skip token refresh when no oauth provider was found", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.SAMLAuthModule}, + }, + { + desc: "should skip token refresh if there's an unexpected error while looking up the external session entry, additionally, no error should be returned", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, setup: func(env *environment) { env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(nil, assert.AnError).Once() @@ -411,10 +427,11 @@ func TestIntegration_TryTokenRefresh_WithExternalSessions(t *testing.T) { } }, }, - // Kinda impossible to happen, can only happen after the feature is enabled and logged in users don't have their external sessions set + // Edge case, can only happen after the feature is enabled and logged in users don't have their external sessions set { - desc: "should skip token refresh if the user doesn't have an external session", - identity: userIdentity, + desc: "should skip token refresh if the user doesn't have an external session", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, setup: func(env *environment) { env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(nil, auth.ErrExternalSessionNotFound).Once() @@ -424,15 +441,17 @@ func TestIntegration_TryTokenRefresh_WithExternalSessions(t *testing.T) { }, }, { - desc: "should skip token refresh when no oauth provider was found", - identity: userIdentity, + desc: "should skip token refresh when no oauth provider was found", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, setup: func(env *environment) { env.socialService.ExpectedAuthInfoProvider = nil }, }, { - desc: "should skip token refresh when oauth provider token handling is disabled (UseRefreshToken is false)", - identity: userIdentity, + desc: "should skip token refresh when oauth provider token handling is disabled (UseRefreshToken is false)", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, setup: func(env *environment) { env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ UseRefreshToken: false, @@ -440,8 +459,9 @@ func TestIntegration_TryTokenRefresh_WithExternalSessions(t *testing.T) { }, }, { - desc: "should skip token refresh when the token is still valid and no id token is present", - identity: userIdentity, + desc: "should skip token refresh when the token is still valid and no id token is present", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, setup: func(env *environment) { env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ ID: 1, @@ -458,8 +478,40 @@ func TestIntegration_TryTokenRefresh_WithExternalSessions(t *testing.T) { expectedToken: unexpiredToken, }, { - desc: "should not do token refresh if access token or id token have not expired yet", - identity: userIdentity, + desc: "should skip token refresh when there is no refresh token and the provider does not require one", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, + setup: func(env *environment) { + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: false, + } + }, + expectedToken: nil, + }, + { + desc: "should return error when there is no refresh token and provider requires one", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, + setup: func(env *environment) { + env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ + ID: 1, + UserID: 1, + AccessToken: expiredToken.AccessToken, + RefreshToken: "", + ExpiresAt: expiredToken.Expiry, + }, nil).Once() + + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + }, + expectedToken: nil, + expectedErr: ErrNoRefreshTokenFound, + }, + { + desc: "should not do token refresh if access token or id token have not expired yet", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, setup: func(env *environment) { env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ ID: 1, @@ -477,42 +529,17 @@ func TestIntegration_TryTokenRefresh_WithExternalSessions(t *testing.T) { expectedToken: unexpiredTokenWithIDToken, }, { - desc: "should skip token refresh when there is no refresh token", - identity: userIdentity, - setup: func(env *environment) { - env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ - ID: 1, - UserID: 1, - AccessToken: unexpiredTokenWithIDToken.AccessToken, - RefreshToken: "", - ExpiresAt: unexpiredTokenWithIDToken.Expiry, - }, nil).Once() - - env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ - UseRefreshToken: true, - } - }, - expectedToken: &oauth2.Token{ - AccessToken: unexpiredTokenWithIDToken.AccessToken, - RefreshToken: "", - Expiry: unexpiredTokenWithIDToken.Expiry, - }, - }, - { - desc: "should refresh token when the access token is expired", - identity: &authn.Identity{ - AuthenticatedBy: login.GenericOAuthModule, - ID: "1", - Type: claims.TypeUser, - }, + desc: "should refresh token when the access token is expired", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, setup: func(env *environment) { env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ ID: 1, UserID: 1, AccessToken: expiredToken.AccessToken, - IDToken: UNEXPIRED_ID_TOKEN, RefreshToken: expiredToken.RefreshToken, ExpiresAt: expiredToken.Expiry, + IDToken: UNEXPIRED_ID_TOKEN, }, nil).Once() env.sessionService.On("UpdateExternalSession", mock.Anything, int64(1), mock.MatchedBy(verifyUpdateExternalSessionCommand(unexpiredTokenWithIDToken))).Return(nil).Once() @@ -526,12 +553,13 @@ func TestIntegration_TryTokenRefresh_WithExternalSessions(t *testing.T) { expectedToken: unexpiredTokenWithIDToken, }, { - desc: "should refresh token when the id token is expired", - identity: userIdentity, + desc: "should refresh token when the id token is expired", + identity: userIdentity, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, setup: func(env *environment) { env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ ID: 1, - UserID: 1, + UserID: 1234, AccessToken: unexpiredTokenWithIDToken.AccessToken, RefreshToken: unexpiredTokenWithIDToken.RefreshToken, ExpiresAt: unexpiredTokenWithIDToken.Expiry, @@ -549,8 +577,38 @@ func TestIntegration_TryTokenRefresh_WithExternalSessions(t *testing.T) { expectedToken: unexpiredTokenWithIDToken, }, { - desc: "should return ErrRetriesExhausted when lock cannot be acquired", - identity: &authn.Identity{ID: "1234", Type: claims.TypeUser, AuthenticatedBy: login.GenericOAuthModule}, + desc: "should be able to refresh token when the caller is render service and the access token is expired", + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, + identity: &authn.Identity{ + AuthenticatedBy: login.RenderModule, + ID: "1", + Type: claims.TypeUser, + }, + setup: func(env *environment) { + env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ + ID: 1, + UserID: 1, + AuthModule: login.RenderModule, + AccessToken: expiredToken.AccessToken, + RefreshToken: expiredToken.RefreshToken, + ExpiresAt: expiredToken.Expiry, + IDToken: UNEXPIRED_ID_TOKEN, + }, nil).Once() + + env.sessionService.On("UpdateExternalSession", mock.Anything, int64(1), mock.MatchedBy(verifyUpdateExternalSessionCommand(unexpiredTokenWithIDToken))).Return(nil).Once() + + env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(unexpiredTokenWithIDToken)).Once() + + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + }, + expectedToken: unexpiredTokenWithIDToken, + }, + { + desc: "should return ErrRetriesExhausted when lock cannot be acquired", + identity: &authn.Identity{ID: "1234", Type: claims.TypeUser, AuthenticatedBy: login.GenericOAuthModule}, + refreshMetadata: &TokenRefreshMetadata{ExternalSessionID: 1, AuthModule: login.GenericOAuthModule}, setup: func(env *environment) { env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ UseRefreshToken: true, @@ -572,6 +630,7 @@ func TestIntegration_TryTokenRefresh_WithExternalSessions(t *testing.T) { env := environment{ sessionService: authtest.NewMockUserAuthTokenService(t), + authInfoService: authinfotest.NewMockAuthInfoService(t), serverLock: serverlock.ProvideService(store, tracing.InitializeTracerForTest()), socialConnector: socialConnector, socialService: &socialtest.FakeSocialService{ @@ -586,7 +645,7 @@ func TestIntegration_TryTokenRefresh_WithExternalSessions(t *testing.T) { env.service = ProvideService( env.socialService, - nil, + env.authInfoService, setting.NewCfg(), prometheus.NewRegistry(), env.serverLock, @@ -596,7 +655,7 @@ func TestIntegration_TryTokenRefresh_WithExternalSessions(t *testing.T) { ) // token refresh - actualToken, err := env.service.TryTokenRefresh(context.Background(), tt.identity, &usertoken.UserToken{ExternalSessionId: 1}) + actualToken, err := env.service.TryTokenRefresh(context.Background(), tt.identity, tt.refreshMetadata) if tt.expectedErr != nil { assert.ErrorIs(t, err, tt.expectedErr) @@ -635,34 +694,44 @@ func verifyUpdateExternalSessionCommand(token *oauth2.Token) func(*auth.UpdateEx func TestOAuthTokenSync_needTokenRefresh(t *testing.T) { tests := []struct { name string - usr *login.UserAuth + token *oauth2.Token expectedTokenRefreshFlag bool expectedTokenDuration time.Duration }{ { - name: "should not need token refresh when token has no expiration date", - usr: &login.UserAuth{}, + name: "should not need token refresh when token has no expiration date", + token: &oauth2.Token{ + AccessToken: "some_access_token", + Expiry: time.Time{}, + }, expectedTokenRefreshFlag: false, }, { name: "should not need token refresh with an invalid jwt token that might result in an error when parsing", - usr: &login.UserAuth{ - OAuthIdToken: "invalid_jwt_format", - }, + token: (&oauth2.Token{ + AccessToken: "some_access_token", + }).WithExtra(map[string]any{"id_token": "invalid_jwt_format"}), expectedTokenRefreshFlag: false, }, { - name: "should flag token refresh with id token is expired", - usr: &login.UserAuth{ - OAuthIdToken: EXPIRED_ID_TOKEN, + name: "should flag token refresh when access token is empty", + token: &oauth2.Token{ + AccessToken: "", }, expectedTokenRefreshFlag: true, + }, + { + name: "should flag token refresh with id token is expired", + token: (&oauth2.Token{ + AccessToken: "some_access_token"}).WithExtra(map[string]any{"id_token": EXPIRED_ID_TOKEN}), + expectedTokenRefreshFlag: true, expectedTokenDuration: time.Second, }, { name: "should flag token refresh when expiry date is zero", - usr: &login.UserAuth{ - OAuthExpiry: time.Unix(0, 0), + token: &oauth2.Token{ + AccessToken: "some_access_token", + Expiry: time.Unix(0, 0), }, expectedTokenRefreshFlag: true, expectedTokenDuration: time.Second, @@ -670,10 +739,686 @@ func TestOAuthTokenSync_needTokenRefresh(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - token := buildOAuthTokenFromAuthInfo(tt.usr) - needsTokenRefresh := needTokenRefresh(context.Background(), token) + needsTokenRefresh := needTokenRefresh(context.Background(), tt.token) assert.Equal(t, tt.expectedTokenRefreshFlag, needsTokenRefresh) }) } } + +func TestIntegration_GetCurrentOAuthToken(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + type testCase struct { + desc string + identity identity.Requester + sessionToken *auth.UserToken + setup func(env *environment) + expectedToken *oauth2.Token + } + + userIdentity := &authn.Identity{ + AuthenticatedBy: login.GenericOAuthModule, + ID: "1234", + Type: claims.TypeUser, + } + + tests := []testCase{ + { + desc: "should return nil when identity is nil", + identity: nil, + expectedToken: nil, + }, + { + desc: "should return nil when identity is not a user", + identity: &authn.Identity{ID: "1", Type: claims.TypeServiceAccount}, + expectedToken: nil, + }, + { + desc: "should refresh token for render service user", + identity: &authn.Identity{ID: "1", Type: claims.TypeUser, AuthenticatedBy: login.RenderModule}, + setup: func(env *environment) { + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + AuthId: "subject", + UserId: 1, + OAuthAccessToken: expiredToken.AccessToken, + OAuthRefreshToken: expiredToken.RefreshToken, + OAuthExpiry: expiredToken.Expiry, + OAuthTokenType: expiredToken.TokenType, + OAuthIdToken: EXPIRED_ID_TOKEN, + }, nil) + + env.sessionService.On("FindExternalSessions", mock.Anything, &auth.ListExternalSessionQuery{UserID: 1}).Return([]*auth.ExternalSession{ + { + ID: 1, + UserID: 1, + AuthModule: login.GenericOAuthModule, + AccessToken: expiredToken.AccessToken, + RefreshToken: expiredToken.RefreshToken, + ExpiresAt: expiredToken.Expiry, + IDToken: EXPIRED_ID_TOKEN, + }, + }, nil).Once() + + env.authInfoService.On("UpdateAuthInfo", mock.Anything, mock.MatchedBy(func(cmd *login.UpdateAuthInfoCommand) bool { + return cmd.UserId == 1 && cmd.AuthModule == login.GenericOAuthModule && + cmd.OAuthToken.AccessToken == unexpiredTokenWithIDToken.AccessToken && + cmd.OAuthToken.RefreshToken == unexpiredTokenWithIDToken.RefreshToken && + cmd.OAuthToken.Expiry.Equal(unexpiredTokenWithIDToken.Expiry) && + cmd.OAuthToken.TokenType == unexpiredTokenWithIDToken.TokenType + })).Return(nil).Once() + + env.sessionService.On("UpdateExternalSession", mock.Anything, int64(1), mock.MatchedBy(verifyUpdateExternalSessionCommand(unexpiredTokenWithIDToken))).Return(nil).Once() + + env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(unexpiredTokenWithIDToken)).Once() + }, + expectedToken: unexpiredTokenWithIDToken, + }, + { + desc: "should refresh token for render service user with multiple external sessions", + identity: &authn.Identity{ID: "1", Type: claims.TypeUser, AuthenticatedBy: login.RenderModule}, + setup: func(env *environment) { + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + AuthId: "subject", + UserId: 1, + OAuthAccessToken: expiredToken.AccessToken, + OAuthRefreshToken: expiredToken.RefreshToken, + OAuthExpiry: expiredToken.Expiry, + OAuthTokenType: expiredToken.TokenType, + OAuthIdToken: EXPIRED_ID_TOKEN, + }, nil) + + // Return multiple external sessions, the most recent one is returned first by the query + env.sessionService.On("FindExternalSessions", mock.Anything, &auth.ListExternalSessionQuery{UserID: 1}).Return([]*auth.ExternalSession{ + { + ID: 2, // newer session + UserID: 1, + AuthModule: login.GenericOAuthModule, + AccessToken: expiredToken.AccessToken, + RefreshToken: expiredToken.RefreshToken, + ExpiresAt: expiredToken.Expiry, + IDToken: EXPIRED_ID_TOKEN, + }, + { + ID: 1, // older session + UserID: 1, + AuthModule: login.GenericOAuthModule, + }}, nil).Once() + + env.authInfoService.On("UpdateAuthInfo", mock.Anything, mock.MatchedBy(func(cmd *login.UpdateAuthInfoCommand) bool { + return cmd.UserId == 1 && cmd.AuthModule == login.GenericOAuthModule && + cmd.OAuthToken.AccessToken == unexpiredTokenWithIDToken.AccessToken && + cmd.OAuthToken.RefreshToken == unexpiredTokenWithIDToken.RefreshToken && + cmd.OAuthToken.Expiry.Equal(unexpiredTokenWithIDToken.Expiry) && + cmd.OAuthToken.TokenType == unexpiredTokenWithIDToken.TokenType + })).Return(nil).Once() + + env.sessionService.On("UpdateExternalSession", mock.Anything, int64(2), mock.MatchedBy(verifyUpdateExternalSessionCommand(unexpiredTokenWithIDToken))).Return(nil).Once() + + env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(unexpiredTokenWithIDToken)).Once() + }, + expectedToken: unexpiredTokenWithIDToken, + }, + { + desc: "should skip token refresh when the token is still valid and no id token is present", + identity: userIdentity, + sessionToken: &auth.UserToken{ExternalSessionId: 1}, + setup: func(env *environment) { + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + OAuthAccessToken: unexpiredToken.AccessToken, + OAuthRefreshToken: unexpiredToken.RefreshToken, + OAuthExpiry: unexpiredToken.Expiry, + OAuthTokenType: unexpiredToken.TokenType, + }, nil) + + env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ + ID: 1, + UserID: 1234, + AuthModule: login.GenericOAuthModule, + AccessToken: unexpiredToken.AccessToken, + RefreshToken: unexpiredToken.RefreshToken, + ExpiresAt: unexpiredToken.Expiry, + }, nil).Once() + + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + }, + expectedToken: unexpiredToken, + }, + { + desc: "should not do token refresh if access token or id token have not expired yet", + identity: userIdentity, + sessionToken: &auth.UserToken{ExternalSessionId: 1}, + setup: func(env *environment) { + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + OAuthIdToken: UNEXPIRED_ID_TOKEN, + OAuthAccessToken: unexpiredTokenWithIDToken.AccessToken, + OAuthRefreshToken: unexpiredTokenWithIDToken.RefreshToken, + OAuthExpiry: unexpiredTokenWithIDToken.Expiry, + OAuthTokenType: unexpiredTokenWithIDToken.TokenType, + }, nil) + + env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ + ID: 1, + UserID: 1234, + AuthModule: login.GenericOAuthModule, + AccessToken: unexpiredToken.AccessToken, + RefreshToken: unexpiredToken.RefreshToken, + ExpiresAt: unexpiredToken.Expiry, + }, nil).Once() + + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + }, + expectedToken: unexpiredTokenWithIDToken, + }, + { + desc: "should return the unexpired access and id token when token refresh is disabled", + identity: userIdentity, + sessionToken: &auth.UserToken{ExternalSessionId: 1}, + setup: func(env *environment) { + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + OAuthIdToken: UNEXPIRED_ID_TOKEN, + OAuthAccessToken: unexpiredTokenWithIDToken.AccessToken, + OAuthExpiry: unexpiredTokenWithIDToken.Expiry, + OAuthTokenType: unexpiredTokenWithIDToken.TokenType, + }, nil) + + env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ + ID: 1, + UserID: 1234, + AuthModule: login.GenericOAuthModule, + AccessToken: unexpiredToken.AccessToken, + ExpiresAt: unexpiredToken.Expiry, + }, nil).Once() + + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: false, + } + }, + expectedToken: unexpiredTokenWithoutRefreshWithIDToken, + }, + // Edge case, can only happen after the feature is enabled and logged in users don't have their external sessions set, + { + desc: "should refresh token when the access token is expired and the external session was not found", + identity: userIdentity, + sessionToken: &auth.UserToken{ExternalSessionId: 1}, + setup: func(env *environment) { + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + AuthId: "subject", + UserId: 1234, + OAuthAccessToken: expiredToken.AccessToken, + OAuthRefreshToken: expiredToken.RefreshToken, + OAuthExpiry: expiredToken.Expiry, + OAuthTokenType: expiredToken.TokenType, + OAuthIdToken: EXPIRED_ID_TOKEN, + }, nil) + + env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(nil, auth.ErrExternalSessionNotFound).Once() + + env.authInfoService.On("UpdateAuthInfo", mock.Anything, mock.MatchedBy(func(cmd *login.UpdateAuthInfoCommand) bool { + return cmd.UserId == 1234 && cmd.AuthModule == login.GenericOAuthModule && + cmd.OAuthToken.AccessToken == unexpiredTokenWithIDToken.AccessToken && + cmd.OAuthToken.RefreshToken == unexpiredTokenWithIDToken.RefreshToken && + cmd.OAuthToken.Expiry.Equal(unexpiredTokenWithIDToken.Expiry) && + cmd.OAuthToken.TokenType == unexpiredTokenWithIDToken.TokenType + })).Return(nil).Once() + + env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(unexpiredTokenWithIDToken)).Once() + }, + expectedToken: unexpiredTokenWithIDToken, + }, + { + desc: "should refresh token when the access token is expired", + identity: userIdentity, + sessionToken: &auth.UserToken{ExternalSessionId: 1}, + setup: func(env *environment) { + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + AuthId: "subject", + UserId: 1234, + OAuthAccessToken: expiredToken.AccessToken, + OAuthRefreshToken: expiredToken.RefreshToken, + OAuthExpiry: expiredToken.Expiry, + OAuthTokenType: expiredToken.TokenType, + OAuthIdToken: EXPIRED_ID_TOKEN, + }, nil) + + env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ + ID: 1, + UserID: 1234, + AccessToken: expiredToken.AccessToken, + RefreshToken: expiredToken.RefreshToken, + ExpiresAt: expiredToken.Expiry, + IDToken: UNEXPIRED_ID_TOKEN, + }, nil).Once() + + env.authInfoService.On("UpdateAuthInfo", mock.Anything, mock.MatchedBy(func(cmd *login.UpdateAuthInfoCommand) bool { + return cmd.UserId == 1234 && cmd.AuthModule == login.GenericOAuthModule && + cmd.OAuthToken.AccessToken == unexpiredTokenWithIDToken.AccessToken && + cmd.OAuthToken.RefreshToken == unexpiredTokenWithIDToken.RefreshToken && + cmd.OAuthToken.Expiry.Equal(unexpiredTokenWithIDToken.Expiry) && + cmd.OAuthToken.TokenType == unexpiredTokenWithIDToken.TokenType + })).Return(nil).Once() + + env.sessionService.On("UpdateExternalSession", mock.Anything, int64(1), mock.MatchedBy(verifyUpdateExternalSessionCommand(unexpiredTokenWithIDToken))).Return(nil).Once() + + env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(unexpiredTokenWithIDToken)).Once() + }, + expectedToken: unexpiredTokenWithIDToken, + }, + { + desc: "should refresh token when the id token is expired", + identity: userIdentity, + sessionToken: &auth.UserToken{ExternalSessionId: 1}, + setup: func(env *environment) { + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + AuthId: "subject", + UserId: 1234, + OAuthAccessToken: unexpiredTokenWithIDToken.AccessToken, + OAuthRefreshToken: unexpiredTokenWithIDToken.RefreshToken, + OAuthExpiry: unexpiredTokenWithIDToken.Expiry, + OAuthTokenType: unexpiredTokenWithIDToken.TokenType, + OAuthIdToken: EXPIRED_ID_TOKEN, + }, nil) + + env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ + ID: 1, + UserID: 1234, + AuthModule: login.GenericOAuthModule, + AccessToken: unexpiredToken.AccessToken, + RefreshToken: unexpiredToken.RefreshToken, + ExpiresAt: unexpiredToken.Expiry, + IDToken: EXPIRED_ID_TOKEN, + }, nil).Once() + + env.authInfoService.On("UpdateAuthInfo", mock.Anything, mock.MatchedBy(func(cmd *login.UpdateAuthInfoCommand) bool { + return cmd.UserId == 1234 && cmd.AuthModule == login.GenericOAuthModule && + cmd.OAuthToken.AccessToken == unexpiredTokenWithIDToken.AccessToken && + cmd.OAuthToken.RefreshToken == unexpiredTokenWithIDToken.RefreshToken && + cmd.OAuthToken.Expiry.Equal(unexpiredTokenWithIDToken.Expiry) && + cmd.OAuthToken.TokenType == unexpiredTokenWithIDToken.TokenType + })).Return(nil).Once() + + env.sessionService.On("UpdateExternalSession", mock.Anything, int64(1), mock.MatchedBy(verifyUpdateExternalSessionCommand(unexpiredTokenWithIDToken))).Return(nil).Once() + + env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(unexpiredTokenWithIDToken)).Once() + }, + expectedToken: unexpiredTokenWithIDToken, + }, + } + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + socialConnector := socialtest.NewMockSocialConnector(t) + store := db.InitTestDB(t) + features := featuremgmt.WithFeatures() + + env := environment{ + sessionService: authtest.NewMockUserAuthTokenService(t), + authInfoService: authinfotest.NewMockAuthInfoService(t), + serverLock: serverlock.ProvideService(store, tracing.InitializeTracerForTest()), + socialConnector: socialConnector, + socialService: &socialtest.FakeSocialService{ + ExpectedConnector: socialConnector, + }, + store: store, + } + + if tt.setup != nil { + tt.setup(&env) + } + + env.service = ProvideService( + env.socialService, + env.authInfoService, + setting.NewCfg(), + prometheus.NewRegistry(), + env.serverLock, + tracing.InitializeTracerForTest(), + env.sessionService, + features, + ) + + actualToken := env.service.GetCurrentOAuthToken(context.Background(), tt.identity, tt.sessionToken) + + if tt.expectedToken == nil { + assert.Nil(t, actualToken) + return + } + + assert.NotNil(t, actualToken) + assert.Equal(t, tt.expectedToken.AccessToken, actualToken.AccessToken) + assert.Equal(t, tt.expectedToken.RefreshToken, actualToken.RefreshToken) + assert.WithinDuration(t, tt.expectedToken.Expiry, actualToken.Expiry, time.Second) + assert.Equal(t, tt.expectedToken.TokenType, actualToken.TokenType) + if tt.expectedToken.Extra("id_token") != nil { + assert.Equal(t, tt.expectedToken.Extra("id_token"), actualToken.Extra("id_token")) + } else { + assert.Nil(t, actualToken.Extra("id_token")) + } + }) + } +} + +func TestIntegration_GetCurrentOAuthToken_WithExternalSessions(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + type testCase struct { + desc string + identity identity.Requester + sessionToken *auth.UserToken + setup func(env *environment) + expectedToken *oauth2.Token + } + + userIdentity := &authn.Identity{ + AuthenticatedBy: login.GenericOAuthModule, + ID: "1234", + Type: claims.TypeUser, + } + + tests := []testCase{ + { + desc: "should return nil when identity is nil", + identity: nil, + expectedToken: nil, + }, + { + desc: "should return nil when identity is not a user", + identity: &authn.Identity{ID: "1", Type: claims.TypeServiceAccount}, + expectedToken: nil, + }, + { + desc: "should refresh token for render service user", + identity: &authn.Identity{ID: "1", Type: claims.TypeUser, AuthenticatedBy: login.RenderModule}, + setup: func(env *environment) { + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + }, nil) + + env.sessionService.On("GetExternalSession", mock.Anything, int64(3)).Return(&auth.ExternalSession{ + ID: 3, + UserID: 1, + AuthModule: login.GenericOAuthModule, + AccessToken: expiredToken.AccessToken, + RefreshToken: expiredToken.RefreshToken, + ExpiresAt: expiredToken.Expiry, + IDToken: EXPIRED_ID_TOKEN, + }, nil).Once() + + env.sessionService.On("FindExternalSessions", mock.Anything, &auth.ListExternalSessionQuery{UserID: 1}).Return([]*auth.ExternalSession{ + { + ID: 3, + UserID: 1, + AuthModule: login.GenericOAuthModule, + AccessToken: expiredToken.AccessToken, + RefreshToken: expiredToken.RefreshToken, + ExpiresAt: expiredToken.Expiry, + IDToken: EXPIRED_ID_TOKEN, + }, + }, nil).Once() + + env.sessionService.On("UpdateExternalSession", mock.Anything, int64(3), mock.MatchedBy(verifyUpdateExternalSessionCommand(unexpiredTokenWithIDToken))).Return(nil).Once() + + env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(unexpiredTokenWithIDToken)).Once() + }, + expectedToken: unexpiredTokenWithIDToken, + }, + { + desc: "should refresh token for render service user with multiple external sessions", + identity: &authn.Identity{ID: "1", Type: claims.TypeUser, AuthenticatedBy: login.RenderModule}, + setup: func(env *environment) { + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + AuthId: "subject", + UserId: 1, + OAuthAccessToken: expiredToken.AccessToken, + OAuthRefreshToken: expiredToken.RefreshToken, + OAuthExpiry: expiredToken.Expiry, + OAuthTokenType: expiredToken.TokenType, + OAuthIdToken: EXPIRED_ID_TOKEN, + }, nil) + + // Return multiple external sessions, the most recent one is returned first by the query + env.sessionService.On("FindExternalSessions", mock.Anything, &auth.ListExternalSessionQuery{UserID: 1}).Return([]*auth.ExternalSession{ + { + ID: 2, // newer session + UserID: 1, + AuthModule: login.GenericOAuthModule, + AccessToken: expiredToken.AccessToken, + RefreshToken: expiredToken.RefreshToken, + ExpiresAt: expiredToken.Expiry, + IDToken: EXPIRED_ID_TOKEN, + }, + { + ID: 1, // older session + UserID: 1, + AuthModule: login.GenericOAuthModule, + }}, nil).Once() + + env.sessionService.On("GetExternalSession", mock.Anything, int64(2)).Return(&auth.ExternalSession{ + ID: 2, + UserID: 1, + AuthModule: login.GenericOAuthModule, + AccessToken: expiredToken.AccessToken, + RefreshToken: expiredToken.RefreshToken, + ExpiresAt: expiredToken.Expiry, + IDToken: EXPIRED_ID_TOKEN, + }, nil).Once() + + env.sessionService.On("UpdateExternalSession", mock.Anything, int64(2), mock.MatchedBy(verifyUpdateExternalSessionCommand(unexpiredTokenWithIDToken))).Return(nil).Once() + + env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(unexpiredTokenWithIDToken)).Once() + }, + expectedToken: unexpiredTokenWithIDToken, + }, + { + desc: "should skip token refresh when the token is still valid and no id token is present", + identity: userIdentity, + sessionToken: &auth.UserToken{ExternalSessionId: 1}, + setup: func(env *environment) { + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + }, nil) + + env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ + ID: 1, + UserID: 1234, + AuthModule: login.GenericOAuthModule, + AccessToken: unexpiredToken.AccessToken, + RefreshToken: unexpiredToken.RefreshToken, + ExpiresAt: unexpiredToken.Expiry, + }, nil).Once() + + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + }, + expectedToken: unexpiredToken, + }, + { + desc: "should return the unexpired access and id token when token refresh is disabled", + identity: userIdentity, + sessionToken: &auth.UserToken{ExternalSessionId: 1}, + setup: func(env *environment) { + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + }, nil) + + env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ + ID: 1, + UserID: 1234, + AuthModule: login.GenericOAuthModule, + AccessToken: unexpiredTokenWithIDToken.AccessToken, + ExpiresAt: unexpiredTokenWithIDToken.Expiry, + IDToken: UNEXPIRED_ID_TOKEN, + }, nil).Once() + + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: false, + } + }, + expectedToken: unexpiredTokenWithoutRefreshWithIDToken, + }, + { + desc: "should not do token refresh if access token or id token have not expired yet", + identity: userIdentity, + sessionToken: &auth.UserToken{ExternalSessionId: 1}, + setup: func(env *environment) { + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + }, nil) + + env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ + ID: 1, + UserID: 1234, + AuthModule: login.GenericOAuthModule, + AccessToken: unexpiredTokenWithIDToken.AccessToken, + RefreshToken: unexpiredTokenWithIDToken.RefreshToken, + ExpiresAt: unexpiredTokenWithIDToken.Expiry, + IDToken: UNEXPIRED_ID_TOKEN, + }, nil).Once() + + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + }, + expectedToken: unexpiredTokenWithIDToken, + }, + { + desc: "should refresh token when the access token is expired", + identity: userIdentity, + sessionToken: &auth.UserToken{ExternalSessionId: 1}, + setup: func(env *environment) { + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + }, nil) + + env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ + ID: 1, + UserID: 1, + AccessToken: expiredToken.AccessToken, + RefreshToken: expiredToken.RefreshToken, + ExpiresAt: expiredToken.Expiry, + IDToken: UNEXPIRED_ID_TOKEN, + }, nil).Twice() + + env.sessionService.On("UpdateExternalSession", mock.Anything, int64(1), mock.MatchedBy(verifyUpdateExternalSessionCommand(unexpiredTokenWithIDToken))).Return(nil).Once() + + env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(unexpiredTokenWithIDToken)).Once() + }, + expectedToken: unexpiredTokenWithIDToken, + }, + { + desc: "should refresh token when the id token is expired", + identity: userIdentity, + sessionToken: &auth.UserToken{ExternalSessionId: 1}, + setup: func(env *environment) { + env.socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{ + UseRefreshToken: true, + } + env.authInfoService.On("GetAuthInfo", mock.Anything, mock.Anything).Return(&login.UserAuth{ + AuthModule: login.GenericOAuthModule, + }, nil) + + env.sessionService.On("GetExternalSession", mock.Anything, int64(1)).Return(&auth.ExternalSession{ + ID: 1, + UserID: 1234, + AuthModule: login.GenericOAuthModule, + AccessToken: unexpiredToken.AccessToken, + RefreshToken: unexpiredToken.RefreshToken, + ExpiresAt: unexpiredToken.Expiry, + IDToken: EXPIRED_ID_TOKEN, + }, nil).Twice() + + env.sessionService.On("UpdateExternalSession", mock.Anything, int64(1), mock.MatchedBy(verifyUpdateExternalSessionCommand(unexpiredTokenWithIDToken))).Return(nil).Once() + + env.socialConnector.On("TokenSource", mock.Anything, mock.Anything).Return(oauth2.StaticTokenSource(unexpiredTokenWithIDToken)).Once() + }, + expectedToken: unexpiredTokenWithIDToken, + }, + } + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + socialConnector := socialtest.NewMockSocialConnector(t) + store := db.InitTestDB(t) + features := featuremgmt.WithFeatures(featuremgmt.FlagImprovedExternalSessionHandling) + + env := environment{ + sessionService: authtest.NewMockUserAuthTokenService(t), + authInfoService: authinfotest.NewMockAuthInfoService(t), + serverLock: serverlock.ProvideService(store, tracing.InitializeTracerForTest()), + socialConnector: socialConnector, + socialService: &socialtest.FakeSocialService{ + ExpectedConnector: socialConnector, + }, + store: store, + } + + if tt.setup != nil { + tt.setup(&env) + } + + env.service = ProvideService( + env.socialService, + env.authInfoService, + setting.NewCfg(), + prometheus.NewRegistry(), + env.serverLock, + tracing.InitializeTracerForTest(), + env.sessionService, + features, + ) + + actualToken := env.service.GetCurrentOAuthToken(context.Background(), tt.identity, tt.sessionToken) + + if tt.expectedToken == nil { + assert.Nil(t, actualToken) + return + } + + assert.NotNil(t, actualToken) + assert.Equal(t, tt.expectedToken.AccessToken, actualToken.AccessToken) + assert.Equal(t, tt.expectedToken.RefreshToken, actualToken.RefreshToken) + assert.WithinDuration(t, tt.expectedToken.Expiry, actualToken.Expiry, time.Second) + if tt.expectedToken.Extra("id_token") != nil { + assert.Equal(t, tt.expectedToken.Extra("id_token"), actualToken.Extra("id_token")) + } else { + assert.Nil(t, actualToken.Extra("id_token")) + } + }) + } +} diff --git a/pkg/services/oauthtoken/oauthtokentest/mock.go b/pkg/services/oauthtoken/oauthtokentest/mock.go index 39e2f6d8fd9..b9319461480 100644 --- a/pkg/services/oauthtoken/oauthtokentest/mock.go +++ b/pkg/services/oauthtoken/oauthtokentest/mock.go @@ -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 } diff --git a/pkg/services/oauthtoken/oauthtokentest/oauthtokentest.go b/pkg/services/oauthtoken/oauthtokentest/oauthtokentest.go index 8c58b43d232..a8a6eeafeae 100644 --- a/pkg/services/oauthtoken/oauthtokentest/oauthtokentest.go +++ b/pkg/services/oauthtoken/oauthtokentest/oauthtokentest.go @@ -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 } diff --git a/public/app/core/components/FolderFilter/FolderFilter.tsx b/public/app/core/components/FolderFilter/FolderFilter.tsx index 2d310a6609b..e668c7b6157 100644 --- a/public/app/core/components/FolderFilter/FolderFilter.tsx +++ b/public/app/core/components/FolderFilter/FolderFilter.tsx @@ -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); diff --git a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx index 67c97879c19..7967f867ad5 100644 --- a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx +++ b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx @@ -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(undefined); // TODO: error not populated anymore const lastSearchTimestamp = useRef(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 = [ diff --git a/public/app/core/components/NestedFolderPicker/useFoldersQuery.ts b/public/app/core/components/NestedFolderPicker/useFoldersQuery.ts index a85bbf09d8a..d852d9c241f 100644 --- a/public/app/core/components/NestedFolderPicker/useFoldersQuery.ts +++ b/public/app/core/components/NestedFolderPicker/useFoldersQuery.ts @@ -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; - permission?: PermissionLevelString; + permission?: PermissionLevel; rootFolderUID?: string; rootFolderItem?: DashboardsTreeItem; } diff --git a/public/app/features/alerting/routes.tsx b/public/app/features/alerting/routes.tsx index b7c025868a3..da4c513388a 100644 --- a/public/app/features/alerting/routes.tsx +++ b/public/app/features/alerting/routes.tsx @@ -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: () => , + component: importAlertingComponent( + () => import(/* webpackChunkName: "AlertingTriage" */ 'app/features/alerting/unified/triage/Triage') + ), }); } diff --git a/public/app/features/alerting/unified/components/EditorColumnHeader.tsx b/public/app/features/alerting/unified/components/EditorColumnHeader.tsx new file mode 100644 index 00000000000..f7c4113bab2 --- /dev/null +++ b/public/app/features/alerting/unified/components/EditorColumnHeader.tsx @@ -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; + +export function EditorColumnHeader({ label, actions, id, children }: Props) { + const styles = useStyles2(editorColumnStyles); + + if (children) { + return
{children}
; + } + + return ( +
+ + {actions && ( + + {actions} + + )} +
+ ); +} + +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, + }), +}); diff --git a/public/app/features/alerting/unified/components/contact-points/templates/EditorColumnHeader.tsx b/public/app/features/alerting/unified/components/contact-points/templates/EditorColumnHeader.tsx deleted file mode 100644 index bd0d77a2a15..00000000000 --- a/public/app/features/alerting/unified/components/contact-points/templates/EditorColumnHeader.tsx +++ /dev/null @@ -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 ( -
- - - {actions} - -
- ); -} - -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, - }), -}); diff --git a/public/app/features/alerting/unified/components/receivers/PayloadEditor.tsx b/public/app/features/alerting/unified/components/receivers/PayloadEditor.tsx index a93285844d1..34ccbc5715c 100644 --- a/public/app/features/alerting/unified/components/receivers/PayloadEditor.tsx +++ b/public/app/features/alerting/unified/components/receivers/PayloadEditor.tsx @@ -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'; diff --git a/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx b/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx index aa5c347464a..bb7708a5226 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/TemplateForm.tsx @@ -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, diff --git a/public/app/features/alerting/unified/components/receivers/TemplatePreview.tsx b/public/app/features/alerting/unified/components/receivers/TemplatePreview.tsx index fc6014a0e0e..5e5ecc1e08b 100644 --- a/public/app/features/alerting/unified/components/receivers/TemplatePreview.tsx +++ b/public/app/features/alerting/unified/components/receivers/TemplatePreview.tsx @@ -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'; diff --git a/public/app/features/alerting/unified/components/receivers/form/fields/TemplateContentAndPreview.tsx b/public/app/features/alerting/unified/components/receivers/form/fields/TemplateContentAndPreview.tsx index 075a58b4a95..7a5c016a126 100644 --- a/public/app/features/alerting/unified/components/receivers/form/fields/TemplateContentAndPreview.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/fields/TemplateContentAndPreview.tsx @@ -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'; diff --git a/public/app/features/alerting/unified/triage/Timeline.tsx b/public/app/features/alerting/unified/triage/Timeline.tsx new file mode 100644 index 00000000000..3bde44c91b9 --- /dev/null +++ b/public/app/features/alerting/unified/triage/Timeline.tsx @@ -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(); + + 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 ( +
+ + {ticks.map((tick) => ( + + {tick.value} + + ))} + +
+ ); +}; diff --git a/public/app/features/alerting/unified/triage/Triage.md b/public/app/features/alerting/unified/triage/Triage.md new file mode 100644 index 00000000000..3bb802e91ab --- /dev/null +++ b/public/app/features/alerting/unified/triage/Triage.md @@ -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 diff --git a/public/app/features/alerting/unified/triage/Triage.tsx b/public/app/features/alerting/unified/triage/Triage.tsx new file mode 100644 index 00000000000..284be52fa41 --- /dev/null +++ b/public/app/features/alerting/unified/triage/Triage.tsx @@ -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 ( + + + + + + ); +}; + +export default withErrorBoundary(TriagePage); diff --git a/public/app/features/alerting/unified/triage/Workbench.tsx b/public/app/features/alerting/unified/triage/Workbench.tsx new file mode 100644 index 00000000000..98c07cc8de6 --- /dev/null +++ b/public/app/features/alerting/unified/triage/Workbench.tsx @@ -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 ; + } 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 ( + + {children} + + ); + } + + return ( + + {children} + + ); + } +} + +/** + * 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(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(); + const leftColumnWidth = rect.width; + + const itemsToRender = pageIndex * DEFAULT_PER_PAGE_PAGINATION; + const dataSlice = take(data, itemsToRender); + const hasMore = data.length > itemsToRender; + + return ( +
+ {/* dummy splitter to handle flex width of group items */} +
+
+
+
+
+
+
+
+
+ {/* content goes here */} +
+
+ + +
+
+ + + + +
+ {/* Render actual data */} +
+ + + {isLoading ? ( + <> + + + + + ) : ( + dataSlice.map((row, index) => { + const rowKey = generateRowKey(row, index); + return renderWorkbenchRow(row, leftColumnWidth, domain, rowKey); + }) + )} + {hasMore && setPageIndex((prevIndex) => prevIndex + 1)} />} + + +
+
+
+ ); +} + +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, + }), + }; +}; diff --git a/public/app/features/alerting/unified/triage/WorkbenchContext.tsx b/public/app/features/alerting/unified/triage/WorkbenchContext.tsx new file mode 100644 index 00000000000..620455d9af6 --- /dev/null +++ b/public/app/features/alerting/unified/triage/WorkbenchContext.tsx @@ -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(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 ( + {children} + ); +} diff --git a/public/app/features/alerting/unified/triage/constants.ts b/public/app/features/alerting/unified/triage/constants.ts new file mode 100644 index 00000000000..627ef4ebb7a --- /dev/null +++ b/public/app/features/alerting/unified/triage/constants.ts @@ -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; diff --git a/public/app/features/alerting/unified/triage/rows/AlertRuleRow.tsx b/public/app/features/alerting/unified/triage/rows/AlertRuleRow.tsx new file mode 100644 index 00000000000..fefd37258f9 --- /dev/null +++ b/public/app/features/alerting/unified/triage/rows/AlertRuleRow.tsx @@ -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 ( + + {row.metadata.title} + + } + /> + } + metadata={ + + + + {row.metadata.folder} + + + } + content={} + depth={depth} + > + + + ); +}; diff --git a/public/app/features/alerting/unified/triage/rows/FolderGroupRow.tsx b/public/app/features/alerting/unified/triage/rows/FolderGroupRow.tsx new file mode 100644 index 00000000000..4809a44573a --- /dev/null +++ b/public/app/features/alerting/unified/triage/rows/FolderGroupRow.tsx @@ -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 ( + + + {row.metadata.value} + + } + isOpenByDefault={true} + leftColumnClassName={styles.folderGroupRow} + rightColumnClassName={styles.folderGroupRow} + depth={depth} + > + {children} + + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + folderGroupRow: css({ + backgroundColor: theme.colors.background.secondary, + }), +}); diff --git a/public/app/features/alerting/unified/triage/rows/GenericRow.tsx b/public/app/features/alerting/unified/triage/rows/GenericRow.tsx new file mode 100644 index 00000000000..904687a4dbf --- /dev/null +++ b/public/app/features/alerting/unified/triage/rows/GenericRow.tsx @@ -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 ( + <> +
+
+
+ +
+
+
+ {content &&
{content}
} +
+
+ {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 ( + + {onToggle && ( + onToggle()} + className={styles.dropdownIcon} + variant="secondary" + size="md" + aria-label={t('alerting.group-wrapper.toggle', 'Toggle group')} + /> + )} + + + {title} + {actions && } + {actions} + + {metadata} + + + ); +}; + +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), + }), + }; +}; diff --git a/public/app/features/alerting/unified/triage/rows/GroupRow.tsx b/public/app/features/alerting/unified/triage/rows/GroupRow.tsx new file mode 100644 index 00000000000..4499672dd6e --- /dev/null +++ b/public/app/features/alerting/unified/triage/rows/GroupRow.tsx @@ -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 ( + } + isOpenByDefault={true} + leftColumnClassName={styles.groupRow} + rightColumnClassName={styles.groupRow} + depth={depth} + > + {children} + + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + groupRow: css({ + backgroundColor: theme.colors.background.secondary, + }), +}); diff --git a/public/app/features/alerting/unified/triage/rows/InstanceRow.tsx b/public/app/features/alerting/unified/triage/rows/InstanceRow.tsx new file mode 100644 index 00000000000..7480d6f8a7e --- /dev/null +++ b/public/app/features/alerting/unified/triage/rows/InstanceRow.tsx @@ -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 ( + + + No labels + +
+ ) : ( + + ) + } + content={ + + } + depth={depth} + /> + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + wrapper: css({ + minHeight: theme.spacing(2.5), + display: 'flex', + alignItems: 'center', + }), + }; +}; diff --git a/public/app/features/alerting/unified/triage/rows/utils.ts b/public/app/features/alerting/unified/triage/rows/utils.ts new file mode 100644 index 00000000000..6a4d0731643 --- /dev/null +++ b/public/app/features/alerting/unified/triage/rows/utils.ts @@ -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}`; + } +} diff --git a/public/app/features/alerting/unified/triage/scene/AlertRuleInstances.tsx b/public/app/features/alerting/unified/triage/scene/AlertRuleInstances.tsx new file mode 100644 index 00000000000..9d2889d9cae --- /dev/null +++ b/public/app/features/alerting/unified/triage/scene/AlertRuleInstances.tsx @@ -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(); + 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 ; + } + + if (!instances.length && !isLoading) { + return ( + Alert instances} + depth={depth} + > +
+ No alert instances found for rule: {ruleUID} +
+
+ ); + } + + const allSeriesLabels: Labels[] = instances.map((instance) => instance.labels); + const commonLabels = allSeriesLabels.length === 1 ? {} : findCommonLabels(allSeriesLabels); + + return ( + <> + {instances.map((instance) => ( + + ))} + + ); +} + +export function GenericRowSkeleton({ width, depth }: { width: number; depth: number }) { + return ( + + + + } + depth={depth} + content={} + /> + ); +} diff --git a/public/app/features/alerting/unified/triage/scene/AlertRuleSummary.tsx b/public/app/features/alerting/unified/triage/scene/AlertRuleSummary.tsx new file mode 100644 index 00000000000..ac9cb28ef82 --- /dev/null +++ b/public/app/features/alerting/unified/triage/scene/AlertRuleSummary.tsx @@ -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 ( + + ); +} diff --git a/public/app/features/alerting/unified/triage/scene/SummaryChart.tsx b/public/app/features/alerting/unified/triage/scene/SummaryChart.tsx new file mode 100644 index 00000000000..9e8d24d152f --- /dev/null +++ b/public/app/features/alerting/unified/triage/scene/SummaryChart.tsx @@ -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 ; +} + +// simple wrapper so we can render the Chart using a Scene parent +export class SummaryChartScene extends SceneObjectBase { + static Component = SummaryChartReact; +} diff --git a/public/app/features/alerting/unified/triage/scene/SummaryStats.tsx b/public/app/features/alerting/unified/triage/scene/SummaryStats.tsx new file mode 100644 index 00000000000..add4f86934e --- /dev/null +++ b/public/app/features/alerting/unified/triage/scene/SummaryStats.tsx @@ -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(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 ( + + + + {{ firingCount }} firing instances + + + {{ pendingCount }} pending instances + + + ); +} + +// simple wrapper so we can render the Chart using a Scene parent +export class SummaryStatsScene extends SceneObjectBase { + static Component = SummaryStatsReact; +} diff --git a/public/app/features/alerting/unified/triage/scene/TriageScene.tsx b/public/app/features/alerting/unified/triage/scene/TriageScene.tsx new file mode 100644 index 00000000000..a7a8a51157e --- /dev/null +++ b/public/app/features/alerting/unified/triage/scene/TriageScene.tsx @@ -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 = () => ; diff --git a/public/app/features/alerting/unified/triage/scene/Workbench.tsx b/public/app/features/alerting/unified/triage/scene/Workbench.tsx new file mode 100644 index 00000000000..356a2924461 --- /dev/null +++ b/public/app/features/alerting/unified/triage/scene/Workbench.tsx @@ -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 { + public static Component = WorkbenchRenderer; +} + +export function WorkbenchRenderer() { + const [timeRange] = useTimeRange(); + const domain = convertTimeRangeToDomain(timeRange); + + const [groupByKeys = []] = useVariableValues(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 ; +} + +type DataPoint = Record, string> & Record; + +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(); + + 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)); +} diff --git a/public/app/features/alerting/unified/triage/scene/utils.ts b/public/app/features/alerting/unified/triage/scene/utils.ts new file mode 100644 index 00000000000..19886a760e0 --- /dev/null +++ b/public/app/features/alerting/unified/triage/scene/utils.ts @@ -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 { + 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(VARIABLES.groupBy); + const [filters = ''] = useVariableValue(VARIABLES.filters); + + const groupByFilter = stringifyGroupFilter(groupBy); + const queryFilter = [groupByFilter, filters].filter((s) => Boolean(s)).join(','); + + return queryFilter; +} diff --git a/public/app/features/alerting/unified/triage/types.ts b/public/app/features/alerting/unified/triage/types.ts new file mode 100644 index 00000000000..b599f46cb33 --- /dev/null +++ b/public/app/features/alerting/unified/triage/types.ts @@ -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[]; +} diff --git a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts index 4ffc9a42723..ac68ca1c5a5 100644 --- a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts +++ b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts @@ -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({ diff --git a/public/app/features/commandPalette/actions/recentScopesActions.ts b/public/app/features/commandPalette/actions/recentScopesActions.ts index 99687d837d3..8cc040e235b 100644 --- a/public/app/features/commandPalette/actions/recentScopesActions.ts +++ b/public/app/features/commandPalette/actions/recentScopesActions.ts @@ -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)) { diff --git a/public/app/features/commandPalette/actions/scopeActions.tsx b/public/app/features/commandPalette/actions/scopeActions.tsx index 783ec7b1b35..bde41152237 100644 --- a/public/app/features/commandPalette/actions/scopeActions.tsx +++ b/public/app/features/commandPalette/actions/scopeActions.tsx @@ -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]); } diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index 3c505a5aeea..56076abfcce 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -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) => { store.get(SETTINGS_KEYS.logsSortOrder) || LogsSortOrder.Descending ); const [isFlipping, setIsFlipping] = useState(false); - const [displayedFields, setDisplayedFields] = useState([]); + const [displayedFields, setDisplayedFields] = useState(panelState?.logs?.displayedFields ?? []); + const [defaultDisplayedFields, setDefaultDisplayedFields] = useState([]); const [contextOpen, setContextOpen] = useState(false); const [contextRow, setContextRow] = useState(undefined); const [pinLineButtonTooltipTitle, setPinLineButtonTooltipTitle] = useState(PINNED_LOGS_MESSAGE); @@ -280,16 +282,6 @@ const UnthemedLogs: React.FunctionComponent = (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) => { ] ); + 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) => { 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) => { 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) => { 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) => { 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) => { dedupStrategy={dedupStrategy} dedupCount={dedupCount} displayedFields={displayedFields} - clearDetectedFields={clearDetectedFields} + clearDisplayedFields={clearDisplayedFields} + defaultDisplayedFields={defaultDisplayedFields} />
diff --git a/public/app/features/explore/Logs/LogsMetaRow.test.tsx b/public/app/features/explore/Logs/LogsMetaRow.test.tsx index 598b7cebb7a..ea0e65f91cb 100644 --- a/public/app/features/explore/Logs/LogsMetaRow.test.tsx +++ b/public/app/features/explore/Logs/LogsMetaRow.test.tsx @@ -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', diff --git a/public/app/features/explore/Logs/LogsMetaRow.tsx b/public/app/features/explore/Logs/LogsMetaRow.tsx index 96206ea3858..04673a11de2 100644 --- a/public/app/features/explore/Logs/LogsMetaRow.tsx +++ b/public/app/features/explore/Logs/LogsMetaRow.tsx @@ -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 = [...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: ( - ), } diff --git a/public/app/features/explore/Logs/LogsTableAvailableFields.tsx b/public/app/features/explore/Logs/LogsTableAvailableFields.tsx index f203b3b48a2..2fff4a27fff 100644 --- a/public/app/features/explore/Logs/LogsTableAvailableFields.tsx +++ b/public/app/features/explore/Logs/LogsTableAvailableFields.tsx @@ -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 ( diff --git a/public/app/features/logs/components/ControlledLogRows.tsx b/public/app/features/logs/components/ControlledLogRows.tsx index 13e57b3781f..ee663e3d0d9 100644 --- a/public/app/features/logs/components/ControlledLogRows.tsx +++ b/public/app/features/logs/components/ControlledLogRows.tsx @@ -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 { 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[]; diff --git a/public/app/features/logs/components/LogLabels.test.tsx b/public/app/features/logs/components/LogLabels.test.tsx index b70af572021..a5ac1219da9 100644 --- a/public/app/features/logs/components/LogLabels.test.tsx +++ b/public/app/features/logs/components/LogLabels.test.tsx @@ -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('', () => { it('renders notice when no labels are found', () => { @@ -96,6 +97,6 @@ describe('', () => { render(); 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(); }); }); diff --git a/public/app/features/logs/components/LogLabels.tsx b/public/app/features/logs/components/LogLabels.tsx index da8107663fb..c112eee4f83 100644 --- a/public/app/features/logs/components/LogLabels.tsx +++ b/public/app/features/logs/components/LogLabels.tsx @@ -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) => { {labels.map((label) => ( - {label === LOG_LINE_BODY_FIELD_NAME ? t('logs.log-labels-list.log-line', 'log line') : label} + {getNormalizedFieldName(label)} ))} diff --git a/public/app/features/logs/components/otel/formats.test.ts b/public/app/features/logs/components/otel/formats.test.ts index 4844e2a307a..0da98ea9b5e 100644 --- a/public/app/features/logs/components/otel/formats.test.ts +++ b/public/app/features/logs/components/otel/formats.test.ts @@ -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'); }); }); diff --git a/public/app/features/logs/components/otel/formats.ts b/public/app/features/logs/components/otel/formats.ts index dc28b49e52a..6f870325231 100644 --- a/public/app/features/logs/components/otel/formats.ts +++ b/public/app/features/logs/components/otel/formats.ts @@ -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(); 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; } diff --git a/public/app/features/logs/components/panel/HighlightedLogRenderer.test.tsx b/public/app/features/logs/components/panel/HighlightedLogRenderer.test.tsx index 272bb0e2ade..99c0d5fcf96 100644 --- a/public/app/features/logs/components/panel/HighlightedLogRenderer.test.tsx +++ b/public/app/features/logs/components/panel/HighlightedLogRenderer.test.tsx @@ -34,7 +34,7 @@ describe('HighlightedLogRenderer', () => { } ); - const { container } = render(); + const { container } = render(); expect(container.innerHTML).toEqual(log.highlightedBody); }); @@ -177,7 +177,7 @@ describe('HighlightedLogRenderer', () => { } ); - const { container } = render(); + const { container } = render(); expect(container.innerHTML).toEqual(log.highlightedBody); }); @@ -201,7 +201,7 @@ describe('HighlightedLogRenderer', () => { } ); - const { container } = render(); + const { container } = render(); expect(container.innerHTML).toEqual(log.highlightedBody); }); diff --git a/public/app/features/logs/components/panel/HighlightedLogRenderer.tsx b/public/app/features/logs/components/panel/HighlightedLogRenderer.tsx index 2016bc84cbc..4a59b1e6d81 100644 --- a/public/app/features/logs/components/panel/HighlightedLogRenderer.tsx +++ b/public/app/features/logs/components/panel/HighlightedLogRenderer.tsx @@ -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 }) => { return ( <> - {log.highlightedBodyTokens.map((token, i) => ( + {tokens.map((token, 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.displayName = 'LogToken'; diff --git a/public/app/features/logs/components/panel/LogLine.test.tsx b/public/app/features/logs/components/panel/LogLine.test.tsx index b79bf06e2e0..6e4b1599398 100644 --- a/public/app/features/logs/components/panel/LogLine.test.tsx +++ b/public/app/features/logs/components/panel/LogLine.test.tsx @@ -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( + + + + ); + 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( + + + + ); + 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', () => { diff --git a/public/app/features/logs/components/panel/LogLine.tsx b/public/app/features/logs/components/panel/LogLine.tsx index 62a19356407..d58a861439e 100644 --- a/public/app/features/logs/components/panel/LogLine.tsx +++ b/public/app/features/logs/components/panel/LogLine.tsx @@ -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 ? ( - - ) : ( - + return displayedFields.map((field) => { + if (field === LOG_LINE_BODY_FIELD_NAME) { + return ; + } + if (field === OTEL_LOG_LINE_ATTRIBUTES_FIELD_NAME && syntaxHighlighting) { + return ( + + + + ); + } + return ( + {searchWords ? ( - ) - ); + ); + }); }; const LogLineBody = ({ log, styles }: { log: LogListModel; styles: LogLineStyles }) => { @@ -444,7 +454,7 @@ const LogLineBody = ({ log, styles }: { log: LogListModel; styles: LogLineStyles return ( - + ); }; diff --git a/public/app/features/logs/components/panel/LogLineDetailsDisplayedFields.tsx b/public/app/features/logs/components/panel/LogLineDetailsDisplayedFields.tsx index e5bce32bd86..24a0c67948b 100644 --- a/public/app/features/logs/components/panel/LogLineDetailsDisplayedFields.tsx +++ b/public/app/features/logs/components/panel/LogLineDetailsDisplayedFields.tsx @@ -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 = ({
-
- {field === LOG_LINE_BODY_FIELD_NAME ? t('logs.log-line-details.log-line-field', 'Log line') : field} -
+
{getNormalizedFieldName(field)}
{displayedFields.length > 1 && ( <>
{!disableActions && (
- {onClickFilterLabel && ( + {onClickFilterLabel && fieldSupportsFilters && ( )} - {onClickFilterOutLabel && ( + {onClickFilterOutLabel && fieldSupportsFilters && (
)} -
{singleKey ? keys[0] : }
+
+ {singleKey ? getNormalizedFieldName(keys[0]) : } +
{singleValue ? ( diff --git a/public/app/features/logs/components/panel/LogLineDetailsLog.tsx b/public/app/features/logs/components/panel/LogLineDetailsLog.tsx index 1f8955c44cb..28ecad3d8a3 100644 --- a/public/app/features/logs/components/panel/LogLineDetailsLog.tsx +++ b/public/app/features/logs/components/panel/LogLineDetailsLog.tsx @@ -33,7 +33,9 @@ export const LogLineDetailsLog = memo(({ log: originalLog, syntaxHighlighting }: <> {!syntaxHighlighting &&
{log.body}
} {syntaxHighlighting && ( -
{}
+
+ {} +
)} )} diff --git a/public/app/features/logs/components/panel/LogList.test.tsx b/public/app/features/logs/components/panel/LogList.test.tsx index 572ff363097..7f292ab7216 100644 --- a/public/app/features/logs/components/panel/LogList.test.tsx +++ b/public/app/features/logs/components/panel/LogList.test.tsx @@ -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( + + ); + 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( + + ); + 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( + + ); + 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 = {}) { return render( diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index 1986a9d2e57..055539ed985 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -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; 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} diff --git a/public/app/features/logs/components/panel/LogListContext.tsx b/public/app/features/logs/components/panel/LogListContext.tsx index bac7d507652..9ec63e2765d 100644 --- a/public/app/features/logs/components/panel/LogListContext.tsx +++ b/public/app/features/logs/components/panel/LogListContext.tsx @@ -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; 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) diff --git a/public/app/features/logs/components/panel/processing.test.ts b/public/app/features/logs/components/panel/processing.test.ts index 0b69cd15f7a..1e8cf516325 100644 --- a/public/app/features/logs/components/panel/processing.test.ts +++ b/public/app/features/logs/components/panel/processing.test.ts @@ -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`); - }); -}); diff --git a/public/app/features/logs/components/panel/processing.ts b/public/app/features/logs/components/panel/processing.ts index 98a0cf6a00c..7ad570acad8 100644 --- a/public/app/features/logs/components/panel/processing.ts +++ b/public/app/features/logs/components/panel/processing.ts @@ -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 | undefined = undefined; private _highlightTokens: Array | 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; +} diff --git a/public/app/features/plugins/extensions/getPluginExtensions.test.tsx b/public/app/features/plugins/extensions/getPluginExtensions.test.tsx index e4f2ea1d762..1282eb93f05 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.test.tsx +++ b/public/app/features/plugins/extensions/getPluginExtensions.test.tsx @@ -405,6 +405,7 @@ describe('getPluginExtensions()', () => { expect.objectContaining({ context, openModal: expect.any(Function), + extensionPointId: extensionPoint2, }) ); }); diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx index 5d5498ce0ac..5077da479d3 100644 --- a/public/app/features/plugins/extensions/utils.tsx +++ b/public/app/features/plugins/extensions/utils.tsx @@ -537,6 +537,7 @@ export function getLinkExtensionOnClick( const helpers: PluginExtensionEventHelpers = { context, + extensionPointId, openModal: createOpenModalFunction(config), openSidebar: (componentTitle, context) => { appEvents.publish( diff --git a/public/app/features/search/service/sql.ts b/public/app/features/search/service/sql.ts index 137fee41620..fe7d7f2b664 100644 --- a/public/app/features/search/service/sql.ts +++ b/public/app/features/search/service/sql.ts @@ -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; } diff --git a/public/app/features/search/service/types.ts b/public/app/features/search/service/types.ts index 6617bb284f9..a670b054979 100644 --- a/public/app/features/search/service/types.ts +++ b/public/app/features/search/service/types.ts @@ -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; } diff --git a/public/app/plugins/panel/logs/LogsPanel.tsx b/public/app/plugins/panel/logs/LogsPanel.tsx index ac03ed51932..cf309b219b3 100644 --- a/public/app/plugins/panel/logs/LogsPanel.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.tsx @@ -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 { * 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 { * * 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} diff --git a/public/app/plugins/panel/logs/module.tsx b/public/app/plugins/panel/logs/module.tsx index e73604b13ea..04ed5e92183 100644 --- a/public/app/plugins/panel/logs/module.tsx +++ b/public/app/plugins/panel/logs/module.tsx @@ -119,6 +119,19 @@ export const plugin = new PanelPlugin(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({ diff --git a/public/app/plugins/panel/logs/panelcfg.cue b/public/app/plugins/panel/logs/panelcfg.cue index 44eb486d3a0..a25eee69a8f 100644 --- a/public/app/plugins/panel/logs/panelcfg.cue +++ b/public/app/plugins/panel/logs/panelcfg.cue @@ -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") diff --git a/public/app/plugins/panel/logs/panelcfg.gen.ts b/public/app/plugins/panel/logs/panelcfg.gen.ts index ad5a194f733..58944c7f5f8 100644 --- a/public/app/plugins/panel/logs/panelcfg.gen.ts +++ b/public/app/plugins/panel/logs/panelcfg.gen.ts @@ -39,6 +39,7 @@ export interface Options { showCommonLabels: boolean; showControls?: boolean; showLabels: boolean; + showLogAttributes?: boolean; showLogContextToggle: boolean; showTime: boolean; sortOrder: common.LogsSortOrder; diff --git a/public/app/plugins/panel/logs/suggestions.ts b/public/app/plugins/panel/logs/suggestions.ts index 6f7daa57c05..79b804cb8d7 100644 --- a/public/app/plugins/panel/logs/suggestions.ts +++ b/public/app/plugins/panel/logs/suggestions.ts @@ -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) { diff --git a/public/app/plugins/panel/logs/types.ts b/public/app/plugins/panel/logs/types.ts index bd369daa388..3b9d0450414 100644 --- a/public/app/plugins/panel/logs/types.ts +++ b/public/app/plugins/panel/logs/types.ts @@ -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 = ( diff --git a/public/app/types/acl.ts b/public/app/types/acl.ts index a3662165c92..f2511efd07a 100644 --- a/public/app/types/acl.ts +++ b/public/app/types/acl.ts @@ -5,13 +5,6 @@ export enum TeamPermissionLevel { export type PermissionLevel = 'view' | 'edit' | 'admin'; -/** @deprecated Use PermissionLevel instead */ -export enum PermissionLevelString { - View = 'View', - Edit = 'Edit', - Admin = 'Admin', -} - export enum SearchQueryType { Folder = 'dash-folder', Dashboard = 'dash-db', diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 2c9439a2df2..0cdda8b0a2a 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1535,6 +1535,9 @@ "group-status": { "content-the-group-is-being-deleted": "The group is being deleted" }, + "group-wrapper": { + "toggle": "Toggle group" + }, "header": { "tooltip-remove": "Remove expression \"{{refId}}\"" }, @@ -1710,6 +1713,9 @@ "placeholder-key": "key", "placeholder-value": "value" }, + "left-column": { + "label-instances": "Instances" + }, "link-to-contact-points": { "aria-label-view-or-create-contact-points": "View or create contact points", "view-or-create-contact-points": "View or create contact points" @@ -2025,6 +2031,12 @@ "body-selected-alertmanager-not-found": "The selected Alertmanager no longer exists or you may not have permission to access it. You can select a different Alertmanager from the dropdown.", "title-selected-alertmanager-not-found": "Selected Alertmanager not found." }, + "pages": { + "triage": { + "subtitle": "Learn about problems in your systems moments after they occur", + "title": "Triage" + } + }, "panel-alert-tab-content": { "alert": { "title-errors-loading-rules": "Errors loading rules" @@ -2916,6 +2928,13 @@ "title-warning": "Warning" } }, + "triage": { + "alert-instances": "Alert instances", + "firing-instances-count": "{{firingCount}} firing instances", + "no-instances-found": "No alert instances found for rule: {ruleUID}", + "no-labels": "No labels", + "pending-instances-count": "{{pendingCount}} pending instances" + }, "type-selector-button": { "add-expression": "Add expression" }, @@ -9446,6 +9465,7 @@ "description-enable-infinite-scrolling": "Experimental. Request more results by scrolling to the bottom of the logs list.", "description-enable-logs-highlighting": "Use a predefined coloring scheme to highlight relevant parts of the log lines", "description-show-controls": "Display controls to jump to the last or first log line, and filters by log level", + "description-show-log-attributes": "Experimental. When OTel logs are displayed, add an extra displayed field with relevant key-value pairs from labels and metadata.", "fields": { "type": { "loki": { @@ -9496,9 +9516,6 @@ "collapse": "Collapse labels", "expand": "Expand labels" }, - "log-labels-list": { - "log-line": "log line" - }, "log-line": { "has-error": "Has errors", "is-sampled": "Is sampled", @@ -9538,6 +9555,7 @@ "inline-mode": "Display inline", "link-value-tooltip": "Link value", "links-section": "Links", + "log-attributes-field": "OTel attributes", "log-line-field": "Log line", "log-line-section": "Log line", "move-displayed-field-down": "Move down", @@ -9744,6 +9762,7 @@ "line-contains": "Add as line contains filter", "line-contains-not": "Add as line does not contain filter" }, + "show-log-attributes": "Display log attributes for OTel logs", "timestamp-format": "Timestamp resolution", "un-themed-log-details": { "aria-label-data-links": "Data links",