diff --git a/.betterer.results b/.betterer.results index af080651f8d..c64fc238700 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2789,13 +2789,6 @@ exports[`better eslint`] = { "public/app/features/datasources/components/DataSourcePluginState.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] ], - "public/app/features/datasources/components/DataSourceTestingStatus.tsx:5381": [ - [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "1"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "2"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "3"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "4"] - ], "public/app/features/datasources/components/DataSourceTypeCard.tsx:5381": [ [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"] ], diff --git a/pkg/tsdb/mysql/sqleng/handler_checkhealth.go b/pkg/tsdb/mysql/sqleng/handler_checkhealth.go index 9f58da99da8..d224bb9d7ee 100644 --- a/pkg/tsdb/mysql/sqleng/handler_checkhealth.go +++ b/pkg/tsdb/mysql/sqleng/handler_checkhealth.go @@ -4,6 +4,9 @@ import ( "context" "encoding/json" "errors" + "fmt" + "net" + "strings" "github.com/go-sql-driver/mysql" "github.com/grafana/grafana-plugin-sdk-go/backend" @@ -14,8 +17,8 @@ func (e *DataSourceHandler) CheckHealth(ctx context.Context, req *backend.CheckH err := e.db.Ping() if err != nil { logCheckHealthError(ctx, e.dsInfo, err, e.log) - if req.PluginContext.User.Role == "Admin" { - return &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: err.Error()}, nil + if strings.EqualFold(req.PluginContext.User.Role, "Admin") { + return ErrToHealthCheckResult(err) } var driverErr *mysql.MySQLError if errors.As(err, &driverErr) { @@ -26,7 +29,41 @@ func (e *DataSourceHandler) CheckHealth(ctx context.Context, req *backend.CheckH return &backend.CheckHealthResult{Status: backend.HealthStatusOk, Message: "Database Connection OK"}, nil } -func logCheckHealthError(ctx context.Context, dsInfo DataSourceInfo, err error, logger log.Logger) { +// ErrToHealthCheckResult converts error into user friendly health check message +// This should be called with non nil error. If the err parameter is empty, we will send Internal Server Error +func ErrToHealthCheckResult(err error) (*backend.CheckHealthResult, error) { + if err == nil { + return &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: "Internal Server Error"}, nil + } + res := &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: err.Error()} + details := map[string]string{} + var opErr *net.OpError + if errors.As(err, &opErr) { + res.Message = "Network error: Failed to connect to the server" + if opErr != nil && opErr.Err != nil { + res.Message += fmt.Sprintf(". Error message: %s", opErr.Err.Error()) + } + details["verboseMessage"] = err.Error() + details["errorDetailsLink"] = "https://grafana.com/docs/grafana/latest/datasources/mysql/#configure-the-data-source" + } + var driverErr *mysql.MySQLError + if errors.As(err, &driverErr) { + res.Message = "Database error: Failed to connect to the MySQL server" + if driverErr != nil && driverErr.Number > 0 { + res.Message += fmt.Sprintf(". MySQL error number: %d", driverErr.Number) + } + details["verboseMessage"] = err.Error() + details["errorDetailsLink"] = "https://dev.mysql.com/doc/mysql-errors/8.4/en/" + } + detailBytes, marshalErr := json.Marshal(details) + if marshalErr != nil { + return res, nil + } + res.JSONDetails = detailBytes + return res, nil +} + +func logCheckHealthError(_ context.Context, dsInfo DataSourceInfo, err error, logger log.Logger) { configSummary := map[string]any{ "config_url_length": len(dsInfo.URL), "config_user_length": len(dsInfo.User), diff --git a/pkg/tsdb/mysql/sqleng/handler_checkhealth_test.go b/pkg/tsdb/mysql/sqleng/handler_checkhealth_test.go new file mode 100644 index 00000000000..8f96b8b6e6f --- /dev/null +++ b/pkg/tsdb/mysql/sqleng/handler_checkhealth_test.go @@ -0,0 +1,60 @@ +package sqleng + +import ( + "errors" + "net" + "testing" + + "github.com/go-sql-driver/mysql" + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestErrToHealthCheckResult(t *testing.T) { + tests := []struct { + name string + err error + want *backend.CheckHealthResult + }{ + { + name: "without error", + want: &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: "Internal Server Error"}, + }, + { + name: "network error", + err: errors.Join(errors.New("foo"), &net.OpError{Op: "read", Net: "tcp", Err: errors.New("some op")}), + want: &backend.CheckHealthResult{ + Status: backend.HealthStatusError, + Message: "Network error: Failed to connect to the server. Error message: some op", + JSONDetails: []byte(`{"errorDetailsLink":"https://grafana.com/docs/grafana/latest/datasources/mysql/#configure-the-data-source","verboseMessage":"foo\nread tcp: some op"}`), + }, + }, + { + name: "db error", + err: errors.Join(errors.New("foo"), &mysql.MySQLError{Number: uint16(1045), Message: "Access denied for user"}), + want: &backend.CheckHealthResult{ + Status: backend.HealthStatusError, + Message: "Database error: Failed to connect to the MySQL server. MySQL error number: 1045", + JSONDetails: []byte(`{"errorDetailsLink":"https://dev.mysql.com/doc/mysql-errors/8.4/en/","verboseMessage":"foo\nError 1045: Access denied for user"}`), + }, + }, + { + name: "regular error", + err: errors.New("internal server error"), + want: &backend.CheckHealthResult{ + Status: backend.HealthStatusError, + Message: "internal server error", + JSONDetails: []byte(`{}`), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ErrToHealthCheckResult(tt.err) + require.Nil(t, err) + assert.Equal(t, string(tt.want.JSONDetails), string(got.JSONDetails)) + require.Equal(t, tt.want, got) + }) + } +} diff --git a/public/app/features/datasources/components/DataSourceTestingStatus.tsx b/public/app/features/datasources/components/DataSourceTestingStatus.tsx index a234396e290..ab98012a7c3 100644 --- a/public/app/features/datasources/components/DataSourceTestingStatus.tsx +++ b/public/app/features/datasources/components/DataSourceTestingStatus.tsx @@ -5,6 +5,7 @@ import { DataSourceSettings as DataSourceSettingsType, GrafanaTheme2 } from '@gr import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { TestingStatus, config } from '@grafana/runtime'; import { AlertVariant, Alert, useTheme2, Link, useStyles2 } from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; import { contextSrv } from '../../../core/core'; import { trackCreateDashboardClicked } from '../tracking'; @@ -46,33 +47,77 @@ const AlertSuccessMessage = ({ title, exploreUrl, dataSourceId, onDashboardLinkC return (
- Next, you can start to visualize data by{' '} - - building a dashboard - - , or by querying data in the{' '} - - Explore view - - . + + Next, you can start to visualize data by{' '} + + building a dashboard + + , or by querying data in the{' '} + + Explore view + + . +
); }; AlertSuccessMessage.displayName = 'AlertSuccessMessage'; +interface ErrorDetailsLinkProps extends HTMLAttributes { + link?: string; +} + +const ErrorDetailsLink = ({ link }: ErrorDetailsLinkProps) => { + const theme = useTheme2(); + const styles = { + content: css({ + color: theme.colors.text.secondary, + paddingBlock: theme.spacing(1), + maxHeight: '50vh', + overflowY: 'auto', + }), + }; + if (!link) { + return <>; + } + const isValidUrl = /^(http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?$/.test(link); + if (!isValidUrl) { + return <>; + } + return ( +
+ + Click{' '} + + here + {' '} + to learn more about this error. + +
+ ); +}; + +ErrorDetailsLink.displayName = 'ErrorDetailsLink'; + const alertVariants = new Set(['success', 'info', 'warning', 'error']); const isAlertVariant = (str: string): str is AlertVariant => alertVariants.has(str); const getAlertVariant = (status: string): AlertVariant => { @@ -87,6 +132,7 @@ export function DataSourceTestingStatus({ testingStatus, exploreUrl, dataSource const message = testingStatus?.message; const detailsMessage = testingStatus?.details?.message; const detailsVerboseMessage = testingStatus?.details?.verboseMessage; + const errorDetailsLink = testingStatus?.details?.errorDetailsLink; const onDashboardLinkClicked = () => { trackCreateDashboardClicked({ grafana_version: config.buildInfo.version, @@ -103,7 +149,7 @@ export function DataSourceTestingStatus({ testingStatus, exploreUrl, dataSource {testingStatus?.details && ( <> - {detailsMessage} + {detailsMessage ? <>{String(detailsMessage)} : null} {severity === 'success' ? ( ) : null} + {severity === 'error' && errorDetailsLink ? : null} {detailsVerboseMessage ? (
{String(detailsVerboseMessage)}
) : null} @@ -129,4 +176,7 @@ const getTestingStatusStyles = (theme: GrafanaTheme2) => ({ container: css({ paddingTop: theme.spacing(3), }), + moreLink: css({ + marginBlock: theme.spacing(1), + }), }); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 019378b6dd9..79baefabb0f 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1030,6 +1030,10 @@ }, "open-advanced-button": "Open advanced data source picker" }, + "data-source-testing-status-page": { + "error-more-details-link": "Click <2>here to learn more about this error.", + "success-more-details-links": "Next, you can start to visualize data by <2>building a dashboard, or by querying data in the <5>Explore view." + }, "data-sources": { "datasource-add-button": { "label": "Add new data source" diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 2ed2c906611..2cd1c0a7625 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -1030,6 +1030,10 @@ }, "open-advanced-button": "Øpęʼn äđväʼnčęđ đäŧä şőūřčę pįčĸęř" }, + "data-source-testing-status-page": { + "error-more-details-link": "Cľįčĸ <2>ĥęřę ŧő ľęäřʼn mőřę äþőūŧ ŧĥįş ęřřőř.", + "success-more-details-links": "Ńęχŧ, yőū čäʼn şŧäřŧ ŧő vįşūäľįžę đäŧä þy <2>þūįľđįʼnģ ä đäşĥþőäřđ, őř þy qūęřyįʼnģ đäŧä įʼn ŧĥę <5>Ēχpľőřę vįęŵ." + }, "data-sources": { "datasource-add-button": { "label": "Åđđ ʼnęŵ đäŧä şőūřčę"