Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 44856d9c06 |
@@ -4,7 +4,7 @@ go 1.25.5
|
||||
|
||||
require (
|
||||
github.com/go-kit/log v0.2.1
|
||||
github.com/grafana/alerting v0.0.0-20260112110054-6c6f13659ad3
|
||||
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f
|
||||
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
|
||||
|
||||
@@ -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-20260112110054-6c6f13659ad3 h1:KVncUdAc5YwY/OQmw6HgzJmbRKn6IwrhvtcBAd1yDHo=
|
||||
github.com/grafana/alerting v0.0.0-20260112110054-6c6f13659ad3/go.mod h1:Oy4MthJqfErlieO14ryZXdukDrUACy8Lg56P3zP7S1k=
|
||||
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/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
@@ -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-20260112110054-6c6f13659ad3 // indirect
|
||||
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f // 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
@@ -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-20260112110054-6c6f13659ad3 h1:KVncUdAc5YwY/OQmw6HgzJmbRKn6IwrhvtcBAd1yDHo=
|
||||
github.com/grafana/alerting v0.0.0-20260112110054-6c6f13659ad3/go.mod h1:Oy4MthJqfErlieO14ryZXdukDrUACy8Lg56P3zP7S1k=
|
||||
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/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=
|
||||
|
||||
@@ -32,13 +32,14 @@ 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 // indirect; @grafana/grafana-operator-experience-squad
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // @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/sts v1.39.1 // indirect; @grafana/grafana-operator-experience-squad
|
||||
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/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
|
||||
@@ -81,14 +82,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 // @grafana/grafana-git-ui-sync-team
|
||||
github.com/google/go-github/v70 v70.0.0 // indirect; @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-20260112110054-6c6f13659ad3 // @grafana/alerting-backend
|
||||
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f // @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
|
||||
@@ -264,7 +265,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 // @grafana/grafana-app-platform-squad
|
||||
github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2 // @grafana/grafana-app-platform-squad
|
||||
)
|
||||
|
||||
// Replace the workspace versions
|
||||
@@ -293,8 +294,6 @@ 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 (
|
||||
@@ -655,14 +654,9 @@ 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
|
||||
@@ -682,8 +676,6 @@ 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
|
||||
@@ -705,7 +697,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.20260112162805-d29cc9cf7f0f
|
||||
replace github.com/prometheus/alertmanager => github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604
|
||||
|
||||
exclude github.com/mattn/go-sqlite3 v2.0.3+incompatible
|
||||
|
||||
|
||||
@@ -680,7 +680,6 @@ 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=
|
||||
@@ -738,8 +737,6 @@ 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=
|
||||
@@ -762,8 +759,6 @@ 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=
|
||||
@@ -892,6 +887,8 @@ 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=
|
||||
@@ -1029,8 +1026,6 @@ 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=
|
||||
@@ -1625,8 +1620,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-20260112110054-6c6f13659ad3 h1:KVncUdAc5YwY/OQmw6HgzJmbRKn6IwrhvtcBAd1yDHo=
|
||||
github.com/grafana/alerting v0.0.0-20260112110054-6c6f13659ad3/go.mod h1:Oy4MthJqfErlieO14ryZXdukDrUACy8Lg56P3zP7S1k=
|
||||
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/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=
|
||||
@@ -1669,6 +1664,8 @@ 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=
|
||||
@@ -1679,8 +1676,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.20260112162805-d29cc9cf7f0f h1:9tRhudagkQO2s61SLFLSziIdCm7XlkfypVKDxpcHokg=
|
||||
github.com/grafana/prometheus-alertmanager v0.25.1-0.20260112162805-d29cc9cf7f0f/go.mod h1:AsVdCBeDFN9QbgpJg+8voDAcgsW0RmNvBd70ecMMdC0=
|
||||
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/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=
|
||||
@@ -1756,8 +1753,6 @@ 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=
|
||||
@@ -1882,10 +1877,6 @@ 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=
|
||||
|
||||
@@ -38,6 +38,6 @@ use (
|
||||
./pkg/semconv
|
||||
)
|
||||
|
||||
replace github.com/prometheus/alertmanager => github.com/grafana/prometheus-alertmanager v0.25.1-0.20260112162805-d29cc9cf7f0f
|
||||
replace github.com/prometheus/alertmanager => github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604
|
||||
|
||||
replace github.com/crewjam/saml => github.com/grafana/saml v0.4.15-0.20240917091248-ae3bbdad8a56
|
||||
|
||||
+1
-3
@@ -280,6 +280,7 @@ 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=
|
||||
@@ -905,8 +906,6 @@ 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=
|
||||
@@ -1912,7 +1911,6 @@ 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,8 +14,6 @@ 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';
|
||||
@@ -37,11 +35,11 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
*
|
||||
* https://developers.grafana.com/ui/latest/index.html?path=/docs/inputs-secrettextarea--docs
|
||||
*/
|
||||
export const SecretTextArea = ({ isConfigured, onReset, grow, ...props }: Props) => {
|
||||
export const SecretTextArea = ({ isConfigured, onReset, ...props }: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
return (
|
||||
<Stack>
|
||||
<Box grow={grow ? 1 : undefined}>
|
||||
<Box>
|
||||
{!isConfigured && <TextArea {...props} />}
|
||||
{isConfigured && (
|
||||
<TextArea
|
||||
|
||||
@@ -11,12 +11,15 @@ import (
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/client-go/dynamic"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
|
||||
folderv1 "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
|
||||
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/api/dtos"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/team"
|
||||
@@ -50,25 +53,39 @@ func (a *api) getResourcePermissionsFromK8s(ctx context.Context, namespace strin
|
||||
}
|
||||
|
||||
resourcePermName := a.buildResourcePermissionName(resourceID)
|
||||
|
||||
resourcePermResource := dynamicClient.Resource(iamv0.ResourcePermissionInfo.GroupVersionResource()).Namespace(namespace)
|
||||
unstructuredObj, err := resourcePermResource.Get(ctx, resourcePermName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
if k8serrors.IsNotFound(err) {
|
||||
return getResourcePermissionsResponse{}, nil
|
||||
}
|
||||
|
||||
dto := make(getResourcePermissionsResponse, 0)
|
||||
|
||||
if err != nil && !k8serrors.IsNotFound(err) {
|
||||
return nil, fmt.Errorf("failed to get resource permission from k8s: %w", err)
|
||||
}
|
||||
|
||||
var resourcePerm iamv0.ResourcePermission
|
||||
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(unstructuredObj.Object, &resourcePerm); err != nil {
|
||||
return nil, fmt.Errorf("failed to convert to typed resource permission: %w", err)
|
||||
if unstructuredObj != nil {
|
||||
var resourcePerm iamv0.ResourcePermission
|
||||
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(unstructuredObj.Object, &resourcePerm); err != nil {
|
||||
return nil, fmt.Errorf("failed to convert to typed resource permission: %w", err)
|
||||
}
|
||||
|
||||
directDTO, err := a.convertK8sResourcePermissionToDTO(&resourcePerm, namespace, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dto = append(dto, directDTO...)
|
||||
}
|
||||
|
||||
return a.convertK8sResourcePermissionToDTO(&resourcePerm, namespace)
|
||||
inheritedDTO, err := a.getInheritedPermissions(ctx, namespace, resourceID, dynamicClient)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to get inherited permissions from k8s API", "error", err, "resourceID", resourceID, "resource", a.service.options.Resource)
|
||||
} else {
|
||||
dto = append(dto, inheritedDTO...)
|
||||
}
|
||||
|
||||
return dto, nil
|
||||
}
|
||||
|
||||
func (a *api) convertK8sResourcePermissionToDTO(resourcePerm *iamv0.ResourcePermission, namespace string) (getResourcePermissionsResponse, error) {
|
||||
func (a *api) convertK8sResourcePermissionToDTO(resourcePerm *iamv0.ResourcePermission, namespace string, isInherited bool) (getResourcePermissionsResponse, error) {
|
||||
permissions := resourcePerm.Spec.Permissions
|
||||
if len(permissions) == 0 {
|
||||
return getResourcePermissionsResponse{}, nil
|
||||
@@ -107,7 +124,7 @@ func (a *api) convertK8sResourcePermissionToDTO(resourcePerm *iamv0.ResourcePerm
|
||||
Permission: permission,
|
||||
Actions: actions,
|
||||
IsManaged: true,
|
||||
IsInherited: false,
|
||||
IsInherited: isInherited,
|
||||
}
|
||||
|
||||
switch kind {
|
||||
@@ -162,6 +179,115 @@ func getMapKeys(m map[string][]string) []string {
|
||||
return keys
|
||||
}
|
||||
|
||||
func (a *api) getInheritedPermissions(ctx context.Context, namespace string, resourceID string, dynamicClient dynamic.Interface) (getResourcePermissionsResponse, error) {
|
||||
switch a.service.options.Resource {
|
||||
case "folders":
|
||||
return a.getFolderHierarchyPermissions(ctx, namespace, resourceID, dynamicClient)
|
||||
|
||||
case "dashboards":
|
||||
return a.getDashboardInheritedPermissions(ctx, namespace, resourceID, dynamicClient)
|
||||
|
||||
default:
|
||||
return getResourcePermissionsResponse{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (a *api) getDashboardInheritedPermissions(ctx context.Context, namespace string, dashboardUID string, dynamicClient dynamic.Interface) (getResourcePermissionsResponse, error) {
|
||||
dashboardsGVR := schema.GroupVersionResource{
|
||||
Group: "dashboard.grafana.app",
|
||||
Version: "v0alpha1",
|
||||
Resource: "dashboards",
|
||||
}
|
||||
|
||||
dashboardResource := dynamicClient.Resource(dashboardsGVR).Namespace(namespace)
|
||||
unstructuredDash, err := dashboardResource.Get(ctx, dashboardUID, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
if k8serrors.IsNotFound(err) {
|
||||
return getResourcePermissionsResponse{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get dashboard from k8s: %w", err)
|
||||
}
|
||||
|
||||
annotations := unstructuredDash.GetAnnotations()
|
||||
parentFolderUID := annotations[utils.AnnoKeyFolder]
|
||||
|
||||
if parentFolderUID == "" {
|
||||
return getResourcePermissionsResponse{}, nil
|
||||
}
|
||||
|
||||
return a.getFolderHierarchyPermissions(ctx, namespace, parentFolderUID, dynamicClient)
|
||||
}
|
||||
|
||||
// getFolderHierarchyPermissions gets permissions from a folder and all its parents
|
||||
func (a *api) getFolderHierarchyPermissions(ctx context.Context, namespace string, folderUID string, dynamicClient dynamic.Interface) (getResourcePermissionsResponse, error) {
|
||||
foldersGVR := schema.GroupVersionResource{
|
||||
Group: "folder.grafana.app",
|
||||
Version: "v1beta1",
|
||||
Resource: "folders",
|
||||
}
|
||||
|
||||
// GET /apis/folder.grafana.app/v1beta1/namespaces/{namespace}/folders/{folderUID}/parents
|
||||
parentsResource := dynamicClient.Resource(foldersGVR).Namespace(namespace)
|
||||
unstructuredResult, err := parentsResource.Get(ctx, folderUID, metav1.GetOptions{}, "parents")
|
||||
if err != nil {
|
||||
if k8serrors.IsNotFound(err) {
|
||||
// Folder not found or no parents
|
||||
return getResourcePermissionsResponse{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get folder parents from k8s: %w", err)
|
||||
}
|
||||
|
||||
var folderInfoList folderv1.FolderInfoList
|
||||
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(unstructuredResult.Object, &folderInfoList); err != nil {
|
||||
return nil, fmt.Errorf("failed to convert folder parents response: %w", err)
|
||||
}
|
||||
|
||||
if len(folderInfoList.Items) == 0 {
|
||||
return getResourcePermissionsResponse{}, nil
|
||||
}
|
||||
|
||||
allInheritedPermissions := make(getResourcePermissionsResponse, 0)
|
||||
resourcePermResource := dynamicClient.Resource(iamv0.ResourcePermissionInfo.GroupVersionResource()).Namespace(namespace)
|
||||
|
||||
for _, parentFolder := range folderInfoList.Items {
|
||||
if parentFolder.Detached {
|
||||
a.logger.Debug("Skipping detached parent folder", "folderName", parentFolder.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
if parentFolder.Name == folderUID {
|
||||
continue
|
||||
}
|
||||
|
||||
parentPermName := a.buildResourcePermissionName(parentFolder.Name)
|
||||
|
||||
unstructuredObj, err := resourcePermResource.Get(ctx, parentPermName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
if k8serrors.IsNotFound(err) {
|
||||
continue
|
||||
}
|
||||
a.logger.Warn("Failed to get parent folder permission from k8s", "error", err, "parentFolder", parentFolder.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
var parentResourcePerm iamv0.ResourcePermission
|
||||
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(unstructuredObj.Object, &parentResourcePerm); err != nil {
|
||||
a.logger.Warn("Failed to convert parent folder permission", "error", err, "parentFolder", parentFolder.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
inheritedDTO, err := a.convertK8sResourcePermissionToDTO(&parentResourcePerm, namespace, true)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to convert parent folder permissions to DTO", "error", err, "parentFolder", parentFolder.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
allInheritedPermissions = append(allInheritedPermissions, inheritedDTO...)
|
||||
}
|
||||
|
||||
return allInheritedPermissions, nil
|
||||
}
|
||||
|
||||
func (a *api) buildResourcePermissionName(resourceID string) string {
|
||||
return fmt.Sprintf("%s-%s-%s", a.getAPIGroup(), a.service.options.Resource, resourceID)
|
||||
}
|
||||
|
||||
@@ -166,24 +166,8 @@ func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) {
|
||||
githubInfo = spec["github"].(map[string]any)
|
||||
assert.Equal(t, "454546", githubInfo["installationID"], "installationID should be updated")
|
||||
|
||||
// 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")
|
||||
// DELETE
|
||||
require.NoError(t, helper.Connections.Resource.Delete(ctx, "connection", metav1.DeleteOptions{}), "failed to 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")
|
||||
|
||||
@@ -11,7 +11,6 @@ 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';
|
||||
@@ -20,26 +19,6 @@ 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: {
|
||||
@@ -58,17 +37,6 @@ 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 {
|
||||
@@ -136,7 +104,34 @@ export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({
|
||||
try {
|
||||
await queryFulfilled;
|
||||
} catch (e) {
|
||||
handleProvisioningFormError(e, dispatch, 'Error validating repository');
|
||||
// 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');
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -245,70 +240,6 @@ 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
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
NewObjectAddedToCanvasEvent,
|
||||
ObjectRemovedFromCanvasEvent,
|
||||
ObjectsReorderedOnCanvasEvent,
|
||||
RepeatsUpdatedEvent,
|
||||
} from './shared';
|
||||
|
||||
export interface DashboardEditPaneState extends SceneObjectState {
|
||||
@@ -88,12 +87,6 @@ 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,10 +57,12 @@ 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 outlineChildren = editableElement.getOutlineChildren?.(isEditing) ?? [];
|
||||
const visibleChildren = isEditing
|
||||
? outlineChildren
|
||||
: outlineChildren.filter((child) => !getEditableElementFor(child)?.getEditableElementInfo().isHidden);
|
||||
const visibleChildren = useMemo(() => {
|
||||
const children = editableElement.getOutlineChildren?.(isEditing) ?? [];
|
||||
return isEditing
|
||||
? children
|
||||
: children.filter((child) => !getEditableElementFor(child)?.getEditableElementInfo().isHidden);
|
||||
}, [editableElement, isEditing]);
|
||||
|
||||
const onNodeClicked = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -256,6 +258,7 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
}),
|
||||
nodeButtonClone: css({
|
||||
color: theme.colors.text.secondary,
|
||||
cursor: 'not-allowed',
|
||||
}),
|
||||
outlineInput: css({
|
||||
border: `1px solid ${theme.components.input.borderColor}`,
|
||||
|
||||
@@ -84,10 +84,6 @@ 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, RepeatsUpdatedEvent } from '../../edit-pane/shared';
|
||||
import { DashboardStateChangedEvent } from '../../edit-pane/shared';
|
||||
import { getCloneKey, getLocalVariableValueSet } from '../../utils/clone';
|
||||
import { getMultiVariableValues } from '../../utils/utils';
|
||||
import { scrollCanvasElementIntoView } from '../layouts-shared/scrollCanvasElementIntoView';
|
||||
@@ -147,7 +147,6 @@ 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, RepeatsUpdatedEvent } from '../../edit-pane/shared';
|
||||
import { DashboardStateChangedEvent } from '../../edit-pane/shared';
|
||||
import { getCloneKey, getLocalVariableValueSet } from '../../utils/clone';
|
||||
import { getMultiVariableValues } from '../../utils/utils';
|
||||
import { scrollCanvasElementIntoView, scrollIntoView } from '../layouts-shared/scrollCanvasElementIntoView';
|
||||
@@ -219,7 +219,6 @@ export class DashboardGridItem
|
||||
}
|
||||
|
||||
this._prevRepeatValues = values;
|
||||
this.publishEvent(new RepeatsUpdatedEvent(this), true);
|
||||
}
|
||||
|
||||
public handleVariableName() {
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
ConnectedContext,
|
||||
ConnectingContext,
|
||||
DisconnectedContext,
|
||||
ErrorContext,
|
||||
ServerPublicationContext,
|
||||
State,
|
||||
} from 'centrifuge';
|
||||
@@ -26,7 +25,6 @@ import {
|
||||
StreamingFrameAction,
|
||||
StreamingFrameOptions,
|
||||
BackendDataSourceResponse,
|
||||
getBackendSrv,
|
||||
} from '@grafana/runtime';
|
||||
|
||||
import { StreamingResponseData } from '../data/utils';
|
||||
@@ -73,7 +71,6 @@ 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));
|
||||
@@ -109,7 +106,6 @@ 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);
|
||||
}
|
||||
|
||||
//----------------------------------------------------------
|
||||
@@ -128,27 +124,6 @@ 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
|
||||
|
||||
@@ -11,11 +11,7 @@ export const usePluginConfig = (plugin?: CatalogPlugin) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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;
|
||||
const isPluginInstalled = config.pluginAdminExternalManageEnabled ? plugin.isFullyInstalled : plugin.isInstalled;
|
||||
|
||||
if (isPluginInstalled && !plugin.isDisabled) {
|
||||
return loadPlugin(plugin.id);
|
||||
|
||||
@@ -57,7 +57,6 @@ export function ConfigForm({ data }: ConfigFormProps) {
|
||||
const repositoryName = data?.metadata?.name;
|
||||
const settings = useGetFrontendSettingsQuery();
|
||||
const [submitData, request] = useCreateOrUpdateRepository(repositoryName);
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -78,6 +77,7 @@ 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,6 +104,17 @@ 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();
|
||||
@@ -115,9 +126,11 @@ export function ConfigForm({ data }: ConfigFormProps) {
|
||||
});
|
||||
|
||||
reset(formData);
|
||||
setTimeout(() => navigate(PROVISIONING_URL), 300);
|
||||
setTimeout(() => {
|
||||
navigate('/admin/provisioning');
|
||||
}, 300);
|
||||
}
|
||||
}, [request.isSuccess, reset, getValues, repositoryName, navigate]);
|
||||
}, [request.isSuccess, reset, getValues, navigate, repositoryName]);
|
||||
|
||||
const onSubmit = async (form: RepositoryFormData) => {
|
||||
setIsLoading(true);
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,199 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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} />;
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
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,16 +1,14 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { t, Trans } from '@grafana/i18n';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { Button, Dropdown, Icon, Menu, Stack } from '@grafana/ui';
|
||||
import { Button, ConfirmModal, 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';
|
||||
|
||||
@@ -23,102 +21,110 @@ 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();
|
||||
|
||||
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 });
|
||||
|
||||
useEffect(() => {
|
||||
if (deleteRequest.isSuccess) {
|
||||
setShowModal(false);
|
||||
if (redirectTo) {
|
||||
navigate(redirectTo);
|
||||
}
|
||||
},
|
||||
[deleteRepository, replaceRepository, name, repository, redirectTo, navigate]
|
||||
);
|
||||
}
|
||||
}, [deleteRequest.isSuccess, redirectTo, navigate]);
|
||||
|
||||
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 onConfirm = useCallback(async () => {
|
||||
if (selectedAction === 'keep-resources' && repository) {
|
||||
const updatedRepository = {
|
||||
...repository,
|
||||
metadata: {
|
||||
...repository.metadata,
|
||||
finalizers: ['cleanup', 'release-orphan-resources'],
|
||||
},
|
||||
};
|
||||
await replaceRepository({ name, repository: updatedRepository });
|
||||
}
|
||||
|
||||
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'),
|
||||
})
|
||||
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?'
|
||||
);
|
||||
}, [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 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={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>
|
||||
<>
|
||||
<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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { CONNECTIONS_URL, PROVISIONING_URL } from '../constants';
|
||||
import { PROVISIONING_URL } from '../constants';
|
||||
import { getRepoHrefForProvider } from '../utils/git';
|
||||
import { getIsReadOnlyWorkflows } from '../utils/repository';
|
||||
import { getRepositoryTypeConfig } from '../utils/repositoryTypes';
|
||||
@@ -34,9 +34,6 @@ 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,5 +1,4 @@
|
||||
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';
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { SelectableValue } from '@grafana/data';
|
||||
|
||||
import {
|
||||
BitbucketRepositoryConfig,
|
||||
ConnectionSpec,
|
||||
GitHubRepositoryConfig,
|
||||
GitLabRepositoryConfig,
|
||||
GitRepositoryConfig,
|
||||
@@ -52,16 +51,6 @@ 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 { ConnectionFormData, RepositoryFormData } from '../types';
|
||||
import { RepositoryFormData } from '../types';
|
||||
|
||||
export type RepositoryField = keyof WizardFormData['repository'];
|
||||
export type RepositoryFormPath = `repository.${RepositoryField}` | 'repository.sync.intervalSeconds';
|
||||
@@ -89,20 +89,3 @@ 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 { CONNECTIONS_URL, CONNECT_URL, GETTING_STARTED_URL, PROVISIONING_URL } from '../constants';
|
||||
import { PROVISIONING_URL, CONNECT_URL, GETTING_STARTED_URL } from '../constants';
|
||||
|
||||
export function getProvisioningRoutes(): RouteDescriptor[] {
|
||||
if (!checkRequiredFeatures()) {
|
||||
@@ -36,26 +36,6 @@ 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(
|
||||
|
||||
@@ -11806,53 +11806,7 @@
|
||||
"free-tier-limit-tooltip": "Free-tier accounts are restricted to one connection",
|
||||
"instance-fully-managed-tooltip": "Configuration is disabled because this instance is fully managed"
|
||||
},
|
||||
"connection-form": {
|
||||
"alert-connection-deleted": "Connection deleted",
|
||||
"alert-connection-saved": "Connection saved",
|
||||
"alert-connection-updated": "Connection updated",
|
||||
"back-to-connections": "Back to connections",
|
||||
"button-save": "Save",
|
||||
"button-saving": "Saving...",
|
||||
"description-app-id": "The ID of your GitHub App",
|
||||
"description-installation-id": "The installation ID of your GitHub App",
|
||||
"description-private-key": "The private key for your GitHub App in PEM format",
|
||||
"description-provider": "Select the provider type",
|
||||
"error-delete-connection": "Failed to delete connection",
|
||||
"error-required": "This field is required",
|
||||
"error-save-connection": "Failed to save connection",
|
||||
"label-app-id": "GitHub App ID",
|
||||
"label-installation-id": "GitHub Installation ID",
|
||||
"label-private-key": "Private Key (PEM)",
|
||||
"label-provider": "Provider",
|
||||
"not-found": "Connection not found",
|
||||
"not-found-description": "The connection you are looking for does not exist.",
|
||||
"page-subtitle": "Configure a connection to authenticate with external providers",
|
||||
"page-title-create": "Create connection",
|
||||
"page-title-edit": "Edit connection",
|
||||
"placeholder-app-id": "123456",
|
||||
"placeholder-installation-id": "12345678",
|
||||
"placeholder-private-key": "-----BEGIN RSA PRIVATE KEY-----..."
|
||||
},
|
||||
"connections": {
|
||||
"add-connection": "Add connection",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"delete-confirm": "Are you sure you want to delete this connection? This action cannot be undone.",
|
||||
"delete-title": "Delete connection",
|
||||
"error-loading": "Failed to load connections",
|
||||
"no-connections": "No connections configured",
|
||||
"no-connections-message": "Add a connection to authenticate with external providers",
|
||||
"no-results": "No results matching your query",
|
||||
"page-subtitle": "View and manage your app connections",
|
||||
"page-title": "Connections",
|
||||
"search-placeholder": "Search connections",
|
||||
"status-connected": "Connected",
|
||||
"status-disconnected": "Disconnected",
|
||||
"status-unknown": "Unknown",
|
||||
"view": "View"
|
||||
},
|
||||
"delete-repository-button": {
|
||||
"button-cancel": "Cancel",
|
||||
"button-delete": "Delete",
|
||||
"confirm-delete-keep-resources": "Are you sure you want to delete the repository configuration but keep its resources?",
|
||||
"confirm-delete-with-resources": "Are you sure you want to delete the repository configuration and all its resources?",
|
||||
@@ -12116,7 +12070,6 @@
|
||||
"jobs": "Jobs"
|
||||
},
|
||||
"repository-actions": {
|
||||
"connections": "Connections",
|
||||
"settings": "Settings",
|
||||
"source-code": "Source code"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user