mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
MySQL: Datasource health check error message improvements (#96906)
* updated mysql health check error messages * fix betterer error * fix lint errors * renamed moreDetailsLink to errorDetailsLink * update * added i18n locale files
This commit is contained in:
parent
94262fd095
commit
ea3bc8f253
@ -2789,13 +2789,6 @@ exports[`better eslint`] = {
|
|||||||
"public/app/features/datasources/components/DataSourcePluginState.tsx:5381": [
|
"public/app/features/datasources/components/DataSourcePluginState.tsx:5381": [
|
||||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
|
||||||
],
|
],
|
||||||
"public/app/features/datasources/components/DataSourceTestingStatus.tsx:5381": [
|
|
||||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
|
||||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
|
|
||||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
|
|
||||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
|
|
||||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"]
|
|
||||||
],
|
|
||||||
"public/app/features/datasources/components/DataSourceTypeCard.tsx:5381": [
|
"public/app/features/datasources/components/DataSourceTypeCard.tsx:5381": [
|
||||||
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"]
|
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"]
|
||||||
],
|
],
|
||||||
|
@ -4,6 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/go-sql-driver/mysql"
|
"github.com/go-sql-driver/mysql"
|
||||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
"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()
|
err := e.db.Ping()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logCheckHealthError(ctx, e.dsInfo, err, e.log)
|
logCheckHealthError(ctx, e.dsInfo, err, e.log)
|
||||||
if req.PluginContext.User.Role == "Admin" {
|
if strings.EqualFold(req.PluginContext.User.Role, "Admin") {
|
||||||
return &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: err.Error()}, nil
|
return ErrToHealthCheckResult(err)
|
||||||
}
|
}
|
||||||
var driverErr *mysql.MySQLError
|
var driverErr *mysql.MySQLError
|
||||||
if errors.As(err, &driverErr) {
|
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
|
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{
|
configSummary := map[string]any{
|
||||||
"config_url_length": len(dsInfo.URL),
|
"config_url_length": len(dsInfo.URL),
|
||||||
"config_user_length": len(dsInfo.User),
|
"config_user_length": len(dsInfo.User),
|
||||||
|
60
pkg/tsdb/mysql/sqleng/handler_checkhealth_test.go
Normal file
60
pkg/tsdb/mysql/sqleng/handler_checkhealth_test.go
Normal file
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -5,6 +5,7 @@ import { DataSourceSettings as DataSourceSettingsType, GrafanaTheme2 } from '@gr
|
|||||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||||
import { TestingStatus, config } from '@grafana/runtime';
|
import { TestingStatus, config } from '@grafana/runtime';
|
||||||
import { AlertVariant, Alert, useTheme2, Link, useStyles2 } from '@grafana/ui';
|
import { AlertVariant, Alert, useTheme2, Link, useStyles2 } from '@grafana/ui';
|
||||||
|
import { Trans } from 'app/core/internationalization';
|
||||||
|
|
||||||
import { contextSrv } from '../../../core/core';
|
import { contextSrv } from '../../../core/core';
|
||||||
import { trackCreateDashboardClicked } from '../tracking';
|
import { trackCreateDashboardClicked } from '../tracking';
|
||||||
@ -46,33 +47,77 @@ const AlertSuccessMessage = ({ title, exploreUrl, dataSourceId, onDashboardLinkC
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
Next, you can start to visualize data by{' '}
|
<Trans i18nKey="data-source-testing-status-page.success-more-details-links">
|
||||||
<Link
|
Next, you can start to visualize data by{' '}
|
||||||
aria-label={`Create a dashboard`}
|
<Link
|
||||||
href={`/dashboard/new-with-ds/${dataSourceId}`}
|
aria-label={`Create a dashboard`}
|
||||||
className="external-link"
|
href={`/dashboard/new-with-ds/${dataSourceId}`}
|
||||||
onClick={onDashboardLinkClicked}
|
className="external-link"
|
||||||
>
|
onClick={onDashboardLinkClicked}
|
||||||
building a dashboard
|
>
|
||||||
</Link>
|
building a dashboard
|
||||||
, or by querying data in the{' '}
|
</Link>
|
||||||
<Link
|
, or by querying data in the{' '}
|
||||||
aria-label={`Explore data`}
|
<Link
|
||||||
className={cx('external-link', {
|
aria-label={`Explore data`}
|
||||||
[`${styles.disabled}`]: !canExploreDataSources,
|
className={cx('external-link', {
|
||||||
'test-disabled': !canExploreDataSources,
|
[`${styles.disabled}`]: !canExploreDataSources,
|
||||||
})}
|
'test-disabled': !canExploreDataSources,
|
||||||
href={exploreUrl}
|
})}
|
||||||
>
|
href={exploreUrl}
|
||||||
Explore view
|
>
|
||||||
</Link>
|
Explore view
|
||||||
.
|
</Link>
|
||||||
|
.
|
||||||
|
</Trans>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
AlertSuccessMessage.displayName = 'AlertSuccessMessage';
|
AlertSuccessMessage.displayName = 'AlertSuccessMessage';
|
||||||
|
|
||||||
|
interface ErrorDetailsLinkProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
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 (
|
||||||
|
<div className={styles.content}>
|
||||||
|
<Trans i18nKey="data-source-testing-status-page.error-more-details-link">
|
||||||
|
Click{' '}
|
||||||
|
<Link
|
||||||
|
aria-label={`More details about the error`}
|
||||||
|
className={'external-link'}
|
||||||
|
href={link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
here
|
||||||
|
</Link>{' '}
|
||||||
|
to learn more about this error.
|
||||||
|
</Trans>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ErrorDetailsLink.displayName = 'ErrorDetailsLink';
|
||||||
|
|
||||||
const alertVariants = new Set(['success', 'info', 'warning', 'error']);
|
const alertVariants = new Set(['success', 'info', 'warning', 'error']);
|
||||||
const isAlertVariant = (str: string): str is AlertVariant => alertVariants.has(str);
|
const isAlertVariant = (str: string): str is AlertVariant => alertVariants.has(str);
|
||||||
const getAlertVariant = (status: string): AlertVariant => {
|
const getAlertVariant = (status: string): AlertVariant => {
|
||||||
@ -87,6 +132,7 @@ export function DataSourceTestingStatus({ testingStatus, exploreUrl, dataSource
|
|||||||
const message = testingStatus?.message;
|
const message = testingStatus?.message;
|
||||||
const detailsMessage = testingStatus?.details?.message;
|
const detailsMessage = testingStatus?.details?.message;
|
||||||
const detailsVerboseMessage = testingStatus?.details?.verboseMessage;
|
const detailsVerboseMessage = testingStatus?.details?.verboseMessage;
|
||||||
|
const errorDetailsLink = testingStatus?.details?.errorDetailsLink;
|
||||||
const onDashboardLinkClicked = () => {
|
const onDashboardLinkClicked = () => {
|
||||||
trackCreateDashboardClicked({
|
trackCreateDashboardClicked({
|
||||||
grafana_version: config.buildInfo.version,
|
grafana_version: config.buildInfo.version,
|
||||||
@ -103,7 +149,7 @@ export function DataSourceTestingStatus({ testingStatus, exploreUrl, dataSource
|
|||||||
<Alert severity={severity} title={message} data-testid={e2eSelectors.pages.DataSource.alert}>
|
<Alert severity={severity} title={message} data-testid={e2eSelectors.pages.DataSource.alert}>
|
||||||
{testingStatus?.details && (
|
{testingStatus?.details && (
|
||||||
<>
|
<>
|
||||||
{detailsMessage}
|
{detailsMessage ? <>{String(detailsMessage)}</> : null}
|
||||||
{severity === 'success' ? (
|
{severity === 'success' ? (
|
||||||
<AlertSuccessMessage
|
<AlertSuccessMessage
|
||||||
title={message}
|
title={message}
|
||||||
@ -112,6 +158,7 @@ export function DataSourceTestingStatus({ testingStatus, exploreUrl, dataSource
|
|||||||
onDashboardLinkClicked={onDashboardLinkClicked}
|
onDashboardLinkClicked={onDashboardLinkClicked}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{severity === 'error' && errorDetailsLink ? <ErrorDetailsLink link={String(errorDetailsLink)} /> : null}
|
||||||
{detailsVerboseMessage ? (
|
{detailsVerboseMessage ? (
|
||||||
<details style={{ whiteSpace: 'pre-wrap' }}>{String(detailsVerboseMessage)}</details>
|
<details style={{ whiteSpace: 'pre-wrap' }}>{String(detailsVerboseMessage)}</details>
|
||||||
) : null}
|
) : null}
|
||||||
@ -129,4 +176,7 @@ const getTestingStatusStyles = (theme: GrafanaTheme2) => ({
|
|||||||
container: css({
|
container: css({
|
||||||
paddingTop: theme.spacing(3),
|
paddingTop: theme.spacing(3),
|
||||||
}),
|
}),
|
||||||
|
moreLink: css({
|
||||||
|
marginBlock: theme.spacing(1),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
@ -1030,6 +1030,10 @@
|
|||||||
},
|
},
|
||||||
"open-advanced-button": "Open advanced data source picker"
|
"open-advanced-button": "Open advanced data source picker"
|
||||||
},
|
},
|
||||||
|
"data-source-testing-status-page": {
|
||||||
|
"error-more-details-link": "Click <2>here</2> to learn more about this error.",
|
||||||
|
"success-more-details-links": "Next, you can start to visualize data by <2>building a dashboard</2>, or by querying data in the <5>Explore view</5>."
|
||||||
|
},
|
||||||
"data-sources": {
|
"data-sources": {
|
||||||
"datasource-add-button": {
|
"datasource-add-button": {
|
||||||
"label": "Add new data source"
|
"label": "Add new data source"
|
||||||
|
@ -1030,6 +1030,10 @@
|
|||||||
},
|
},
|
||||||
"open-advanced-button": "Øpęʼn äđväʼnčęđ đäŧä şőūřčę pįčĸęř"
|
"open-advanced-button": "Øpęʼn äđväʼnčęđ đäŧä şőūřčę pįčĸęř"
|
||||||
},
|
},
|
||||||
|
"data-source-testing-status-page": {
|
||||||
|
"error-more-details-link": "Cľįčĸ <2>ĥęřę</2> ŧő ľęäřʼn mőřę äþőūŧ ŧĥįş ęřřőř.",
|
||||||
|
"success-more-details-links": "Ńęχŧ, yőū čäʼn şŧäřŧ ŧő vįşūäľįžę đäŧä þy <2>þūįľđįʼnģ ä đäşĥþőäřđ</2>, őř þy qūęřyįʼnģ đäŧä įʼn ŧĥę <5>Ēχpľőřę vįęŵ</5>."
|
||||||
|
},
|
||||||
"data-sources": {
|
"data-sources": {
|
||||||
"datasource-add-button": {
|
"datasource-add-button": {
|
||||||
"label": "Åđđ ʼnęŵ đäŧä şőūřčę"
|
"label": "Åđđ ʼnęŵ đäŧä şőūřčę"
|
||||||
|
Loading…
Reference in New Issue
Block a user