Compare commits

..

9 Commits

Author SHA1 Message Date
Tito Lins ca1862af58 update prometheus-alertmanager 2026-01-13 10:10:49 +01:00
Tito Lins ec8889b2fb update alerting 2026-01-13 10:09:27 +01:00
Tito Lins c74cc1cbf2 fix go mod 2026-01-13 10:08:26 +01:00
Yulia Shanyrova 5dd9a14903 Plugins: Fix the flaky configuration tab on the plugin details page for cloud instances (#114922)
Fix flaky configuration tab for plugin details page at cloud instances
2026-01-13 09:55:52 +01:00
Roberto Jiménez Sánchez 68bf19d840 Provisioning: handle resource version conflicts in connection CRUDL test (#116184)
fix: handle resource version conflicts in connection CRUDL test

After updating a connection resource, the controller may update the
resource status, changing the resource version. This causes the delete
operation to fail with a resource version conflict.

Add retry logic to handle conflicts gracefully by retrying the delete
operation when encountering resource version conflicts.
2026-01-13 08:53:54 +00:00
Costa Alexoglou 220c29de89 fix: 401 in grafana live spam (#116140) 2026-01-13 09:46:06 +01:00
Oscar Kilhed 91ab753368 Dynamic Dashboards: Fix navigation to repeated panels and update outline when lazy items repeat (#116030)
Dashboard Outline: Fix navigation to repeated panels and lazy-loaded repeats

- Remove cursor: not-allowed styling from repeated panels in outline
- Add RepeatsUpdatedEvent to notify when panel repeats are populated
- Subscribe to RepeatsUpdatedEvent in DashboardEditPane to refresh outline
- Remove memoization from visibleChildren to ensure outline updates on re-render
2026-01-13 08:43:50 +01:00
Alex Khomenko 250ca7985f Provisioning: Add Connections page (#116060)
* Provisioning: Add connections page

* Provisioning: Add connections form

* Provisioning: Add connections form

* Update fields

* Fix generated name

* Update connection name

* Add edit page

* error handling

* Form validation

* Add Connections button

* Cleanup

* Extract ConnectionFormData type

* Add list test and separate empty states

* Add form test

* Update tests

* i18n

* Cleanup

* Use SecretTextArea from grafana-ui

* Fix breadcrumbs

* tweaks

* Add missing URL

* Switch to ShowConfirmModalEvent

* i18n

* redirect to list on success

* add timeout

* Fix tags invalidation
2026-01-13 08:25:40 +02:00
Hugo Häggmark b57ed32484 chore: remove app/core/config barrel files (#116068) 2026-01-13 06:23:21 +01:00
101 changed files with 1497 additions and 559 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ go 1.25.5
require (
github.com/go-kit/log v0.2.1
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f
github.com/grafana/alerting v0.0.0-20260112110054-6c6f13659ad3
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4
github.com/grafana/grafana-app-sdk v0.48.7
github.com/grafana/grafana-app-sdk/logging v0.48.7
+2 -2
View File
@@ -243,8 +243,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts=
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
github.com/grafana/alerting v0.0.0-20260112110054-6c6f13659ad3 h1:KVncUdAc5YwY/OQmw6HgzJmbRKn6IwrhvtcBAd1yDHo=
github.com/grafana/alerting v0.0.0-20260112110054-6c6f13659ad3/go.mod h1:Oy4MthJqfErlieO14ryZXdukDrUACy8Lg56P3zP7S1k=
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI=
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4=
github.com/grafana/grafana-app-sdk v0.48.7 h1:9mF7nqkqP0QUYYDlznoOt+GIyjzj45wGfUHB32u2ZMo=
+1 -1
View File
@@ -97,7 +97,7 @@ require (
github.com/google/gnostic-models v0.7.1 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f // indirect
github.com/grafana/alerting v0.0.0-20260112110054-6c6f13659ad3 // indirect
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect
github.com/grafana/dataplane/sdata v0.0.9 // indirect
+2 -2
View File
@@ -215,8 +215,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts=
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
github.com/grafana/alerting v0.0.0-20260112110054-6c6f13659ad3 h1:KVncUdAc5YwY/OQmw6HgzJmbRKn6IwrhvtcBAd1yDHo=
github.com/grafana/alerting v0.0.0-20260112110054-6c6f13659ad3/go.mod h1:Oy4MthJqfErlieO14ryZXdukDrUACy8Lg56P3zP7S1k=
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o=
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg=
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY=
-5
View File
@@ -1156,11 +1156,6 @@
"count": 2
}
},
"public/app/core/config.ts": {
"no-barrel-files/no-barrel-files": {
"count": 2
}
},
"public/app/core/navigation/types.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
+15 -7
View File
@@ -32,14 +32,13 @@ require (
github.com/armon/go-radix v1.0.0 // @grafana/grafana-app-platform-squad
github.com/aws/aws-sdk-go v1.55.7 // @grafana/aws-datasources
github.com/aws/aws-sdk-go-v2 v1.40.0 // @grafana/aws-datasources
github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // @grafana/grafana-operator-experience-squad
github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // indirect; @grafana/grafana-operator-experience-squad
github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.45.3 // @grafana/aws-datasources
github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.51.0 // @grafana/aws-datasources
github.com/aws/aws-sdk-go-v2/service/ec2 v1.225.2 // @grafana/aws-datasources
github.com/aws/aws-sdk-go-v2/service/oam v1.18.3 // @grafana/aws-datasources
github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.26.6 // @grafana/aws-datasources
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.1 // @grafana/grafana-operator-experience-squad
github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // @grafana/grafana-operator-experience-squad
github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // indirect; @grafana/grafana-operator-experience-squad
github.com/aws/smithy-go v1.23.2 // @grafana/aws-datasources
github.com/beevik/etree v1.4.1 // @grafana/grafana-backend-group
github.com/benbjohnson/clock v1.3.5 // @grafana/alerting-backend
@@ -82,14 +81,14 @@ require (
github.com/golang/protobuf v1.5.4 // @grafana/grafana-backend-group
github.com/golang/snappy v1.0.0 // @grafana/alerting-backend
github.com/google/go-cmp v0.7.0 // @grafana/grafana-backend-group
github.com/google/go-github/v70 v70.0.0 // indirect; @grafana/grafana-git-ui-sync-team
github.com/google/go-github/v70 v70.0.0 // @grafana/grafana-git-ui-sync-team
github.com/google/go-querystring v1.1.0 // indirect; @grafana/oss-big-tent
github.com/google/uuid v1.6.0 // @grafana/grafana-backend-group
github.com/google/wire v0.7.0 // @grafana/grafana-backend-group
github.com/googleapis/gax-go/v2 v2.15.0 // @grafana/grafana-backend-group
github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // @grafana/grafana-app-platform-squad
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f // @grafana/alerting-backend
github.com/grafana/alerting v0.0.0-20260112110054-6c6f13659ad3 // @grafana/alerting-backend
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // @grafana/identity-access-team
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // @grafana/identity-access-team
github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics
@@ -265,7 +264,7 @@ require (
// For local development grafana/grafana will always use the local files
// Check go.work file for details
github.com/grafana/grafana/pkg/promlib v0.0.8 // @grafana/oss-big-tent
github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2 // @grafana/grafana-app-platform-squad
github.com/grafana/grafana/pkg/semconv v0.0.0 // @grafana/grafana-app-platform-squad
)
// Replace the workspace versions
@@ -294,6 +293,8 @@ replace (
github.com/grafana/grafana/pkg/aggregator => ./pkg/aggregator
github.com/grafana/grafana/pkg/apimachinery => ./pkg/apimachinery
github.com/grafana/grafana/pkg/apiserver => ./pkg/apiserver
github.com/grafana/grafana/pkg/plugins => ./pkg/plugins
github.com/grafana/grafana/pkg/semconv => ./pkg/semconv
)
require (
@@ -654,9 +655,14 @@ require (
require github.com/grafana/tempo v1.5.1-0.20250529124718-87c2dc380cec // @grafana/observability-traces-and-profiling
require github.com/grafana/grafana/pkg/plugins v0.0.0
require (
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/IBM/pgxpoolprometheus v1.1.2 // indirect
github.com/Machiel/slugify v1.0.1 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
@@ -676,6 +682,8 @@ require (
github.com/google/gnostic v0.7.1 // indirect
github.com/gophercloud/gophercloud/v2 v2.9.0 // indirect
github.com/grafana/sqlds/v5 v5.0.3 // indirect
github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.2 // indirect
github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 // indirect
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/moby/go-archive v0.1.0 // indirect
@@ -697,7 +705,7 @@ require (
replace github.com/crewjam/saml => github.com/grafana/saml v0.4.15-0.20240917091248-ae3bbdad8a56
// Use our fork of the upstream Alertmanager.
replace github.com/prometheus/alertmanager => github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604
replace github.com/prometheus/alertmanager => github.com/grafana/prometheus-alertmanager v0.25.1-0.20260112162805-d29cc9cf7f0f
exclude github.com/mattn/go-sqlite3 v2.0.3+incompatible
+17 -8
View File
@@ -680,6 +680,7 @@ github.com/Azure/azure-storage-blob-go v0.15.0 h1:rXtgp8tN1p29GvpGgfJetavIG0V7Og
github.com/Azure/azure-storage-blob-go v0.15.0/go.mod h1:vbjsVbX0dlxnRc4FFMPsS9BsJWPcne7GB7onqlPvz58=
github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Azure/go-autorest v11.2.8+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs=
github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
@@ -737,6 +738,8 @@ github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXY
github.com/IBM/pgxpoolprometheus v1.1.2 h1:sHJwxoL5Lw4R79Zt+H4Uj1zZ4iqXJLdk7XDE7TPs97U=
github.com/IBM/pgxpoolprometheus v1.1.2/go.mod h1:+vWzISN6S9ssgurhUNmm6AlXL9XLah3TdWJktquKTR8=
github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk=
github.com/Machiel/slugify v1.0.1 h1:EfWSlRWstMadsgzmiV7d0yVd2IFlagWH68Q+DcYCm4E=
github.com/Machiel/slugify v1.0.1/go.mod h1:fTFGn5uWEynW4CUMG7sWkYXOf1UgDxyTM3DbR6Qfg3k=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
@@ -759,6 +762,8 @@ github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/OneOfOne/xxhash v1.2.5 h1:zl/OfRA6nftbBK9qTohYBJ5xvw6C/oNKizR7cZGl3cI=
github.com/OneOfOne/xxhash v1.2.5/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
@@ -887,8 +892,6 @@ github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.26.6 h1:Pwbxovp
github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.26.6/go.mod h1:Z4xLt5mXspLKjBV92i165wAJ/3T6TIv4n7RtIS8pWV0=
github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0 h1:0reDqfEN+tB+sozj2r92Bep8MEwBZgtAXTND1Kk9OXg=
github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0/go.mod h1:kUklwasNoCn5YpyAqC/97r6dzTA1SRKJfKq16SXeoDU=
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.1 h1:w6a0H79HrHf3lr+zrw+pSzR5B+caiQFAKiNHlrUcnoc=
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.1/go.mod h1:c6Vg0BRiU7v0MVhHupw90RyL120QBwAMLbDCzptGeMk=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 h1:0JPwLz1J+5lEOfy/g0SURC9cxhbQ1lIMHMa+AHZSzz0=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.1/go.mod h1:fKvyjJcz63iL/ftA6RaM8sRCtN4r4zl4tjL3qw5ec7k=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 h1:OWs0/j2UYR5LOGi88sD5/lhN6TDLG6SfA7CqsQO9zF0=
@@ -1026,6 +1029,8 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
@@ -1620,8 +1625,8 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts=
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
github.com/grafana/alerting v0.0.0-20260112110054-6c6f13659ad3 h1:KVncUdAc5YwY/OQmw6HgzJmbRKn6IwrhvtcBAd1yDHo=
github.com/grafana/alerting v0.0.0-20260112110054-6c6f13659ad3/go.mod h1:Oy4MthJqfErlieO14ryZXdukDrUACy8Lg56P3zP7S1k=
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o=
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg=
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY=
@@ -1664,8 +1669,6 @@ github.com/grafana/grafana/apps/quotas v0.0.0-20251209183543-1013d74f13f2 h1:rDP
github.com/grafana/grafana/apps/quotas v0.0.0-20251209183543-1013d74f13f2/go.mod h1:M7bV60iRB61y0ISPG1HX/oNLZtlh0ZF22rUYwNkAKjo=
github.com/grafana/grafana/pkg/promlib v0.0.8 h1:VUWsqttdf0wMI4j9OX9oNrykguQpZcruudDAFpJJVw0=
github.com/grafana/grafana/pkg/promlib v0.0.8/go.mod h1:U1ezG/MGaEPoThqsr3lymMPN5yIPdVTJnDZ+wcXT+ao=
github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2 h1:A65jWgLk4Re28gIuZcpC0aTh71JZ0ey89hKGE9h543s=
github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2/go.mod h1:2HRzUK/xQEYc+8d5If/XSusMcaYq9IptnBSHACiQcOQ=
github.com/grafana/jsonparser v0.0.0-20240425183733-ea80629e1a32 h1:NznuPwItog+rwdVg8hAuGKP29ndRSzJAwhxKldkP8oQ=
github.com/grafana/jsonparser v0.0.0-20240425183733-ea80629e1a32/go.mod h1:796sq+UcONnSlzA3RtlBZ+b/hrerkZXiEmO8oMjyRwY=
github.com/grafana/loki/pkg/push v0.0.0-20250823105456-332df2b20000 h1:/5LKSYgLmAhwA4m6iGUD4w1YkydEWWjazn9qxCFT8W0=
@@ -1676,8 +1679,8 @@ github.com/grafana/nanogit v0.3.0 h1:XNEef+4Vi+465ZITJs/g/xgnDRJbWhhJ7iQrAnWZ0oQ
github.com/grafana/nanogit v0.3.0/go.mod h1:6s6CCTpyMOHPpcUZaLGI+rgBEKdmxVbhqSGgCK13j7Y=
github.com/grafana/otel-profiling-go v0.5.1 h1:stVPKAFZSa7eGiqbYuG25VcqYksR6iWvF3YH66t4qL8=
github.com/grafana/otel-profiling-go v0.5.1/go.mod h1:ftN/t5A/4gQI19/8MoWurBEtC6gFw8Dns1sJZ9W4Tls=
github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604 h1:aXfUhVN/Ewfpbko2CCtL65cIiGgwStOo4lWH2b6gw2U=
github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604/go.mod h1:O/QP1BCm0HHIzbKvgMzqb5sSyH88rzkFk84F4TfJjBU=
github.com/grafana/prometheus-alertmanager v0.25.1-0.20260112162805-d29cc9cf7f0f h1:9tRhudagkQO2s61SLFLSziIdCm7XlkfypVKDxpcHokg=
github.com/grafana/prometheus-alertmanager v0.25.1-0.20260112162805-d29cc9cf7f0f/go.mod h1:AsVdCBeDFN9QbgpJg+8voDAcgsW0RmNvBd70ecMMdC0=
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og=
github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU=
github.com/grafana/pyroscope/api v1.2.1-0.20251118081820-ace37f973a0f h1:fTlIj5n4x5dU63XHItug7GLjtnaeJdPqBlqg4zlABq0=
@@ -1753,6 +1756,8 @@ github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5O
github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM=
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0=
github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.2 h1:gCNiM4T5xEc4IpT8vM50CIO+AtElr5kO9l2Rxbq+Sz8=
github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.2/go.mod h1:6ZM4ZdwClyAsiU2uDBmRHCvq0If/03BMbF9U+U7G5pA=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
@@ -1877,6 +1882,10 @@ github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbd
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
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/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 h1:hgVxRoDDPtQE68PT4LFvNlPz2nBKd3OMlGKIQ69OmR4=
github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531/go.mod h1:fqTUQpVYBvhCNIsMXGl2GE9q6z94DIP6NtFKXCSTVbg=
github.com/joshlf/testutil v0.0.0-20170608050642-b5d8aa79d93d h1:J8tJzRyiddAFF65YVgxli+TyWBi0f79Sld6rJP6CBcY=
github.com/joshlf/testutil v0.0.0-20170608050642-b5d8aa79d93d/go.mod h1:b+Q3v8Yrg5o15d71PSUraUzYb+jWl6wQMSBXSGS/hv0=
github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7/go.mod h1:2iMrUgbbvHEiQClaW2NsSzMyGHqN+rDFqY705q49KG0=
github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
+1 -1
View File
@@ -38,6 +38,6 @@ use (
./pkg/semconv
)
replace github.com/prometheus/alertmanager => github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604
replace github.com/prometheus/alertmanager => github.com/grafana/prometheus-alertmanager v0.25.1-0.20260112162805-d29cc9cf7f0f
replace github.com/crewjam/saml => github.com/grafana/saml v0.4.15-0.20240917091248-ae3bbdad8a56
+3 -1
View File
@@ -280,7 +280,6 @@ github.com/Azure/go-amqp v0.17.0/go.mod h1:9YJ3RhxRT1gquYnzpZO1vcYMMpAdJT+QEg6fw
github.com/Azure/go-amqp v1.4.0 h1:Xj3caqi4comOF/L1Uc5iuBxR/pB6KumejC01YQOqOR4=
github.com/Azure/go-amqp v1.4.0/go.mod h1:vZAogwdrkbyK3Mla8m/CxSc/aKdnTZ4IbPxl51Y5WZE=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Azure/go-autorest/autorest v0.11.18/go.mod h1:dSiJPy22c3u0OtOKDNttNgqpNFY/GeWa7GH/Pz56QRA=
github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 h1:Ov8avRZi2vmrE2JcXw+tu5K/yB41r7xK9GZDiBF7NdM=
github.com/Azure/go-autorest/autorest/azure/auth v0.5.13/go.mod h1:5BAVfWLWXihP47vYrPuBKKf4cS0bXI+KM9Qx6ETDJYo=
@@ -906,6 +905,8 @@ github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB7
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grafana/alerting v0.0.0-20250729175202-b4b881b7b263/go.mod h1:VKxaR93Gff0ZlO2sPcdPVob1a/UzArFEW5zx3Bpyhls=
github.com/grafana/alerting v0.0.0-20251009192429-9427c24835ae/go.mod h1:VGjS5gDwWEADPP6pF/drqLxEImgeuHlEW5u8E5EfIrM=
github.com/grafana/alerting v0.0.0-20260112110054-6c6f13659ad3 h1:KVncUdAc5YwY/OQmw6HgzJmbRKn6IwrhvtcBAd1yDHo=
github.com/grafana/alerting v0.0.0-20260112110054-6c6f13659ad3/go.mod h1:Oy4MthJqfErlieO14ryZXdukDrUACy8Lg56P3zP7S1k=
github.com/grafana/authlib v0.0.0-20250710201142-9542f2f28d43/go.mod h1:1fWkOiL+m32NBgRHZtlZGz2ji868tPZACYbqP3nBRJI=
github.com/grafana/authlib/types v0.0.0-20250710201142-9542f2f28d43/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw=
github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw=
@@ -1911,6 +1912,7 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0/go.mod h
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.22.0/go.mod h1:hYwym2nDEeZfG/motx0p7L7J1N1vyzIThemQsb4g2qY=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0/go.mod h1:Y5+XiUG4Emn1hTfciPzGPJaSI+RpDts6BnCIir0SLqk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0/go.mod h1:r49hO7CgrxY9Voaj3Xe8pANWtr0Oq916d0XAmOoCZAQ=
go.opentelemetry.io/otel/exporters/prometheus v0.58.0/go.mod h1:7qo/4CLI+zYSNbv0GMNquzuss2FVZo3OYrGh96n4HNc=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.36.0/go.mod h1:PD57idA/AiFD5aqoxGxCvT/ILJPeHy3MjqU/NS7KogY=
@@ -14,6 +14,8 @@ export type Props = React.ComponentProps<typeof TextArea> & {
isConfigured: boolean;
/** Called when the user clicks on the "Reset" button in order to clear the secret */
onReset: () => void;
/** If true, the text area will grow to fill available width. */
grow?: boolean;
};
export const CONFIGURED_TEXT = 'configured';
@@ -35,11 +37,11 @@ const getStyles = (theme: GrafanaTheme2) => {
*
* https://developers.grafana.com/ui/latest/index.html?path=/docs/inputs-secrettextarea--docs
*/
export const SecretTextArea = ({ isConfigured, onReset, ...props }: Props) => {
export const SecretTextArea = ({ isConfigured, onReset, grow, ...props }: Props) => {
const styles = useStyles2(getStyles);
return (
<Stack>
<Box>
<Box grow={grow ? 1 : undefined}>
{!isConfigured && <TextArea {...props} />}
{isConfigured && (
<TextArea
+18 -2
View File
@@ -166,8 +166,24 @@ func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) {
githubInfo = spec["github"].(map[string]any)
assert.Equal(t, "454546", githubInfo["installationID"], "installationID should be updated")
// DELETE
require.NoError(t, helper.Connections.Resource.Delete(ctx, "connection", metav1.DeleteOptions{}), "failed to delete resource")
// DELETE - Retry delete to handle resource version conflicts
// The controller may have updated the resource after our update, changing the resource version
require.Eventually(t, func() bool {
err := helper.Connections.Resource.Delete(ctx, "connection", metav1.DeleteOptions{})
if err != nil {
if k8serrors.IsConflict(err) {
// Resource version conflict - retry
return false
}
if k8serrors.IsNotFound(err) {
// Already deleted - success
return true
}
// Other error - fail the test
require.NoError(t, err, "failed to delete resource")
}
return true
}, 5*time.Second, 100*time.Millisecond, "should successfully delete resource")
list, err = helper.Connections.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err, "failed to list resources")
assert.Equal(t, 0, len(list.Items), "should have no connections")
+7 -3
View File
@@ -7,11 +7,12 @@ import (
"fmt"
"io"
"github.com/grafana/grafana/pkg/tsdb/tempo/traceql"
"google.golang.org/grpc/metadata"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/tracing"
"github.com/grafana/grafana/pkg/tsdb/tempo/kinds/dataquery"
"github.com/grafana/grafana/pkg/tsdb/tempo/traceql"
stream_utils "github.com/grafana/grafana/pkg/tsdb/tempo/utils"
"github.com/grafana/tempo/pkg/tempopb"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
@@ -63,7 +64,10 @@ func (s *Service) runMetricsStream(ctx context.Context, req *backend.RunStreamRe
qrr.Start = uint64(backendQuery.TimeRange.From.UnixNano())
qrr.End = uint64(backendQuery.TimeRange.To.UnixNano())
ctx = stream_utils.AppendHeadersToOutgoingContext(ctx, req)
// Setting the user agent for the gRPC call. When DS is decoupled we don't recreate instance when grafana config
// changes or updates, so we have to get it from context.
// Ideally this would be pushed higher, so it's set once for all rpc calls, but we have only one now.
ctx = metadata.AppendToOutgoingContext(ctx, "User-Agent", backend.UserAgentFromContext(ctx).String())
if isInstantQuery(tempoQuery.MetricsQueryType) {
instantQuery := &tempopb.QueryInstantRequest{
+6 -2
View File
@@ -7,11 +7,12 @@ import (
"fmt"
"io"
"google.golang.org/grpc/metadata"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/tracing"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/grafana/grafana/pkg/tsdb/tempo/kinds/dataquery"
stream_utils "github.com/grafana/grafana/pkg/tsdb/tempo/utils"
"github.com/grafana/tempo/pkg/tempopb"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
@@ -61,7 +62,10 @@ func (s *Service) runSearchStream(ctx context.Context, req *backend.RunStreamReq
sr.Start = uint32(backendQuery.TimeRange.From.Unix())
sr.End = uint32(backendQuery.TimeRange.To.Unix())
ctx = stream_utils.AppendHeadersToOutgoingContext(ctx, req)
// Setting the user agent for the gRPC call. When DS is decoupled we don't recreate instance when grafana config
// changes or updates, so we have to get it from context.
// Ideally this would be pushed higher, so it's set once for all rpc calls, but we have only one now.
ctx = metadata.AppendToOutgoingContext(ctx, "User-Agent", backend.UserAgentFromContext(ctx).String())
stream, err := datasource.StreamingClient.Search(ctx, sr)
if err != nil {
+5 -13
View File
@@ -6,7 +6,6 @@ import (
"strings"
"github.com/grafana/grafana-plugin-sdk-go/backend"
stream_utils "github.com/grafana/grafana/pkg/tsdb/tempo/utils"
)
func (s *Service) SubscribeStream(_ context.Context, req *backend.SubscribeStreamRequest) (*backend.SubscribeStreamResponse, error) {
@@ -40,18 +39,11 @@ func (s *Service) PublishStream(_ context.Context, _ *backend.PublishStreamReque
func (s *Service) RunStream(ctx context.Context, request *backend.RunStreamRequest, sender *backend.StreamSender) error {
s.logger.Debug("New stream call", "path", request.Path)
tempoDatasource, dsInfoErr := s.getDSInfo(ctx, request.PluginContext)
// get incoming and team http headers and append to stream request.
headers, err := stream_utils.SetHeadersFromIncomingContext(ctx)
if err != nil {
return err
}
request.Headers = headers
tempoDatasource, err := s.getDSInfo(ctx, request.PluginContext)
if strings.HasPrefix(request.Path, SearchPathPrefix) {
if dsInfoErr != nil {
return backend.DownstreamErrorf("failed to get datasource information: %w", dsInfoErr)
if err != nil {
return backend.DownstreamErrorf("failed to get datasource information: %w", err)
}
if err = s.runSearchStream(ctx, request, sender, tempoDatasource); err != nil {
return sendError(err, sender)
@@ -60,8 +52,8 @@ func (s *Service) RunStream(ctx context.Context, request *backend.RunStreamReque
}
}
if strings.HasPrefix(request.Path, MetricsPathPrefix) {
if dsInfoErr != nil {
return backend.DownstreamErrorf("failed to get datasource information: %w", dsInfoErr)
if err != nil {
return backend.DownstreamErrorf("failed to get datasource information: %w", err)
}
if err = s.runMetricsStream(ctx, request, sender, tempoDatasource); err != nil {
return sendError(err, sender)
-121
View File
@@ -1,121 +0,0 @@
package stream_utils
import (
"context"
"encoding/json"
"fmt"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"google.golang.org/grpc/metadata"
)
// Appends incoming request headers to the outgoing context to make sure none are lost when we make the request to tempo.
func AppendHeadersToOutgoingContext(ctx context.Context, req *backend.RunStreamRequest) context.Context {
// append all incoming headers
for key, value := range req.Headers {
ctx = metadata.AppendToOutgoingContext(ctx, key, value)
}
// Setting the user agent for the gRPC call. When DS is decoupled we don't recreate instance when grafana config
// changes or updates, so we have to get it from context.
// Ideally this would be pushed higher, so it's set once for all rpc calls, but we have only one now.
ctx = metadata.AppendToOutgoingContext(ctx, "User-Agent", backend.UserAgentFromContext(ctx).String())
return ctx
}
// When we receive a new query request we should make sure that all incoming HTTP headers are being forwarding to the grpc stream request
// this is to make sure that no headers are lost when we make the actual call to Tempo later on.
func SetHeadersFromIncomingContext(ctx context.Context) (map[string]string, error) {
// get the plugin from context
plugin := backend.PluginConfigFromContext(ctx)
// get the HTTP headers
teamHeaders, error := getTeamHTTPHeaders(plugin)
if error != nil {
return nil, error
}
// get the rest of the incoming headers
headers, err := getClientOptionsHeaders(ctx, plugin)
if err != nil {
return nil, err
}
for key, value := range teamHeaders {
headers[key] = value
}
return headers, nil
}
func getTeamHTTPHeaders(plugin backend.PluginContext) (map[string]string, error) {
headers := map[string]string{}
// Grab the JSON data from the datasource instance settings
jsonData := plugin.DataSourceInstanceSettings.JSONData
var data map[string]interface{}
err := json.Unmarshal(jsonData, &data)
if err != nil {
return nil, err
}
// fetch team http headers
if teamHttpHeaders, ok := data["teamHttpHeaders"]; ok {
// team headers have the following structure
// headers: [<team_id>: [{header: <header_name>, value: <header_value>}]]
// header_value is whatever the user has set under LBAC permissions for their given rule.
if lbacHeaders, ok := teamHttpHeaders.(map[string]interface{})["headers"]; ok {
headerMap := lbacHeaders.(map[string]interface{})
labelPolicyKey, labelPolicyValue := getLabelPolicyKeyValue(headerMap)
if labelPolicyKey != "" && labelPolicyValue != "" {
headers[labelPolicyKey] = labelPolicyValue
}
}
}
return headers, nil
}
func getLabelPolicyKeyValue(headerWithRules map[string]interface{}) (string, string) {
labelPolicyKey := ""
labelPolicyValue := ""
// we go through each teams' rule and ignoring the team, go through their set rules and prepare them to be all appended for the X-Prom-Label-Policy header value
// the result will be a comma separated list of the rules:
// "<rule_num>:<rule_value>, <rule_num>:<rule_value>"
for _, accessRuleValue := range headerWithRules {
rules := accessRuleValue.([]interface{})
for _, accessRule := range rules {
header := accessRule.(map[string]interface{})
for key, value := range header {
// for now, team headers only contain a single header key value, but in case in the future more are introduced, we make sure we only set the one we care about.
if key == "header" && value == "X-Prom-Label-Policy" {
labelPolicyKey = value.(string)
continue
}
if key == "value" {
if valueStr, ok := value.(string); ok {
if labelPolicyValue == "" {
labelPolicyValue = valueStr
} else {
labelPolicyValue += "," + valueStr
}
}
}
}
}
}
return labelPolicyKey, labelPolicyValue
}
func getClientOptionsHeaders(ctx context.Context, plugin backend.PluginContext) (map[string]string, error) {
headers := map[string]string{}
opts, err := plugin.DataSourceInstanceSettings.HTTPClientOptions(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get HTTP client options: %w", err)
}
for name, values := range opts.Header {
for _, value := range values {
headers[name] = value
}
}
return headers, nil
}
-149
View File
@@ -1,149 +0,0 @@
package stream_utils
import (
"context"
"testing"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/useragent"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/metadata"
)
func TestAppendHeadersToOutgoingContext_AppendsHeadersAndUserAgent(t *testing.T) {
ctx := context.TODO()
ua, err := useragent.New("10.0.0", "linux", "amd64")
require.NoError(t, err)
ctx = backend.WithUserAgent(ctx, ua)
ctx = metadata.NewOutgoingContext(ctx, metadata.Pairs("Existing", "one"))
req := &backend.RunStreamRequest{
Headers: map[string]string{
"X-Test": "value",
},
}
out := AppendHeadersToOutgoingContext(ctx, req)
outgoingMD, ok := metadata.FromOutgoingContext(out)
require.True(t, ok)
assert.Equal(t, []string{"value"}, outgoingMD.Get("x-test"))
assert.Equal(t, []string{ua.String()}, outgoingMD.Get("user-agent"))
assert.Equal(t, []string{"one"}, outgoingMD.Get("existing"))
}
func TestSetHeadersFromIncomingContext_MergesTeamAndClientHeaders(t *testing.T) {
jsonData := []byte(`{
"teamHttpHeaders": {
"headers": {
"101": [
{"header": "X-Prom-Label-Policy", "value": "1:team-value"},
{"header": "X-Prom-Label-Policy", "value": "2:team-wins"}
]
}
},
"httpHeaderName1": "X-Client",
"httpHeaderName2": "X-Shared"
}`)
pluginCtx := backend.PluginContext{
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{
JSONData: jsonData,
DecryptedSecureJSONData: map[string]string{
"httpHeaderValue1": "client-value",
"httpHeaderValue2": "client-overridden",
},
},
}
ctx := backend.WithPluginContext(context.Background(), pluginCtx)
headers, err := SetHeadersFromIncomingContext(ctx)
require.NoError(t, err)
expected := map[string]string{
"X-Client": "client-value",
"X-Prom-Label-Policy": "1:team-value,2:team-wins",
"X-Shared": "client-overridden",
}
assert.Equal(t, expected, headers)
}
func TestGetTeamHTTPHeaders_NoTeamHeaders(t *testing.T) {
pluginCtx := backend.PluginContext{
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{
JSONData: []byte(`{"httpHeaderName1": "X-Client"}`),
},
}
headers, err := getTeamHTTPHeaders(pluginCtx)
require.NoError(t, err)
assert.Empty(t, headers)
}
func TestGetTeamHTTPHeaders_LabelPolicyValue(t *testing.T) {
pluginCtx := backend.PluginContext{
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{
JSONData: []byte(`{
"teamHttpHeaders": {
"headers": {
"101": [
{"header": "X-Prom-Label-Policy", "value": "1:team-value"},
{"header": "X-Prom-Label-Policy", "value": "2:team-wins"}
]
}
}
}`),
},
}
headers, err := getTeamHTTPHeaders(pluginCtx)
require.NoError(t, err)
assert.Equal(t, map[string]string{
"X-Prom-Label-Policy": "1:team-value,2:team-wins",
}, headers)
}
func TestGetLabelPolicyKeyValue_AppendsValues(t *testing.T) {
headerWithRules := map[string]interface{}{
"101": []interface{}{
map[string]interface{}{
"header": "X-Prom-Label-Policy",
"value": "1:alpha",
},
map[string]interface{}{
"header": "X-Prom-Label-Policy",
"value": "2:beta",
},
},
}
key, value := getLabelPolicyKeyValue(headerWithRules)
assert.Equal(t, "X-Prom-Label-Policy", key)
assert.Equal(t, "1:alpha,2:beta", value)
}
func TestGetClientOptionsHeaders_ParsesHeaders(t *testing.T) {
pluginCtx := backend.PluginContext{
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{
JSONData: []byte(`{"httpHeaderName1": "X-Client"}`),
DecryptedSecureJSONData: map[string]string{
"httpHeaderValue1": "client-value",
},
},
}
headers, err := getClientOptionsHeaders(context.Background(), pluginCtx)
require.NoError(t, err)
assert.Equal(t, map[string]string{"X-Client": "client-value"}, headers)
}
func TestGetClientOptionsHeaders_InvalidJSON(t *testing.T) {
pluginCtx := backend.PluginContext{
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{
JSONData: []byte("{"),
},
}
_, err := getClientOptionsHeaders(context.Background(), pluginCtx)
require.Error(t, err)
}
@@ -11,6 +11,7 @@ import { t } from '@grafana/i18n';
import { isFetchError } from '@grafana/runtime';
import { clearFolders } from 'app/features/browse-dashboards/state/slice';
import { getState } from 'app/store/store';
import { ThunkDispatch } from 'app/types/store';
import { createSuccessNotification, createErrorNotification } from '../../../../core/copy/appNotification';
import { notifyApp } from '../../../../core/reducers/appNotification';
@@ -19,6 +20,26 @@ import { refetchChildren } from '../../../../features/browse-dashboards/state/ac
import { handleError } from '../../../utils';
import { createOnCacheEntryAdded } from '../utils/createOnCacheEntryAdded';
const handleProvisioningFormError = (e: unknown, dispatch: ThunkDispatch, title: string) => {
if (typeof e === 'object' && e && 'error' in e && isFetchError(e.error)) {
if (e.error.data.kind === 'Status' && e.error.data.status === 'Failure') {
const statusError: Status = e.error.data;
dispatch(notifyApp(createErrorNotification(title, new Error(statusError.message || 'Unknown error'))));
return;
}
if (Array.isArray(e.error.data.errors) && e.error.data.errors.length) {
const nonFieldErrors = e.error.data.errors.filter((err: ErrorDetails) => !err.field);
if (nonFieldErrors.length > 0) {
dispatch(notifyApp(createErrorNotification(title)));
}
return;
}
}
handleError(e, dispatch, title);
};
export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({
endpoints: {
listJob: {
@@ -37,6 +58,17 @@ export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({
}),
onCacheEntryAdded: createOnCacheEntryAdded<RepositorySpec, RepositoryStatus>('repositories'),
},
listConnection: {
providesTags: (result) =>
result
? [
{ type: 'Connection', id: 'LIST' },
...result.items
.map((connection) => ({ type: 'Connection' as const, id: connection.metadata?.name }))
.filter(Boolean),
]
: [{ type: 'Connection', id: 'LIST' }],
},
deleteRepository: {
onQueryStarted: async (_, { queryFulfilled, dispatch }) => {
try {
@@ -104,34 +136,7 @@ export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({
try {
await queryFulfilled;
} catch (e) {
// Handle special cases first
if (typeof e === 'object' && e && 'error' in e && isFetchError(e.error)) {
// Handle Status error responses (Kubernetes style)
if (e.error.data.kind === 'Status' && e.error.data.status === 'Failure') {
const statusError: Status = e.error.data;
dispatch(
notifyApp(
createErrorNotification(
'Error validating repository',
new Error(statusError.message || 'Unknown error')
)
)
);
return;
}
// Handle TestResults error responses with field errors
if (Array.isArray(e.error.data.errors) && e.error.data.errors.length) {
const nonFieldErrors = e.error.data.errors.filter((err: ErrorDetails) => !err.field);
// Only show notification if there are errors that don't have a field, field errors are handled by the form
if (nonFieldErrors.length > 0) {
dispatch(notifyApp(createErrorNotification('Error validating repository')));
}
return;
}
}
// For all other cases, use handleError
handleError(e, dispatch, 'Error validating repository');
handleProvisioningFormError(e, dispatch, 'Error validating repository');
}
},
},
@@ -240,6 +245,70 @@ export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({
}
},
},
createConnection: {
onQueryStarted: async (_, { queryFulfilled, dispatch }) => {
try {
await queryFulfilled;
dispatch(
notifyApp(
createSuccessNotification(t('provisioning.connection-form.alert-connection-saved', 'Connection saved'))
)
);
} catch (e) {
handleProvisioningFormError(
e,
dispatch,
t('provisioning.connection-form.error-save-connection', 'Failed to save connection')
);
}
},
},
replaceConnection: {
onQueryStarted: async (_, { queryFulfilled, dispatch }) => {
try {
await queryFulfilled;
dispatch(
notifyApp(
createSuccessNotification(
t('provisioning.connection-form.alert-connection-updated', 'Connection updated')
)
)
);
} catch (e) {
handleProvisioningFormError(
e,
dispatch,
t('provisioning.connection-form.error-save-connection', 'Failed to save connection')
);
}
},
},
deleteConnection: {
invalidatesTags: (result, error) => (error ? [] : [{ type: 'Connection', id: 'LIST' }]),
onQueryStarted: async (_, { queryFulfilled, dispatch }) => {
try {
await queryFulfilled;
dispatch(
notifyApp(
createSuccessNotification(
t('provisioning.connection-form.alert-connection-deleted', 'Connection deleted')
)
)
);
} catch (e) {
if (e instanceof Error) {
dispatch(
notifyApp(
createErrorNotification(
t('provisioning.connection-form.error-delete-connection', 'Failed to delete connection'),
e
)
)
);
}
}
},
},
},
});
@@ -4,8 +4,8 @@ import * as React from 'react';
import SplitPane, { Split } from 'react-split-pane';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { getDragStyles } from '@grafana/ui';
import { config } from 'app/core/config';
interface Props {
splitOrientation?: Split;
-1
View File
@@ -1,6 +1,5 @@
import { PluginState } from '@grafana/data';
import { config, GrafanaBootConfig } from '@grafana/runtime';
export { config, type GrafanaBootConfig as Settings };
let grafanaConfig: GrafanaBootConfig = config;
@@ -2,7 +2,7 @@ import deepEqual from 'fast-deep-equal';
import memoize from 'micro-memoize';
import { getLanguage } from '@grafana/i18n/internal';
import { config } from 'app/core/config';
import { config } from '@grafana/runtime';
const deepMemoize: typeof memoize = (fn) => memoize(fn, { isEqual: deepEqual });
+1 -2
View File
@@ -1,8 +1,7 @@
import { getThemeById } from '@grafana/data/internal';
import { ThemeChangedEvent } from '@grafana/runtime';
import { config, ThemeChangedEvent } from '@grafana/runtime';
import { appEvents } from '../app_events';
import { config } from '../config';
import { contextSrv } from '../services/context_srv';
import { PreferencesService } from './PreferencesService';
+1 -1
View File
@@ -1,7 +1,7 @@
import { Navigate } from 'react-router-dom-v5-compat';
import { config } from '@grafana/runtime';
import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport';
import { config } from 'app/core/config';
import { GrafanaRouteComponent, RouteDescriptor } from 'app/core/navigation/types';
import { AccessControlAction } from 'app/types/accessControl';
@@ -1,4 +1,4 @@
import { config } from 'app/core/config';
import { config } from '@grafana/runtime';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
export const hiddenReducerTypes = ['percent_diff', 'percent_diff_abs'];
@@ -8,8 +8,8 @@ import {
ThresholdsMode,
isTimeSeriesFrames,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { GraphThresholdsStyleMode } from '@grafana/schema';
import { config } from 'app/core/config';
import { EvalFunction } from 'app/features/alerting/state/alertDef';
import { isExpressionQuery } from 'app/features/expressions/guards';
import { ClassicCondition, ExpressionQueryType } from 'app/features/expressions/types';
@@ -18,7 +18,7 @@ import {
standardTransformers,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from 'app/core/config';
import { config } from '@grafana/runtime';
export const standardAnnotationSupport: AnnotationSupport = {
/**
@@ -3,10 +3,9 @@ import { connect, ConnectedProps } from 'react-redux';
import { GrafanaEdition } from '@grafana/data/internal';
import { Trans } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { config, reportInteraction } from '@grafana/runtime';
import { Grid, TextLink, ToolbarButton } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { config } from 'app/core/config';
import { StoreState } from 'app/types/store';
import { isOpenSourceBuildOrUnlicenced } from '../admin/EnterpriseAuthFeaturesCard';
+1 -1
View File
@@ -2,8 +2,8 @@ import { ComponentType } from 'react';
import { DataLink, RegistryItem, Action } from '@grafana/data';
import { PanelOptionsSupplier } from '@grafana/data/internal';
import { config } from '@grafana/runtime';
import { ColorDimensionConfig, ScaleDimensionConfig, DirectionDimensionConfig } from '@grafana/schema';
import { config } from 'app/core/config';
import { BackgroundConfig, Constraint, LineConfig, Placement } from 'app/plugins/panel/canvas/panelcfg.gen';
import { LineStyleConfig } from '../../plugins/panel/canvas/editor/LineStyleEditor';
@@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from 'app/core/config';
import { config } from '@grafana/runtime';
import { DimensionContext } from 'app/features/dimensions/context';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
import { TextDimensionEditor } from 'app/features/dimensions/editors/TextDimensionEditor';
@@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from 'app/core/config';
import { config } from '@grafana/runtime';
import { DimensionContext } from 'app/features/dimensions/context';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
import { TextDimensionEditor } from 'app/features/dimensions/editors/TextDimensionEditor';
@@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from 'app/core/config';
import { config } from '@grafana/runtime';
import { DimensionContext } from 'app/features/dimensions/context';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
import { TextDimensionEditor } from 'app/features/dimensions/editors/TextDimensionEditor';
@@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from 'app/core/config';
import { config } from '@grafana/runtime';
import { DimensionContext } from 'app/features/dimensions/context';
import { ColorDimensionEditor } from 'app/features/dimensions/editors/ColorDimensionEditor';
import { TextDimensionEditor } from 'app/features/dimensions/editors/TextDimensionEditor';
@@ -14,10 +14,10 @@ import {
ActionType,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { TooltipDisplayMode } from '@grafana/schema';
import { ConfirmModal, VariablesInputModal } from '@grafana/ui';
import { LayerElement } from 'app/core/components/Layers/types';
import { config } from 'app/core/config';
import { notFoundItem } from 'app/features/canvas/elements/notFound';
import { DimensionContext } from 'app/features/dimensions/context';
import {
+1 -2
View File
@@ -6,7 +6,7 @@ import { BehaviorSubject, ReplaySubject, Subject, Subscription } from 'rxjs';
import Selecto from 'selecto';
import { AppEvents, PanelData, OneClickMode, ActionType } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { config, locationService } from '@grafana/runtime';
import {
ColorDimensionConfig,
ResourceDimensionConfig,
@@ -17,7 +17,6 @@ import {
DirectionDimensionConfig,
} from '@grafana/schema';
import { Portal } from '@grafana/ui';
import { config } from 'app/core/config';
import { DimensionContext } from 'app/features/dimensions/context';
import {
getColorDimensionFromData,
@@ -2,7 +2,7 @@ import InfiniteViewer from 'infinite-viewer';
import Moveable from 'moveable';
import Selecto from 'selecto';
import { config } from 'app/core/config';
import { config } from '@grafana/runtime';
import { CONNECTION_ANCHOR_DIV_ID } from 'app/plugins/panel/canvas/components/connections/ConnectionAnchors';
import {
CONNECTION_VERTEX_ID,
@@ -18,6 +18,7 @@ import {
NewObjectAddedToCanvasEvent,
ObjectRemovedFromCanvasEvent,
ObjectsReorderedOnCanvasEvent,
RepeatsUpdatedEvent,
} from './shared';
export interface DashboardEditPaneState extends SceneObjectState {
@@ -87,6 +88,12 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
})
);
this._subs.add(
dashboard.subscribeToEvent(RepeatsUpdatedEvent, () => {
this.forceRender();
})
);
if (this.panelEditAction) {
this.performPanelEditAction(this.panelEditAction);
this.panelEditAction = undefined;
@@ -57,12 +57,10 @@ function DashboardOutlineNode({ sceneObject, editPane, isEditing, depth, index }
const instanceName = elementInfo.instanceName === '' ? noTitleText : elementInfo.instanceName;
const outlineRename = useOutlineRename(editableElement, isEditing);
const isContainer = editableElement.getOutlineChildren ? true : false;
const visibleChildren = useMemo(() => {
const children = editableElement.getOutlineChildren?.(isEditing) ?? [];
return isEditing
? children
: children.filter((child) => !getEditableElementFor(child)?.getEditableElementInfo().isHidden);
}, [editableElement, isEditing]);
const outlineChildren = editableElement.getOutlineChildren?.(isEditing) ?? [];
const visibleChildren = isEditing
? outlineChildren
: outlineChildren.filter((child) => !getEditableElementFor(child)?.getEditableElementInfo().isHidden);
const onNodeClicked = (e: React.MouseEvent) => {
e.stopPropagation();
@@ -258,7 +256,6 @@ function getStyles(theme: GrafanaTheme2) {
}),
nodeButtonClone: css({
color: theme.colors.text.secondary,
cursor: 'not-allowed',
}),
outlineInput: css({
border: `1px solid ${theme.components.input.borderColor}`,
@@ -84,6 +84,10 @@ export class ConditionalRenderingChangedEvent extends BusEventWithPayload<SceneO
static type = 'conditional-rendering-changed';
}
export class RepeatsUpdatedEvent extends BusEventWithPayload<SceneObject> {
static type = 'repeats-updated';
}
export interface DashboardEditActionEventPayload {
removedObject?: SceneObject;
addedObject?: SceneObject;
@@ -14,7 +14,7 @@ import {
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { ConditionalRenderingGroup } from '../../conditional-rendering/group/ConditionalRenderingGroup';
import { DashboardStateChangedEvent } from '../../edit-pane/shared';
import { DashboardStateChangedEvent, RepeatsUpdatedEvent } from '../../edit-pane/shared';
import { getCloneKey, getLocalVariableValueSet } from '../../utils/clone';
import { getMultiVariableValues } from '../../utils/utils';
import { scrollCanvasElementIntoView } from '../layouts-shared/scrollCanvasElementIntoView';
@@ -147,6 +147,7 @@ export class AutoGridItem extends SceneObjectBase<AutoGridItemState> implements
this.setState({ repeatedPanels, repeatedConditionalRendering });
this._prevRepeatValues = values;
this.publishEvent(new RepeatsUpdatedEvent(this), true);
}
public getPanelCount() {
@@ -17,7 +17,7 @@ import {
import { GRID_COLUMN_COUNT } from 'app/core/constants';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { DashboardStateChangedEvent } from '../../edit-pane/shared';
import { DashboardStateChangedEvent, RepeatsUpdatedEvent } from '../../edit-pane/shared';
import { getCloneKey, getLocalVariableValueSet } from '../../utils/clone';
import { getMultiVariableValues } from '../../utils/utils';
import { scrollCanvasElementIntoView, scrollIntoView } from '../layouts-shared/scrollCanvasElementIntoView';
@@ -219,6 +219,7 @@ export class DashboardGridItem
}
this._prevRepeatValues = values;
this.publishEvent(new RepeatsUpdatedEvent(this), true);
}
public handleVariableName() {
@@ -1,11 +1,11 @@
import { render, screen } from '@testing-library/react';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { config } from '@grafana/runtime';
import { SceneTimeRange, VizPanel } from '@grafana/scenes';
import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction } from 'app/types/accessControl';
import { config } from '../../../../core/config';
import { grantUserPermissions } from '../../../alerting/unified/mocks';
import { DashboardScene, DashboardSceneState } from '../../scene/DashboardScene';
import { DefaultGridLayoutManager } from '../../scene/layout-default/DefaultGridLayoutManager';
@@ -1,8 +1,8 @@
import * as React from 'react';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { Modal, ModalTabsHeader, TabContent, Themeable2, withTheme2 } from '@grafana/ui';
import { config } from 'app/core/config';
import { contextSrv } from 'app/core/services/context_srv';
import { SharePublicDashboard } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard';
import { isPublicDashboardsEnabled } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
@@ -4,7 +4,7 @@ const mockPushMeasurement = jest.fn();
import { PanelLoadTimeMonitor } from './PanelLoadTimeMonitor';
jest.mock('app/core/config', () => ({
jest.mock('@grafana/runtime', () => ({
config: {
grafanaJavascriptAgent: {
enabled: true,
@@ -1,7 +1,7 @@
import { useEffect } from 'react';
import { faro } from '@grafana/faro-web-sdk';
import { config } from 'app/core/config';
import { config } from '@grafana/runtime';
import { PanelLogEvents } from 'app/core/log_events';
interface Props {
@@ -12,7 +12,7 @@ jest.mock('@grafana/faro-web-sdk', () => ({
},
}));
jest.mock('app/core/config', () => ({
jest.mock('@grafana/runtime', () => ({
config: {
grafanaJavascriptAgent: {
enabled: true,
@@ -1,6 +1,6 @@
import { FieldConfigSource } from '@grafana/data';
import { faro } from '@grafana/faro-web-sdk';
import { config } from 'app/core/config';
import { config } from '@grafana/runtime';
import { FIELD_CONFIG_CUSTOM_KEY, FIELD_CONFIG_OVERRIDES_KEY, PanelLogEvents } from 'app/core/log_events';
interface PanelLogInfo {
+1 -1
View File
@@ -1,7 +1,7 @@
import { config } from '@grafana/runtime';
import { DashboardRoutes } from 'app/types/dashboard';
import { SafeDynamicImport } from '../../core/components/DynamicImports/SafeDynamicImport';
import { config } from '../../core/config';
import { RouteDescriptor } from '../../core/navigation/types';
export const getPublicDashboardRoutes = (): RouteDescriptor[] => {
@@ -13,10 +13,9 @@ import {
dateTimeForTimeZone,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { locationService } from '@grafana/runtime';
import { config, locationService } from '@grafana/runtime';
import { sceneGraph } from '@grafana/scenes';
import { appEvents } from 'app/core/app_events';
import { config } from 'app/core/config';
import { AutoRefreshInterval, contextSrv, ContextSrv } from 'app/core/services/context_srv';
import {
getCopiedTimeRange,
@@ -2,8 +2,8 @@ import { each, map } from 'lodash';
import { DataLinkBuiltInVars, MappingType, VariableHide } from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test';
import { config } from '@grafana/runtime';
import { FieldConfigSource } from '@grafana/schema';
import { config } from 'app/core/config';
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants';
import { mockDataSource } from 'app/features/alerting/unified/mocks';
import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources';
@@ -6,7 +6,7 @@ import {
LoadingState,
PanelData,
} from '@grafana/data';
import { config } from 'app/core/config';
import { config } from '@grafana/runtime';
import { SnapshotWorker } from '../../query/state/DashboardQueryRunner/SnapshotWorker';
import { getTimeSrv } from '../services/TimeSrv';
@@ -6,9 +6,8 @@ import { useRef, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { getBackendSrv } from '@grafana/runtime';
import { config, getBackendSrv } from '@grafana/runtime';
import { Button, useStyles2 } from '@grafana/ui';
import { config } from 'app/core/config';
import { MediaType, PickerTabType, ResourceFolderName } from '../types';
@@ -3,10 +3,9 @@ import { memo, useState } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { applyFieldOverrides, DataFrame, SelectableValue, SplitOpen } from '@grafana/data';
import { getTemplateSrv, reportInteraction } from '@grafana/runtime';
import { config, getTemplateSrv, reportInteraction } from '@grafana/runtime';
import { TimeZone } from '@grafana/schema';
import { RadioButtonGroup, Table, AdHocFilterItem, PanelChrome } from '@grafana/ui';
import { config } from 'app/core/config';
import { PANEL_BORDER } from 'app/core/constants';
import { ExploreItemState, TABLE_RESULTS_STYLE, TABLE_RESULTS_STYLES, TableResultsStyle } from 'app/types/explore';
import { StoreState } from 'app/types/store';
@@ -13,10 +13,9 @@ import {
EventBusSrv,
} from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { getTemplateSrv, PanelRenderer } from '@grafana/runtime';
import { config, getTemplateSrv, PanelRenderer } from '@grafana/runtime';
import { TimeZone } from '@grafana/schema';
import { AdHocFilterItem, PanelChrome, withTheme2, Themeable2, PanelContextProvider } from '@grafana/ui';
import { config } from 'app/core/config';
import {
hasDeprecatedParentRowIndex,
migrateFromParentRowIndexToNestedFrames,
@@ -22,7 +22,7 @@ import {
PluginExtensionPoints,
PluginExtensionTypes,
} from '@grafana/data';
import { usePluginLinks, usePluginComponents } from '@grafana/runtime';
import { usePluginLinks, usePluginComponents, config } from '@grafana/runtime';
import { DEFAULT_SPAN_FILTERS } from 'app/features/explore/state/constants';
import { TraceViewPluginExtensionContext } from '../types/trace';
@@ -47,13 +47,6 @@ jest.mock('app/core/copy/appNotification', () => ({
})),
}));
// Mock config
jest.mock('../../../../../core/config', () => ({
config: {
feedbackLinksEnabled: false, // Default to false to avoid interference with tests
},
}));
// Mock navigator.clipboard
Object.assign(navigator, {
clipboard: {
@@ -127,6 +120,7 @@ describe('TracePageHeader test', () => {
beforeEach(() => {
jest.clearAllMocks();
mockWindowOpen.mockClear();
config.feedbackLinksEnabled = false; // Default to false to avoid interference with tests
});
it('should render the new trace header', () => {
@@ -438,9 +432,7 @@ describe('TracePageHeader test', () => {
});
it('should render feedback button when feedbackLinksEnabled is true', () => {
// Mock config with feedbackLinksEnabled = true
const mockConfig = require('../../../../../core/config');
mockConfig.config.feedbackLinksEnabled = true;
config.feedbackLinksEnabled = true;
setup();
@@ -453,9 +445,7 @@ describe('TracePageHeader test', () => {
it('should display tooltip for feedback button', async () => {
const user = userEvent.setup();
// Mock config with feedbackLinksEnabled = true
const mockConfig = require('../../../../../core/config');
mockConfig.config.feedbackLinksEnabled = true;
config.feedbackLinksEnabled = true;
setup();
@@ -469,9 +459,7 @@ describe('TracePageHeader test', () => {
});
it('should render feedback button with correct styling and icon', () => {
// Mock config with feedbackLinksEnabled = true
const mockConfig = require('../../../../../core/config');
mockConfig.config.feedbackLinksEnabled = true;
config.feedbackLinksEnabled = true;
setup();
@@ -26,7 +26,13 @@ import {
PluginExtensionPoints,
} from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { reportInteraction, renderLimitedComponents, usePluginComponents, usePluginLinks } from '@grafana/runtime';
import {
reportInteraction,
renderLimitedComponents,
usePluginComponents,
usePluginLinks,
config,
} from '@grafana/runtime';
import { AdHocFiltersComboboxRenderer } from '@grafana/scenes';
import { TimeZone } from '@grafana/schema';
import {
@@ -46,7 +52,6 @@ import {
} from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification';
import { config } from '../../../../../core/config';
import { downloadTraceAsJson } from '../../../../inspector/utils/download';
import { ViewRangeTimeUpdate, TUpdateViewRangeTimeFunction, ViewRange } from '../TraceTimelineViewer/types';
import { getHeaderTags, getTraceName } from '../model/trace-viewer';
+1 -1
View File
@@ -1,5 +1,5 @@
import { DataQuery, ReducerID, SelectableValue } from '@grafana/data';
import { config } from 'app/core/config';
import { config } from '@grafana/runtime';
import { EvalFunction } from '../alerting/state/alertDef';
@@ -15,9 +15,8 @@ import {
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { getTemplateSrv, reportInteraction } from '@grafana/runtime';
import { config, getTemplateSrv, reportInteraction } from '@grafana/runtime';
import { Button, Spinner, Table } from '@grafana/ui';
import { config } from 'app/core/config';
import { GetDataOptions } from 'app/features/query/state/PanelQueryRunner';
import { dataFrameToLogsModel } from '../logs/logsModel';
+1 -1
View File
@@ -1,8 +1,8 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { stylesFactory } from '@grafana/ui';
import { config } from 'app/core/config';
/** @deprecated */
export const getPanelInspectorStyles = stylesFactory(() => {
@@ -3,6 +3,7 @@ import {
ConnectedContext,
ConnectingContext,
DisconnectedContext,
ErrorContext,
ServerPublicationContext,
State,
} from 'centrifuge';
@@ -25,6 +26,7 @@ import {
StreamingFrameAction,
StreamingFrameOptions,
BackendDataSourceResponse,
getBackendSrv,
} from '@grafana/runtime';
import { StreamingResponseData } from '../data/utils';
@@ -71,6 +73,7 @@ export class CentrifugeService implements CentrifugeSrv {
readonly connectionState: BehaviorSubject<boolean>;
readonly connectionBlocker: Promise<void>;
private readonly dataStreamSubscriberReadiness: Observable<boolean>;
private lastAuthCheck = 0;
constructor(private deps: CentrifugeSrvDeps) {
this.dataStreamSubscriberReadiness = deps.dataStreamSubscriberReadiness.pipe(share(), startWith(true));
@@ -106,6 +109,7 @@ export class CentrifugeService implements CentrifugeSrv {
this.centrifuge.on('connecting', this.onDisconnect);
this.centrifuge.on('disconnected', this.onDisconnect);
this.centrifuge.on('publication', this.onServerSideMessage);
this.centrifuge.on('error', this.onError);
}
//----------------------------------------------------------
@@ -124,6 +128,27 @@ export class CentrifugeService implements CentrifugeSrv {
console.log('Publication from server-side channel', context);
};
private onError = (context: ErrorContext) => {
/**
* This is a workaround to handle the case where the authentication token
* has expired, but we still try to reconnect inside a page with Grafana Live enabled.
* See: https://github.com/grafana/grafana/issues/72792
*/
if (context.type === 'transport' && context.error?.code === 2) {
const now = Date.now();
// Check every 5 seconds to avoid hammering the
// API if there is a case like this
if (now - this.lastAuthCheck > 5000) {
this.lastAuthCheck = now;
getBackendSrv()
.get('/api/login/ping')
.catch(() => {
// Just swallow this error - it's non-critical
});
}
}
};
/**
* Get a channel. If the scope, namespace, or path is invalid, a shutdown
* channel will be returned with an error state indicated in its status
+1 -1
View File
@@ -1,5 +1,5 @@
import { PanelPluginMeta, PluginState, unEscapeStringFromRegex } from '@grafana/data';
import { config } from 'app/core/config';
import { config } from '@grafana/runtime';
export function getAllPanelPluginMeta(): PanelPluginMeta[] {
const allPanels = config.panels;
@@ -11,6 +11,7 @@ import {
toDataFrame,
VisualizationSuggestionScore,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import {
BarGaugeDisplayMode,
BigValueColorMode,
@@ -20,7 +21,6 @@ import {
VizOrientation,
} from '@grafana/schema';
import { appEvents } from 'app/core/app_events';
import { config } from 'app/core/config';
import { clearPanelPluginCache } from 'app/features/plugins/importPanelPlugin';
import { pluginImporter } from 'app/features/plugins/importer/pluginImporter';
@@ -11,7 +11,11 @@ export const usePluginConfig = (plugin?: CatalogPlugin) => {
return null;
}
const isPluginInstalled = config.pluginAdminExternalManageEnabled ? plugin.isFullyInstalled : plugin.isInstalled;
// On Cloud, check both isFullyInstalled (for multi-instance setup) and isInstalled (fallback for single instance)
// This ensures tabs show even if instance data hasn't fully loaded
const isPluginInstalled = config.pluginAdminExternalManageEnabled
? plugin.isFullyInstalled || plugin.isInstalled
: plugin.isInstalled;
if (isPluginInstalled && !plugin.isDisabled) {
return loadPlugin(plugin.id);
@@ -3,7 +3,6 @@ import { from, forkJoin, timeout, lastValueFrom, catchError, of } from 'rxjs';
import { PanelPlugin, PluginError } from '@grafana/data';
import { config, getBackendSrv, isFetchError } from '@grafana/runtime';
import { Settings } from 'app/core/config';
import { importPanelPlugin } from 'app/features/plugins/importPanelPlugin';
import { StoreState, ThunkResult } from 'app/types/store';
@@ -301,7 +300,7 @@ export const loadPanelPlugin = (id: string): ThunkResult<Promise<PanelPlugin>> =
function updatePanels() {
return getBackendSrv()
.get('/api/frontend/settings')
.then((settings: Settings) => {
.then((settings) => {
config.panels = settings.panels;
});
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { uniq } from 'lodash';
import { config } from '@grafana/runtime';
import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport';
import { config } from 'app/core/config';
import { RouteDescriptor } from 'app/core/navigation/types';
const profileRoutes: RouteDescriptor[] = [
@@ -57,6 +57,7 @@ export function ConfigForm({ data }: ConfigFormProps) {
const repositoryName = data?.metadata?.name;
const settings = useGetFrontendSettingsQuery();
const [submitData, request] = useCreateOrUpdateRepository(repositoryName);
const navigate = useNavigate();
const {
register,
handleSubmit,
@@ -77,7 +78,6 @@ export function ConfigForm({ data }: ConfigFormProps) {
const isEdit = Boolean(repositoryName);
const [tokenConfigured, setTokenConfigured] = useState(isEdit);
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate();
const [type, readOnly] = watch(['type', 'readOnly']);
const targetOptions = useMemo(() => getTargetOptions(settings.data?.allowedTargets || ['folder']), [settings.data]);
const isGitBased = isGitProvider(type);
@@ -104,17 +104,6 @@ export function ConfigForm({ data }: ConfigFormProps) {
const localFields = type === 'local' ? getLocalProviderFields(type) : null;
const hasTokenInstructions = getHasTokenInstructions(type);
// TODO: this should be removed after 12.2 is released
useEffect(() => {
if (isGitBased && !data?.secure?.token) {
setTokenConfigured(false);
setError('token', {
type: 'manual',
message: `Enter your ${gitFields?.tokenConfig.label ?? 'access token'}`,
});
}
}, [data, gitFields, setTokenConfigured, setError, isGitBased]);
useEffect(() => {
if (request.isSuccess) {
const formData = getValues();
@@ -126,11 +115,9 @@ export function ConfigForm({ data }: ConfigFormProps) {
});
reset(formData);
setTimeout(() => {
navigate('/admin/provisioning');
}, 300);
setTimeout(() => navigate(PROVISIONING_URL), 300);
}
}, [request.isSuccess, reset, getValues, navigate, repositoryName]);
}, [request.isSuccess, reset, getValues, repositoryName, navigate]);
const onSubmit = async (form: RepositoryFormData) => {
setIsLoading(true);
@@ -0,0 +1,277 @@
import { QueryStatus } from '@reduxjs/toolkit/query';
import { render, screen, waitFor } from 'test/test-utils';
import { Connection } from 'app/api/clients/provisioning/v0alpha1';
import { useCreateOrUpdateConnection } from '../hooks/useCreateOrUpdateConnection';
import { ConnectionForm } from './ConnectionForm';
jest.mock('../hooks/useCreateOrUpdateConnection', () => ({
useCreateOrUpdateConnection: jest.fn(),
}));
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
reportInteraction: jest.fn(),
}));
const mockSubmitData = jest.fn();
const mockUseCreateOrUpdateConnection = useCreateOrUpdateConnection as jest.MockedFunction<
typeof useCreateOrUpdateConnection
>;
type MockRequestState = {
status: QueryStatus;
isLoading: boolean;
isSuccess: boolean;
isError: boolean;
error?: unknown;
reset: jest.Mock;
};
const createMockRequestState = (overrides: Partial<MockRequestState> = {}): MockRequestState => ({
status: QueryStatus.uninitialized,
isLoading: false,
isSuccess: false,
isError: false,
reset: jest.fn(),
...overrides,
});
const createMockConnection = (overrides: Partial<Connection> = {}): Connection => ({
metadata: { name: 'test-connection' },
spec: {
type: 'github',
url: 'https://github.com/settings/installations/12345678',
github: {
appID: '123456',
installationID: '12345678',
},
},
secure: {
privateKey: { name: 'configured' },
},
status: {
state: 'connected',
health: { healthy: true },
observedGeneration: 1,
},
...overrides,
});
interface SetupOptions {
data?: Connection;
requestState?: Partial<MockRequestState>;
}
function setup(options: SetupOptions = {}) {
const { data, requestState = {} } = options;
mockUseCreateOrUpdateConnection.mockReturnValue([
mockSubmitData,
createMockRequestState(requestState) as unknown as ReturnType<typeof useCreateOrUpdateConnection>[1],
]);
return {
mockSubmitData,
...render(<ConnectionForm data={data} />),
};
}
describe('ConnectionForm', () => {
beforeEach(() => {
jest.clearAllMocks();
mockSubmitData.mockResolvedValue(undefined);
});
describe('Rendering - Create Mode', () => {
it('should render all form fields', () => {
setup();
expect(screen.getByLabelText(/^Provider/)).toBeInTheDocument();
expect(screen.getByLabelText(/^GitHub App ID/)).toBeInTheDocument();
expect(screen.getByLabelText(/^GitHub Installation ID/)).toBeInTheDocument();
expect(screen.getByLabelText(/^Private Key \(PEM\)/)).toBeInTheDocument();
});
it('should render Save button', () => {
setup();
expect(screen.getByRole('button', { name: /^save$/i })).toBeInTheDocument();
});
it('should not render Delete button in create mode', () => {
setup();
expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument();
});
it('should have Provider field disabled', () => {
setup();
expect(screen.getByLabelText(/^Provider/)).toBeDisabled();
});
});
describe('Rendering - Edit Mode', () => {
it('should populate form fields with existing connection data', () => {
setup({ data: createMockConnection() });
expect(screen.getByLabelText(/^GitHub App ID/)).toHaveValue('123456');
expect(screen.getByLabelText(/^GitHub Installation ID/)).toHaveValue('12345678');
});
it('should render Delete button in edit mode', () => {
setup({ data: createMockConnection() });
expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
});
it('should show configured state for private key', () => {
setup({ data: createMockConnection() });
expect(screen.getByLabelText(/^Private Key \(PEM\)/)).toHaveValue('configured');
});
});
describe('Form Validation', () => {
it('should show required error and not submit when fields are empty', async () => {
const { user, mockSubmitData } = setup();
const saveButton = screen.getByRole('button', { name: /^save$/i });
await user.click(saveButton);
await waitFor(() => {
expect(screen.getAllByText('This field is required')).toHaveLength(3);
});
expect(mockSubmitData).not.toHaveBeenCalled();
});
});
describe('Form Submission - Create', () => {
it('should call submitData with correct data on valid submission', async () => {
const { user, mockSubmitData } = setup();
await user.type(screen.getByLabelText(/^GitHub App ID/), '123456');
await user.type(screen.getByLabelText(/^GitHub Installation ID/), '12345678');
await user.type(screen.getByLabelText(/^Private Key \(PEM\)/), '-----BEGIN RSA PRIVATE KEY-----');
const saveButton = screen.getByRole('button', { name: /^save$/i });
await user.click(saveButton);
await waitFor(() => {
expect(mockSubmitData).toHaveBeenCalledWith(
{
type: 'github',
github: {
appID: '123456',
installationID: '12345678',
},
},
'-----BEGIN RSA PRIVATE KEY-----'
);
});
});
});
describe('Form Submission - Edit', () => {
it('should allow submission without changing private key', async () => {
const { user, mockSubmitData } = setup({ data: createMockConnection() });
const saveButton = screen.getByRole('button', { name: /^save$/i });
await user.click(saveButton);
await waitFor(() => {
expect(mockSubmitData).toHaveBeenCalledWith(
{
type: 'github',
github: {
appID: '123456',
installationID: '12345678',
},
},
'configured'
);
});
});
});
describe('Loading State', () => {
it('should disable Save button while loading', () => {
setup({ requestState: { isLoading: true } });
const saveButton = screen.getByRole('button', { name: /saving/i });
expect(saveButton).toBeDisabled();
});
it('should show "Saving..." text while loading', () => {
setup({ requestState: { isLoading: true } });
expect(screen.getByText('Saving...')).toBeInTheDocument();
});
});
describe('Error Handling', () => {
it('should map API error for appID to form field', async () => {
const { user, mockSubmitData } = setup();
mockSubmitData.mockRejectedValue({
status: 400,
data: { errors: [{ field: 'appID', detail: 'Invalid App ID' }] },
});
await user.type(screen.getByLabelText(/^GitHub App ID/), '123456');
await user.type(screen.getByLabelText(/^GitHub Installation ID/), '12345678');
await user.type(screen.getByLabelText(/^Private Key \(PEM\)/), '-----BEGIN RSA PRIVATE KEY-----');
const saveButton = screen.getByRole('button', { name: /^save$/i });
await user.click(saveButton);
await waitFor(() => {
expect(screen.getByText('Invalid App ID')).toBeInTheDocument();
});
});
it('should map API error for installationID to form field', async () => {
const { user, mockSubmitData } = setup();
mockSubmitData.mockRejectedValue({
status: 400,
data: { errors: [{ field: 'installationID', detail: 'Invalid Installation ID' }] },
});
await user.type(screen.getByLabelText(/^GitHub App ID/), '123456');
await user.type(screen.getByLabelText(/^GitHub Installation ID/), '12345678');
await user.type(screen.getByLabelText(/^Private Key \(PEM\)/), '-----BEGIN RSA PRIVATE KEY-----');
const saveButton = screen.getByRole('button', { name: /^save$/i });
await user.click(saveButton);
await waitFor(() => {
expect(screen.getByText('Invalid Installation ID')).toBeInTheDocument();
});
});
it('should map API error for privateKey to form field', async () => {
const { user, mockSubmitData } = setup();
mockSubmitData.mockRejectedValue({
status: 400,
data: { errors: [{ field: 'secure.privateKey', detail: 'Invalid Private Key format' }] },
});
await user.type(screen.getByLabelText(/^GitHub App ID/), '123456');
await user.type(screen.getByLabelText(/^GitHub Installation ID/), '12345678');
await user.type(screen.getByLabelText(/^Private Key \(PEM\)/), 'invalid-key');
const saveButton = screen.getByRole('button', { name: /^save$/i });
await user.click(saveButton);
await waitFor(() => {
expect(screen.getByText('Invalid Private Key format')).toBeInTheDocument();
});
});
});
});
@@ -0,0 +1,199 @@
import { useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom-v5-compat';
import { t } from '@grafana/i18n';
import { isFetchError, reportInteraction } from '@grafana/runtime';
import { Button, Combobox, Field, Input, SecretTextArea, Stack } from '@grafana/ui';
import { Connection } from 'app/api/clients/provisioning/v0alpha1';
import { FormPrompt } from 'app/core/components/FormPrompt/FormPrompt';
import { CONNECTIONS_URL } from '../constants';
import { useCreateOrUpdateConnection } from '../hooks/useCreateOrUpdateConnection';
import { ConnectionFormData } from '../types';
import { getConnectionFormErrors } from '../utils/getFormErrors';
import { DeleteConnectionButton } from './DeleteConnectionButton';
interface ConnectionFormProps {
data?: Connection;
}
const providerOptions = [{ value: 'github', label: 'GitHub' }];
export function ConnectionForm({ data }: ConnectionFormProps) {
const connectionName = data?.metadata?.name;
const isEdit = Boolean(connectionName);
const privateKey = data?.secure?.privateKey;
const [privateKeyConfigured, setPrivateKeyConfigured] = useState(Boolean(privateKey));
const [submitData, request] = useCreateOrUpdateConnection(connectionName);
const navigate = useNavigate();
const {
register,
handleSubmit,
reset,
control,
formState: { errors, isDirty },
setValue,
getValues,
setError,
} = useForm<ConnectionFormData>({
defaultValues: {
type: data?.spec?.type || 'github',
appID: data?.spec?.github?.appID || '',
installationID: data?.spec?.github?.installationID || '',
privateKey: privateKey?.name || '',
},
});
useEffect(() => {
if (request.isSuccess) {
const formData = getValues();
reportInteraction('grafana_provisioning_connection_saved', {
connectionName: connectionName ?? 'unknown',
connectionType: formData.type,
});
reset(formData);
// use timeout to ensure the form resets before navigating
setTimeout(() => navigate(CONNECTIONS_URL), 300);
}
}, [request.isSuccess, reset, getValues, connectionName, navigate]);
const onSubmit = async (form: ConnectionFormData) => {
try {
const spec = {
type: form.type,
github: {
appID: form.appID,
installationID: form.installationID,
},
};
await submitData(spec, form.privateKey);
} catch (err) {
if (isFetchError(err)) {
const [field, errorMessage] = getConnectionFormErrors(err.data?.errors);
if (field && errorMessage) {
setError(field, errorMessage);
return;
}
}
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: 700 }}>
<FormPrompt onDiscard={reset} confirmRedirect={isDirty} />
<Stack direction="column" gap={2}>
<Field
noMargin
htmlFor="type"
label={t('provisioning.connection-form.label-provider', 'Provider')}
description={t('provisioning.connection-form.description-provider', 'Select the provider type')}
>
<Controller
name="type"
control={control}
render={({ field: { ref, onChange, ...field } }) => (
<Combobox
id="type"
disabled // TODO enable when other providers are supported
options={providerOptions}
onChange={(option) => onChange(option?.value)}
{...field}
/>
)}
/>
</Field>
<Field
noMargin
label={t('provisioning.connection-form.label-app-id', 'GitHub App ID')}
description={t('provisioning.connection-form.description-app-id', 'The ID of your GitHub App')}
invalid={!!errors.appID}
error={errors?.appID?.message}
required
>
<Input
id="appID"
{...register('appID', {
required: t('provisioning.connection-form.error-required', 'This field is required'),
})}
placeholder={t('provisioning.connection-form.placeholder-app-id', '123456')}
/>
</Field>
<Field
noMargin
label={t('provisioning.connection-form.label-installation-id', 'GitHub Installation ID')}
description={t(
'provisioning.connection-form.description-installation-id',
'The installation ID of your GitHub App'
)}
invalid={!!errors.installationID}
error={errors?.installationID?.message}
required
>
<Input
id="installationID"
{...register('installationID', {
required: t('provisioning.connection-form.error-required', 'This field is required'),
})}
placeholder={t('provisioning.connection-form.placeholder-installation-id', '12345678')}
/>
</Field>
<Field
noMargin
htmlFor="privateKey"
label={t('provisioning.connection-form.label-private-key', 'Private Key (PEM)')}
description={t(
'provisioning.connection-form.description-private-key',
'The private key for your GitHub App in PEM format'
)}
invalid={!!errors.privateKey}
error={errors?.privateKey?.message}
required={!isEdit}
>
<Controller
name="privateKey"
control={control}
rules={{
required: isEdit ? false : t('provisioning.connection-form.error-required', 'This field is required'),
}}
render={({ field: { ref, ...field } }) => (
<SecretTextArea
{...field}
id="privateKey"
placeholder={t(
'provisioning.connection-form.placeholder-private-key',
'-----BEGIN RSA PRIVATE KEY-----...'
)}
isConfigured={privateKeyConfigured}
onReset={() => {
setValue('privateKey', '');
setPrivateKeyConfigured(false);
}}
rows={8}
grow
/>
)}
/>
</Field>
<Stack gap={2}>
<Button type="submit" disabled={request.isLoading}>
{request.isLoading
? t('provisioning.connection-form.button-saving', 'Saving...')
: t('provisioning.connection-form.button-save', 'Save')}
</Button>
{connectionName && data && <DeleteConnectionButton name={connectionName} connection={data} />}
</Stack>
</Stack>
</form>
);
}
@@ -0,0 +1,59 @@
import { skipToken } from '@reduxjs/toolkit/query/react';
import { useParams } from 'react-router-dom-v5-compat';
import { Trans, t } from '@grafana/i18n';
import { EmptyState, Text, TextLink } from '@grafana/ui';
import { useGetConnectionQuery } from 'app/api/clients/provisioning/v0alpha1';
import { Page } from 'app/core/components/Page/Page';
import { CONNECTIONS_URL } from '../constants';
import { ConnectionForm } from './ConnectionForm';
export default function ConnectionFormPage() {
const { name = '' } = useParams();
const isCreate = !name;
const query = useGetConnectionQuery(isCreate ? skipToken : { name });
//@ts-expect-error TODO add error types
const notFound = !isCreate && query.isError && query.error?.status === 404;
const pageTitle = isCreate
? t('provisioning.connection-form.page-title-create', 'Create connection')
: t('provisioning.connection-form.page-title-edit', 'Edit connection');
return (
<Page
navId="provisioning"
pageNav={{
text: pageTitle,
subTitle: t(
'provisioning.connection-form.page-subtitle',
'Configure a connection to authenticate with external providers'
),
parentItem: {
text: t('provisioning.connections.page-title', 'Connections'),
url: CONNECTIONS_URL,
},
}}
>
<Page.Contents isLoading={!isCreate && query.isLoading}>
{notFound ? (
<EmptyState message={t('provisioning.connection-form.not-found', 'Connection not found')} variant="not-found">
<Text element="p">
<Trans i18nKey="provisioning.connection-form.not-found-description">
The connection you are looking for does not exist.
</Trans>
</Text>
<TextLink href={CONNECTIONS_URL}>
<Trans i18nKey="provisioning.connection-form.back-to-connections">Back to connections</Trans>
</TextLink>
</EmptyState>
) : (
<ConnectionForm data={isCreate ? undefined : query.data} />
)}
</Page.Contents>
</Page>
);
}
@@ -0,0 +1,165 @@
import { render, screen } from 'test/test-utils';
import { Connection } from 'app/api/clients/provisioning/v0alpha1';
import { ConnectionList } from './ConnectionList';
const createMockConnection = (overrides: Partial<Connection> = {}): Connection => ({
metadata: { name: 'test-connection' },
spec: {
type: 'github',
url: 'https://github.com/settings/installations/12345678',
github: {
appID: '123456',
installationID: '12345678',
},
},
status: {
state: 'connected',
health: { healthy: true },
observedGeneration: 1,
},
...overrides,
});
const mockConnections: Connection[] = [
createMockConnection({
metadata: { name: 'github-conn-1' },
spec: {
type: 'github',
url: 'https://github.com/settings/installations/103343308',
github: {
appID: '123456',
installationID: '103343308',
},
},
}),
createMockConnection({
metadata: { name: 'gitlab-conn-2' },
spec: { type: 'gitlab', url: 'https://gitlab.com/org2/repo2' },
}),
createMockConnection({
metadata: { name: 'another-github' },
spec: {
type: 'github',
url: 'https://github.com/settings/installations/987654321',
github: {
appID: '654321',
installationID: '987654321',
},
},
}),
];
function setup(items: Connection[] = mockConnections) {
return render(<ConnectionList items={items} />, { renderWithRouter: true });
}
describe('ConnectionList', () => {
describe('Rendering', () => {
it('should render search input with correct placeholder', () => {
setup();
expect(screen.getByPlaceholderText('Search connections')).toBeInTheDocument();
});
it('should render all connection items when no filter is applied', () => {
setup();
// Verify all 3 connections are displayed by checking for their URL links
expect(
screen.getByRole('link', { name: 'https://github.com/settings/installations/103343308' })
).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'https://gitlab.com/org2/repo2' })).toBeInTheDocument();
expect(
screen.getByRole('link', { name: 'https://github.com/settings/installations/987654321' })
).toBeInTheDocument();
});
it('should render EmptyState when items array is empty', () => {
setup([]);
expect(screen.getByText('No connections configured')).toBeInTheDocument();
});
});
describe('Filtering', () => {
it('should filter connections by name', async () => {
const { user } = setup();
const searchInput = screen.getByPlaceholderText('Search connections');
await user.type(searchInput, 'gitlab');
// Should show only gitlab connection
expect(screen.getByRole('link', { name: 'https://gitlab.com/org2/repo2' })).toBeInTheDocument();
expect(
screen.queryByRole('link', { name: 'https://github.com/settings/installations/103343308' })
).not.toBeInTheDocument();
expect(
screen.queryByRole('link', { name: 'https://github.com/settings/installations/987654321' })
).not.toBeInTheDocument();
});
it('should filter connections by provider type', async () => {
const { user } = setup();
const searchInput = screen.getByPlaceholderText('Search connections');
await user.type(searchInput, 'github');
// Should show only github connections
expect(
screen.getByRole('link', { name: 'https://github.com/settings/installations/103343308' })
).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'https://gitlab.com/org2/repo2' })).not.toBeInTheDocument();
expect(
screen.getByRole('link', { name: 'https://github.com/settings/installations/987654321' })
).toBeInTheDocument();
});
it('should be case-insensitive', async () => {
const { user } = setup();
const searchInput = screen.getByPlaceholderText('Search connections');
await user.type(searchInput, 'GITLAB');
expect(screen.getByRole('link', { name: 'https://gitlab.com/org2/repo2' })).toBeInTheDocument();
});
it('should show EmptyState when filter matches nothing', async () => {
const { user } = setup();
const searchInput = screen.getByPlaceholderText('Search connections');
await user.type(searchInput, 'nonexistent');
expect(screen.getByText('No results matching your query')).toBeInTheDocument();
expect(
screen.queryByRole('link', { name: 'https://github.com/settings/installations/103343308' })
).not.toBeInTheDocument();
});
it('should clear filter and show all items', async () => {
const { user } = setup();
const searchInput = screen.getByPlaceholderText('Search connections');
await user.type(searchInput, 'gitlab');
// Filter applied
expect(screen.getByRole('link', { name: 'https://gitlab.com/org2/repo2' })).toBeInTheDocument();
expect(
screen.queryByRole('link', { name: 'https://github.com/settings/installations/103343308' })
).not.toBeInTheDocument();
// Clear the filter
await user.clear(searchInput);
// All items should be visible again
expect(
screen.getByRole('link', { name: 'https://github.com/settings/installations/103343308' })
).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'https://gitlab.com/org2/repo2' })).toBeInTheDocument();
expect(
screen.getByRole('link', { name: 'https://github.com/settings/installations/987654321' })
).toBeInTheDocument();
});
});
});
@@ -0,0 +1,51 @@
import { useState } from 'react';
import { t } from '@grafana/i18n';
import { EmptyState, FilterInput, Stack } from '@grafana/ui';
import { Connection } from 'app/api/clients/provisioning/v0alpha1';
import { ConnectionListItem } from './ConnectionListItem';
interface Props {
items: Connection[];
}
export function ConnectionList({ items }: Props) {
const [query, setQuery] = useState('');
const filteredItems = items.filter((item) => {
if (!query) {
return true;
}
const lowerQuery = query.toLowerCase();
const name = item.metadata?.name?.toLowerCase() ?? '';
const providerType = item.spec?.type?.toLowerCase() ?? '';
return name.includes(lowerQuery) || providerType.includes(lowerQuery);
});
const isEmpty = items.length === 0;
return (
<Stack direction={'column'} gap={3}>
<FilterInput
placeholder={t('provisioning.connections.search-placeholder', 'Search connections')}
value={query}
onChange={setQuery}
/>
<Stack direction={'column'} gap={2}>
{filteredItems.length ? (
filteredItems.map((item) => <ConnectionListItem key={item.metadata?.name} connection={item} />)
) : (
<EmptyState
variant={isEmpty ? 'completed' : 'not-found'}
message={
isEmpty
? t('provisioning.connections.no-connections', 'No connections configured')
: t('provisioning.connections.no-results', 'No results matching your query')
}
/>
)}
</Stack>
</Stack>
);
}
@@ -0,0 +1,49 @@
import { Trans } from '@grafana/i18n';
import { Card, LinkButton, Stack, Text, TextLink } from '@grafana/ui';
import { Connection } from 'app/api/clients/provisioning/v0alpha1';
import { RepoIcon } from '../Shared/RepoIcon';
import { RepoType } from '../Wizard/types';
import { CONNECTIONS_URL } from '../constants';
import { getRepositoryTypeConfigs } from '../utils/repositoryTypes';
import { ConnectionStatusBadge } from './ConnectionStatusBadge';
interface Props {
connection: Connection;
}
export function ConnectionListItem({ connection }: Props) {
const { metadata, spec, status } = connection;
const name = metadata?.name ?? '';
const url = spec?.url;
const providerType: RepoType = spec?.type ?? 'github';
const repoConfig = getRepositoryTypeConfigs().find((config) => config.type === providerType);
return (
<Card noMargin key={name}>
<Card.Figure>
<RepoIcon type={providerType} />
</Card.Figure>
<Card.Heading>
<Stack gap={2} direction="row" alignItems="center">
{repoConfig && <Text variant="h3">{`${repoConfig.label} app connection`}</Text>}
{status?.state && <ConnectionStatusBadge status={status} />}
</Stack>
</Card.Heading>
{url && (
<Card.Meta>
<TextLink external href={url}>
{url}
</TextLink>
</Card.Meta>
)}
<Card.Actions>
<LinkButton icon="eye" href={`${CONNECTIONS_URL}/${name}/edit`} variant="primary" size="md">
<Trans i18nKey="provisioning.connections.view">View</Trans>
</LinkButton>
</Card.Actions>
</Card>
);
}
@@ -0,0 +1,42 @@
import { t } from '@grafana/i18n';
import { Badge, IconName } from '@grafana/ui';
import { ConnectionStatus } from 'app/api/clients/provisioning/v0alpha1';
interface Props {
status: ConnectionStatus;
}
interface BadgeConfig {
color: 'green' | 'red' | 'darkgrey';
text: string;
icon: IconName;
}
function getBadgeConfig(status: ConnectionStatus): BadgeConfig {
switch (status.state) {
case 'connected':
return {
color: 'green',
text: t('provisioning.connections.status-connected', 'Connected'),
icon: 'check',
};
case 'disconnected':
return {
color: 'red',
text: t('provisioning.connections.status-disconnected', 'Disconnected'),
icon: 'times-circle',
};
default:
return {
color: 'darkgrey',
text: t('provisioning.connections.status-unknown', 'Unknown'),
icon: 'question-circle',
};
}
}
export function ConnectionStatusBadge({ status }: Props) {
const config = getBadgeConfig(status);
return <Badge color={config.color} text={config.text} icon={config.icon} />;
}
@@ -0,0 +1,55 @@
import { t, Trans } from '@grafana/i18n';
import { Alert, EmptyState, LinkButton, Stack, Text } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { CONNECTIONS_URL } from '../constants';
import { useConnectionList } from '../hooks/useConnectionList';
import { getErrorMessage } from '../utils/httpUtils';
import { ConnectionList } from './ConnectionList';
export default function ConnectionsPage() {
const [items, isLoading, error] = useConnectionList();
const hasNoConnections = !isLoading && !error && items?.length === 0;
return (
<Page
navId="provisioning"
pageNav={{
text: t('provisioning.connections.page-title', 'Connections'),
subTitle: t('provisioning.connections.page-subtitle', 'View and manage your app connections'),
}}
actions={
<LinkButton variant="primary" href={`${CONNECTIONS_URL}/new`}>
<Trans i18nKey="provisioning.connections.add-connection">Add connection</Trans>
</LinkButton>
}
>
<Page.Contents isLoading={isLoading}>
<Stack direction={'column'} gap={3}>
{!!error && (
<Alert severity="error" title={t('provisioning.connections.error-loading', 'Failed to load connections')}>
{getErrorMessage(error)}
</Alert>
)}
{hasNoConnections && (
<EmptyState
variant="call-to-action"
message={t('provisioning.connections.no-connections', 'No connections configured')}
>
<Text element="p">
{t(
'provisioning.connections.no-connections-message',
'Add a connection to authenticate with external providers'
)}
</Text>
</EmptyState>
)}
{!!items?.length && <ConnectionList items={items} />}
</Stack>
</Page.Contents>
</Page>
);
}
@@ -0,0 +1,53 @@
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom-v5-compat';
import { t, Trans } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { Button } from '@grafana/ui';
import { Connection, useDeleteConnectionMutation } from 'app/api/clients/provisioning/v0alpha1';
import { appEvents } from 'app/core/app_events';
import { ShowConfirmModalEvent } from 'app/types/events';
import { CONNECTIONS_URL } from '../constants';
interface Props {
name: string;
connection: Connection;
}
export function DeleteConnectionButton({ name, connection }: Props) {
const navigate = useNavigate();
const [deleteConnection, deleteRequest] = useDeleteConnectionMutation();
const onDelete = useCallback(async () => {
reportInteraction('grafana_provisioning_connection_deleted', {
connectionName: name,
connectionType: connection?.spec?.type ?? 'unknown',
});
await deleteConnection({ name });
navigate(CONNECTIONS_URL);
}, [deleteConnection, name, connection, navigate]);
const showDeleteModal = useCallback(() => {
appEvents.publish(
new ShowConfirmModalEvent({
title: t('provisioning.connections.delete-title', 'Delete connection'),
text: t(
'provisioning.connections.delete-confirm',
'Are you sure you want to delete this connection? This action cannot be undone.'
),
yesText: t('provisioning.connections.delete', 'Delete'),
noText: t('provisioning.connections.cancel', 'Cancel'),
yesButtonVariant: 'destructive',
onConfirm: onDelete,
})
);
}, [onDelete]);
return (
<Button variant="destructive" size="md" disabled={deleteRequest.isLoading} onClick={showDeleteModal}>
<Trans i18nKey="provisioning.connections.delete">Delete</Trans>
</Button>
);
}
@@ -1,14 +1,16 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom-v5-compat';
import { t, Trans } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { Button, ConfirmModal, Dropdown, Icon, Menu, Stack } from '@grafana/ui';
import { Button, Dropdown, Icon, Menu, Stack } from '@grafana/ui';
import {
Repository,
useDeleteRepositoryMutation,
useReplaceRepositoryMutation,
} from 'app/api/clients/provisioning/v0alpha1';
import { appEvents } from 'app/core/app_events';
import { ShowConfirmModalEvent } from 'app/types/events';
type DeleteAction = 'remove-resources' | 'keep-resources';
@@ -21,110 +23,102 @@ interface Props {
export function DeleteRepositoryButton({ name, repository, redirectTo }: Props) {
const [deleteRepository, deleteRequest] = useDeleteRepositoryMutation();
const [replaceRepository, replaceRequest] = useReplaceRepositoryMutation();
const [showModal, setShowModal] = useState(false);
const [selectedAction, setSelectedAction] = useState<DeleteAction>('remove-resources');
const navigate = useNavigate();
useEffect(() => {
if (deleteRequest.isSuccess) {
setShowModal(false);
const performDelete = useCallback(
async (deleteAction: DeleteAction) => {
if (deleteAction === 'keep-resources' && repository) {
const updatedRepository = {
...repository,
metadata: {
...repository.metadata,
finalizers: ['cleanup', 'release-orphan-resources'],
},
};
await replaceRepository({ name, repository: updatedRepository });
}
reportInteraction('grafana_provisioning_repository_deleted', {
repositoryName: name,
repositoryType: repository?.spec?.type ?? 'unknown',
deleteAction,
target: repository?.spec?.sync?.target ?? 'unknown',
workflows: repository?.spec?.workflows ?? [],
});
await deleteRepository({ name });
if (redirectTo) {
navigate(redirectTo);
}
}
}, [deleteRequest.isSuccess, redirectTo, navigate]);
},
[deleteRepository, replaceRepository, name, repository, redirectTo, navigate]
);
const onConfirm = useCallback(async () => {
if (selectedAction === 'keep-resources' && repository) {
const updatedRepository = {
...repository,
metadata: {
...repository.metadata,
finalizers: ['cleanup', 'release-orphan-resources'],
},
};
await replaceRepository({ name, repository: updatedRepository });
}
reportInteraction('grafana_provisioning_repository_deleted', {
repositoryName: name,
repositoryType: repository?.spec?.type ?? 'unknown',
deleteAction: selectedAction,
target: repository?.spec?.sync?.target ?? 'unknown',
workflows: repository?.spec?.workflows ?? [],
});
deleteRepository({ name });
}, [deleteRepository, replaceRepository, name, selectedAction, repository]);
const getConfirmationMessage = () => {
if (selectedAction === 'remove-resources') {
return t(
'provisioning.delete-repository-button.confirm-delete-with-resources',
'Are you sure you want to delete the repository configuration and all its resources?'
);
}
return t(
'provisioning.delete-repository-button.confirm-delete-keep-resources',
'Are you sure you want to delete the repository configuration but keep its resources?'
const showDeleteWithResourcesModal = useCallback(() => {
appEvents.publish(
new ShowConfirmModalEvent({
title: t(
'provisioning.delete-repository-button.title-delete-repository-and-resources',
'Delete repository configuration and resources'
),
text: t(
'provisioning.delete-repository-button.confirm-delete-with-resources',
'Are you sure you want to delete the repository configuration and all its resources?'
),
yesText: t('provisioning.delete-repository-button.button-delete', 'Delete'),
noText: t('provisioning.delete-repository-button.button-cancel', 'Cancel'),
yesButtonVariant: 'destructive',
onConfirm: () => performDelete('remove-resources'),
})
);
};
}, [performDelete]);
const getModalTitle = () => {
if (selectedAction === 'remove-resources') {
return t(
'provisioning.delete-repository-button.title-delete-repository-and-resources',
'Delete repository configuration and resources'
);
}
return t(
'provisioning.delete-repository-button.title-delete-repository-only',
'Delete repository configuration only'
const showDeleteKeepResourcesModal = useCallback(() => {
appEvents.publish(
new ShowConfirmModalEvent({
title: t(
'provisioning.delete-repository-button.title-delete-repository-only',
'Delete repository configuration only'
),
text: t(
'provisioning.delete-repository-button.confirm-delete-keep-resources',
'Are you sure you want to delete the repository configuration but keep its resources?'
),
yesText: t('provisioning.delete-repository-button.button-delete', 'Delete'),
noText: t('provisioning.delete-repository-button.button-cancel', 'Cancel'),
yesButtonVariant: 'destructive',
onConfirm: () => performDelete('keep-resources'),
})
);
};
}, [performDelete]);
const isLoading = deleteRequest.isLoading || replaceRequest.isLoading;
return (
<>
<Dropdown
overlay={
<Menu>
<Menu.Item
label={t(
'provisioning.delete-repository-button.delete-and-remove-resources',
'Delete and remove resources (default)'
)}
onClick={() => {
setSelectedAction('remove-resources');
setShowModal(true);
}}
/>
<Menu.Item
label={t('provisioning.delete-repository-button.delete-and-keep-resources', 'Delete and keep resources')}
onClick={() => {
setSelectedAction('keep-resources');
setShowModal(true);
}}
/>
</Menu>
}
>
<Button variant="destructive" disabled={isLoading}>
<Stack alignItems="center">
<Trans i18nKey="provisioning.delete-repository-button.delete">Delete</Trans>
<Icon name={'angle-down'} />
</Stack>
</Button>
</Dropdown>
<ConfirmModal
isOpen={showModal}
title={getModalTitle()}
body={getConfirmationMessage()}
confirmText={t('provisioning.delete-repository-button.button-delete', 'Delete')}
onConfirm={onConfirm}
onDismiss={() => setShowModal(false)}
/>
</>
<Dropdown
overlay={
<Menu>
<Menu.Item
label={t(
'provisioning.delete-repository-button.delete-and-remove-resources',
'Delete and remove resources (default)'
)}
onClick={showDeleteWithResourcesModal}
/>
<Menu.Item
label={t('provisioning.delete-repository-button.delete-and-keep-resources', 'Delete and keep resources')}
onClick={showDeleteKeepResourcesModal}
/>
</Menu>
}
>
<Button variant="destructive" disabled={isLoading}>
<Stack alignItems="center">
<Trans i18nKey="provisioning.delete-repository-button.delete">Delete</Trans>
<Icon name={'angle-down'} />
</Stack>
</Button>
</Dropdown>
);
}
@@ -4,7 +4,7 @@ import { Badge, Button, LinkButton, Stack } from '@grafana/ui';
import { Repository } from 'app/api/clients/provisioning/v0alpha1';
import { StatusBadge } from '../Shared/StatusBadge';
import { PROVISIONING_URL } from '../constants';
import { CONNECTIONS_URL, PROVISIONING_URL } from '../constants';
import { getRepoHrefForProvider } from '../utils/git';
import { getIsReadOnlyWorkflows } from '../utils/repository';
import { getRepositoryTypeConfig } from '../utils/repositoryTypes';
@@ -34,6 +34,9 @@ export function RepositoryActions({ repository }: RepositoryActionsProps) {
</Button>
)}
<SyncRepository repository={repository} />
<LinkButton variant="secondary" icon="link" href={CONNECTIONS_URL}>
<Trans i18nKey="provisioning.repository-actions.connections">Connections</Trans>
</LinkButton>
<LinkButton
variant="secondary"
icon="cog"
@@ -1,4 +1,5 @@
export const PROVISIONING_URL = '/admin/provisioning';
export const CONNECTIONS_URL = `${PROVISIONING_URL}/connections`;
export const CONNECT_URL = `${PROVISIONING_URL}/connect`;
export const GETTING_STARTED_URL = `${PROVISIONING_URL}/getting-started`;
export const UPGRADE_URL = 'https://grafana.com/profile/org/subscription';
@@ -0,0 +1,17 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { ListConnectionApiArg, useListConnectionQuery } from 'app/api/clients/provisioning/v0alpha1';
// Sort connections alphabetically by name
export function useConnectionList(options: ListConnectionApiArg | typeof skipToken = {}) {
const query = useListConnectionQuery(options);
const collator = new Intl.Collator(undefined, { numeric: true });
const sortedItems = query.data?.items?.slice().sort((a, b) => {
const nameA = a.metadata?.name ?? '';
const nameB = b.metadata?.name ?? '';
return collator.compare(nameA, nameB);
});
return [sortedItems, query.isLoading, query.error] as const;
}
@@ -0,0 +1,40 @@
import { useCallback } from 'react';
import {
Connection,
ConnectionSpec,
ConnectionSecure,
useCreateConnectionMutation,
useReplaceConnectionMutation,
} from 'app/api/clients/provisioning/v0alpha1';
export function useCreateOrUpdateConnection(name?: string) {
const [create, createRequest] = useCreateConnectionMutation();
const [update, updateRequest] = useReplaceConnectionMutation();
const updateOrCreate = useCallback(
async (data: ConnectionSpec, privateKey?: string) => {
const secure: ConnectionSecure | undefined = privateKey?.length
? { privateKey: { create: privateKey } }
: undefined;
const connection: Connection = {
metadata: name ? { name } : { generateName: 'c' },
spec: data,
secure,
};
if (name) {
return update({
name,
connection,
});
}
return create({ connection });
},
[create, name, update]
);
return [updateOrCreate, name ? updateRequest : createRequest] as const;
}
+11
View File
@@ -5,6 +5,7 @@ import { SelectableValue } from '@grafana/data';
import {
BitbucketRepositoryConfig,
ConnectionSpec,
GitHubRepositoryConfig,
GitLabRepositoryConfig,
GitRepositoryConfig,
@@ -51,6 +52,16 @@ export type RepositoryFormData = Omit<RepositorySpec, 'workflows' | RepositorySp
export type RepositorySettingsField = Path<RepositoryFormData>;
// Connection type definition - extracted from API client
export type ConnectionType = ConnectionSpec['type'];
export type ConnectionFormData = {
type: ConnectionSpec['type'];
appID: string;
installationID: string;
privateKey?: string;
};
// Section configuration
export interface RepositorySection {
name: string;
@@ -3,7 +3,7 @@ import { Path } from 'react-hook-form';
import { ErrorDetails } from 'app/api/clients/provisioning/v0alpha1';
import { WizardFormData } from '../Wizard/types';
import { RepositoryFormData } from '../types';
import { ConnectionFormData, RepositoryFormData } from '../types';
export type RepositoryField = keyof WizardFormData['repository'];
export type RepositoryFormPath = `repository.${RepositoryField}` | 'repository.sync.intervalSeconds';
@@ -89,3 +89,20 @@ export const getConfigFormErrors = (errors?: ErrorDetails[]): ConfigFormErrorTup
return mapErrorsToField(errors, fieldMap, { allowPartial: true });
};
// Connection form errors
export type ConnectionFormPath = Path<ConnectionFormData>;
export type ConnectionFormErrorTuple = GenericFormErrorTuple<ConnectionFormPath>;
export const getConnectionFormErrors = (errors?: ErrorDetails[]): ConnectionFormErrorTuple => {
const fieldMap: Record<string, ConnectionFormPath> = {
appID: 'appID',
installationID: 'installationID',
'github.appID': 'appID',
'github.installationID': 'installationID',
'secure.privateKey': 'privateKey',
privateKey: 'privateKey',
};
return mapErrorsToField(errors, fieldMap, { allowPartial: true });
};
@@ -3,7 +3,7 @@ import { RouteDescriptor } from 'app/core/navigation/types';
import { DashboardRoutes } from 'app/types/dashboard';
import { checkRequiredFeatures } from '../GettingStarted/features';
import { PROVISIONING_URL, CONNECT_URL, GETTING_STARTED_URL } from '../constants';
import { CONNECTIONS_URL, CONNECT_URL, GETTING_STARTED_URL, PROVISIONING_URL } from '../constants';
export function getProvisioningRoutes(): RouteDescriptor[] {
if (!checkRequiredFeatures()) {
@@ -36,6 +36,26 @@ export function getProvisioningRoutes(): RouteDescriptor[] {
)
),
},
{
path: CONNECTIONS_URL,
component: SafeDynamicImport(
() => import(/* webpackChunkName: "ConnectionsPage"*/ 'app/features/provisioning/Connection/ConnectionsPage')
),
},
{
path: `${CONNECTIONS_URL}/:name/edit`,
component: SafeDynamicImport(
() =>
import(/* webpackChunkName: "ConnectionFormPage"*/ 'app/features/provisioning/Connection/ConnectionFormPage')
),
},
{
path: `${CONNECTIONS_URL}/new`,
component: SafeDynamicImport(
() =>
import(/* webpackChunkName: "ConnectionFormPage"*/ 'app/features/provisioning/Connection/ConnectionFormPage')
),
},
{
path: `${CONNECT_URL}/:type`,
component: SafeDynamicImport(
@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react';
import { mockToolkitActionCreator } from 'test/core/redux/mocks';
import { config } from 'app/core/config';
import { config } from '@grafana/runtime';
import { Props, UsersActionBarUnconnected } from './UsersActionBar';
import { searchQueryChanged } from './state/reducers';
@@ -4,8 +4,8 @@ import { Link } from 'react-router-dom-v5-compat';
import { SIGV4ConnectionConfig } from '@grafana/aws-sdk';
import { DataSourcePluginOptionsEditorProps, SelectableValue } from '@grafana/data';
import { config } from '@grafana/runtime';
import { Box, DataSourceHttpSettings, InlineField, InlineSwitch, Select, Text } from '@grafana/ui';
import { config } from 'app/core/config';
import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from './types';
@@ -5,7 +5,7 @@ import { useEffectOnce, useToggle } from 'react-use';
import { GrafanaTheme2, PanelProps } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { TimeRangeUpdatedEvent } from '@grafana/runtime';
import { config, TimeRangeUpdatedEvent } from '@grafana/runtime';
import {
Alert,
BigValue,
@@ -17,7 +17,6 @@ import {
ScrollContainer,
useStyles2,
} from '@grafana/ui';
import { config } from 'app/core/config';
import alertDef from 'app/features/alerting/state/alertDef';
import { alertRuleApi } from 'app/features/alerting/unified/api/alertRuleApi';
import { INSTANCES_DISPLAY_LIMIT } from 'app/features/alerting/unified/components/rules/RuleDetails';
@@ -12,10 +12,10 @@ import {
PanelProps,
VizOrientation,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { BarGaugeSizing } from '@grafana/schema';
import { BarGauge, DataLinksContextMenu, VizLayout, VizRepeater, VizRepeaterRenderValueProps } from '@grafana/ui';
import { DataLinksContextMenuApi } from '@grafana/ui/internal';
import { config } from 'app/core/config';
import { BarGaugeLegend } from './BarGaugeLegend';
import { defaultOptions, Options } from './panelcfg.gen';
@@ -5,7 +5,7 @@ import { useMemo, useState } from 'react';
import uPlot from 'uplot';
import { Field, getDisplayProcessor, PanelProps, useDataLinksContext } from '@grafana/data';
import { PanelDataErrorView } from '@grafana/runtime';
import { config, PanelDataErrorView } from '@grafana/runtime';
import { DashboardCursorSync, TooltipDisplayMode } from '@grafana/schema';
import {
EventBusPlugin,
@@ -18,7 +18,6 @@ import {
} from '@grafana/ui';
import { AxisProps, ScaleProps, TimeRange2, TooltipHoverMode } from '@grafana/ui/internal';
import { TimeSeries } from 'app/core/components/TimeSeries/TimeSeries';
import { config } from 'app/core/config';
import { TimeSeriesTooltip } from '../timeseries/TimeSeriesTooltip';
import { AnnotationsPlugin2 } from '../timeseries/plugins/AnnotationsPlugin2';
@@ -5,8 +5,8 @@ import { first } from 'rxjs/operators';
import { SelectableValue } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { ContextMenu, MenuItem, MenuItemProps } from '@grafana/ui';
import { config } from 'app/core/config';
import { ElementState } from 'app/features/canvas/runtime/element';
import { FrameState } from 'app/features/canvas/runtime/frame';
import { Scene } from 'app/features/canvas/runtime/scene';
@@ -2,9 +2,9 @@ import { css } from '@emotion/css';
import { useEffect, useMemo, useRef, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { DirectionDimensionConfig, DirectionDimensionMode, ConnectionDirection } from '@grafana/schema';
import { useStyles2 } from '@grafana/ui';
import { config } from 'app/core/config';
import { Scene } from 'app/features/canvas/runtime/scene';
import { ConnectionCoordinates } from '../../panelcfg.gen';
@@ -2,9 +2,9 @@ import { css } from '@emotion/css';
import { useEffect, useMemo, useRef, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { DirectionDimensionConfig, DirectionDimensionMode, ConnectionDirection } from '@grafana/schema';
import { useStyles2 } from '@grafana/ui';
import { config } from 'app/core/config';
import { Scene } from 'app/features/canvas/runtime/scene';
import { ConnectionCoordinates } from '../../panelcfg.gen';
+2 -1
View File
@@ -1,9 +1,10 @@
import { isNumber, isString } from 'lodash';
import { DataFrame, Field, AppEvents, getFieldDisplayName, PluginState, SelectableValue } from '@grafana/data';
import { config } from '@grafana/runtime';
import { ConnectionDirection } from '@grafana/schema';
import { appEvents } from 'app/core/app_events';
import { hasAlphaPanels, config } from 'app/core/config';
import { hasAlphaPanels } from 'app/core/config';
import { CanvasConnection, CanvasElementItem, CanvasElementOptions } from 'app/features/canvas/element';
import { notFoundItem } from 'app/features/canvas/elements/notFound';
import { advancedElementItems, canvasElementRegistry, defaultElementItems } from 'app/features/canvas/registry';
@@ -1,10 +1,10 @@
import { PureComponent, type JSX } from 'react';
import { FieldDisplay, getDisplayProcessor, getFieldDisplayValues, PanelProps } from '@grafana/data';
import { config } from '@grafana/runtime';
import { BarGaugeSizing, VizOrientation } from '@grafana/schema';
import { DataLinksContextMenu, Gauge, VizRepeater, VizRepeaterRenderValueProps } from '@grafana/ui';
import { DataLinksContextMenuApi } from '@grafana/ui/internal';
import { config } from 'app/core/config';
import { clearNameForSingleSeries } from '../bargauge/BarGaugePanel';
@@ -8,7 +8,7 @@ import tinycolor from 'tinycolor2';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Trans } from '@grafana/i18n';
import { config } from 'app/core/config';
import { config } from '@grafana/runtime';
interface Props {
map: Map;
@@ -12,11 +12,11 @@ import {
GrafanaTheme2,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { useStyles2, VizLegendItem } from '@grafana/ui';
import { ColorScale } from 'app/core/components/ColorScale/ColorScale';
import { SanitizedSVG } from 'app/core/components/SVG/SanitizedSVG';
import { getThresholdItems } from 'app/core/components/TimelineChart/utils';
import { config } from 'app/core/config';
import { DimensionSupplier } from 'app/features/dimensions/types';
import { StyleConfigState } from '../style/types';
@@ -5,8 +5,8 @@ import { useMemo, useRef, useState } from 'react';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { Button, IconButton, RadioButtonGroup, Select } from '@grafana/ui';
import { config } from 'app/core/config';
import { MapMeasure, MapMeasureOptions, measures } from '../utils/measure';
@@ -9,7 +9,8 @@ import {
SelectableValue,
PluginState,
} from '@grafana/data';
import { config, hasAlphaPanels } from 'app/core/config';
import { config } from '@grafana/runtime';
import { hasAlphaPanels } from 'app/core/config';
import { basemapLayers } from './basemaps';
import { carto } from './basemaps/carto';
@@ -10,8 +10,8 @@ import {
parseLiveChannelAddress,
} from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { Select, Alert, Label, stylesFactory, Combobox } from '@grafana/ui';
import { config } from 'app/core/config';
import { discoveryResources, getAPIGroupDiscoveryList, GroupDiscoveryResource } from 'app/features/apiserver/discovery';
import { getManagedChannelInfo } from 'app/features/live/info';
@@ -7,10 +7,9 @@ import {
getFieldDisplayValues,
PanelProps,
} from '@grafana/data';
import { PanelDataErrorView } from '@grafana/runtime';
import { config, PanelDataErrorView } from '@grafana/runtime';
import { DataLinksContextMenu, Stack, VizRepeater, VizRepeaterRenderValueProps } from '@grafana/ui';
import { DataLinksContextMenuApi, RadialGauge } from '@grafana/ui/internal';
import { config } from 'app/core/config';
import { Options } from './panelcfg.gen';
@@ -1,5 +1,5 @@
import { PanelDataSummary, VisualizationSuggestionScore, VisualizationSuggestionsSupplier } from '@grafana/data';
import { config } from 'app/core/config';
import { config } from '@grafana/runtime';
import icnTablePanelSvg from 'app/plugins/panel/table/img/icn-table-panel.svg';
import { Options, FieldConfig } from './panelcfg.gen';
+1 -1
View File
@@ -1,6 +1,6 @@
import { PanelPlugin } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from 'app/core/config';
import { config } from '@grafana/runtime';
import { TextPanel } from './TextPanel';
import { TextPanelEditor } from './TextPanelEditor';
@@ -10,7 +10,7 @@ import {
useDataLinksContext,
FieldType,
} from '@grafana/data';
import { PanelDataErrorView } from '@grafana/runtime';
import { config, PanelDataErrorView } from '@grafana/runtime';
import { TooltipDisplayMode, VizOrientation } from '@grafana/schema';
import {
EventBusPlugin,
@@ -21,7 +21,6 @@ import {
} from '@grafana/ui';
import { FILTER_OUT_OPERATOR, TimeRange2, TooltipHoverMode } from '@grafana/ui/internal';
import { TimeSeries } from 'app/core/components/TimeSeries/TimeSeries';
import { config } from 'app/core/config';
import { TimeSeriesTooltip } from './TimeSeriesTooltip';
import { Options } from './panelcfg.gen';

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