mirror of https://github.com/grafana/grafana
commit
05ae5be918
|
@ -0,0 +1,93 @@ |
|||||||
|
package sqleng |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"encoding/json" |
||||||
|
"errors" |
||||||
|
"fmt" |
||||||
|
"net" |
||||||
|
"strings" |
||||||
|
|
||||||
|
"github.com/grafana/grafana-plugin-sdk-go/backend" |
||||||
|
"github.com/grafana/grafana-plugin-sdk-go/backend/log" |
||||||
|
) |
||||||
|
|
||||||
|
func (e *DataSourceHandler) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { |
||||||
|
if err := e.db.Ping(); err != nil { |
||||||
|
logCheckHealthError(ctx, e.dsInfo, err) |
||||||
|
if strings.EqualFold(req.PluginContext.User.Role, "Admin") { |
||||||
|
return ErrToHealthCheckResult(err) |
||||||
|
} |
||||||
|
return &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: e.TransformQueryError(e.log, err).Error()}, nil |
||||||
|
} |
||||||
|
return &backend.CheckHealthResult{Status: backend.HealthStatusOk, Message: "Database Connection OK"}, nil |
||||||
|
} |
||||||
|
|
||||||
|
// 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{ |
||||||
|
"verboseMessage": err.Error(), |
||||||
|
"errorDetailsLink": "https://grafana.com/docs/grafana/latest/datasources/mssql", |
||||||
|
} |
||||||
|
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()) |
||||||
|
} |
||||||
|
} |
||||||
|
if strings.HasPrefix(err.Error(), "mssql: ") { |
||||||
|
res.Message = "Database error: Failed to connect to the mssql server" |
||||||
|
if unwrappedErr := errors.Unwrap(err); unwrappedErr != nil { |
||||||
|
details["verboseMessage"] = unwrappedErr.Error() |
||||||
|
} |
||||||
|
} |
||||||
|
detailBytes, marshalErr := json.Marshal(details) |
||||||
|
if marshalErr != nil { |
||||||
|
return res, nil |
||||||
|
} |
||||||
|
res.JSONDetails = detailBytes |
||||||
|
return res, nil |
||||||
|
} |
||||||
|
|
||||||
|
func logCheckHealthError(ctx context.Context, dsInfo DataSourceInfo, err error) { |
||||||
|
logger := log.DefaultLogger.FromContext(ctx) |
||||||
|
configSummary := map[string]any{ |
||||||
|
"config_url_length": len(dsInfo.URL), |
||||||
|
"config_user_length": len(dsInfo.User), |
||||||
|
"config_database_length": len(dsInfo.Database), |
||||||
|
"config_json_data_database_length": len(dsInfo.JsonData.Database), |
||||||
|
"config_max_open_conns": dsInfo.JsonData.MaxOpenConns, |
||||||
|
"config_max_idle_conns": dsInfo.JsonData.MaxIdleConns, |
||||||
|
"config_conn_max_life_time": dsInfo.JsonData.ConnMaxLifetime, |
||||||
|
"config_conn_timeout": dsInfo.JsonData.ConnectionTimeout, |
||||||
|
"config_ssl_mode": dsInfo.JsonData.Mode, |
||||||
|
"config_tls_configuration_method": dsInfo.JsonData.ConfigurationMethod, |
||||||
|
"config_tls_skip_verify": dsInfo.JsonData.TlsSkipVerify, |
||||||
|
"config_timezone": dsInfo.JsonData.Timezone, |
||||||
|
"config_time_interval": dsInfo.JsonData.TimeInterval, |
||||||
|
"config_enable_secure_proxy": dsInfo.JsonData.SecureDSProxy, |
||||||
|
"config_allow_clear_text_passwords": dsInfo.JsonData.AllowCleartextPasswords, |
||||||
|
"config_authentication_type": dsInfo.JsonData.AuthenticationType, |
||||||
|
"config_ssl_root_cert_file_length": len(dsInfo.JsonData.RootCertFile), |
||||||
|
"config_ssl_cert_file_length": len(dsInfo.JsonData.CertFile), |
||||||
|
"config_ssl_key_file_length": len(dsInfo.JsonData.CertKeyFile), |
||||||
|
"config_encrypt_length": len(dsInfo.JsonData.Encrypt), |
||||||
|
"config_server_name_length": len(dsInfo.JsonData.Servername), |
||||||
|
"config_password_length": len(dsInfo.DecryptedSecureJSONData["password"]), |
||||||
|
"config_tls_ca_cert_length": len(dsInfo.DecryptedSecureJSONData["tlsCACert"]), |
||||||
|
"config_tls_client_cert_length": len(dsInfo.DecryptedSecureJSONData["tlsClientCert"]), |
||||||
|
"config_tls_client_key_length": len(dsInfo.DecryptedSecureJSONData["tlsClientKey"]), |
||||||
|
} |
||||||
|
configSummaryJson, marshalError := json.Marshal(configSummary) |
||||||
|
if marshalError != nil { |
||||||
|
logger.Error("Check health failed", "error", err, "message_type", "ds_config_health_check_error") |
||||||
|
return |
||||||
|
} |
||||||
|
logger.Error("Check health failed", "error", err, "message_type", "ds_config_health_check_error_detailed", "details", string(configSummaryJson)) |
||||||
|
} |
@ -0,0 +1,60 @@ |
|||||||
|
package sqleng |
||||||
|
|
||||||
|
import ( |
||||||
|
"errors" |
||||||
|
"net" |
||||||
|
"testing" |
||||||
|
|
||||||
|
"github.com/grafana/grafana-plugin-sdk-go/backend" |
||||||
|
mssql "github.com/microsoft/go-mssqldb" |
||||||
|
"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/mssql","verboseMessage":"foo\nread tcp: some op"}`), |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "db error", |
||||||
|
err: errors.Join(errors.New("foo"), &mssql.Error{Message: "error foo occurred in mssql server"}), |
||||||
|
want: &backend.CheckHealthResult{ |
||||||
|
Status: backend.HealthStatusError, |
||||||
|
Message: "foo\nmssql: error foo occurred in mssql server", |
||||||
|
JSONDetails: []byte(`{"errorDetailsLink":"https://grafana.com/docs/grafana/latest/datasources/mssql","verboseMessage":"foo\nmssql: error foo occurred in mssql server"}`), |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "regular error", |
||||||
|
err: errors.New("internal server error"), |
||||||
|
want: &backend.CheckHealthResult{ |
||||||
|
Status: backend.HealthStatusError, |
||||||
|
Message: "internal server error", |
||||||
|
JSONDetails: []byte(`{"errorDetailsLink":"https://grafana.com/docs/grafana/latest/datasources/mssql","verboseMessage":"internal server error"}`), |
||||||
|
}, |
||||||
|
}, |
||||||
|
} |
||||||
|
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) |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,130 @@ |
|||||||
|
import { useMemo } from 'react'; |
||||||
|
|
||||||
|
import { sceneGraph, SceneObjectBase, VizPanel } from '@grafana/scenes'; |
||||||
|
import { Button } from '@grafana/ui'; |
||||||
|
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; |
||||||
|
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; |
||||||
|
import { getVisualizationOptions2 } from 'app/features/dashboard/components/PanelEditor/getVisualizationOptions'; |
||||||
|
|
||||||
|
import { |
||||||
|
PanelBackgroundSwitch, |
||||||
|
PanelDescriptionTextArea, |
||||||
|
PanelFrameTitleInput, |
||||||
|
} from '../panel-edit/getPanelFrameOptions'; |
||||||
|
import { EditableDashboardElement, isDashboardLayoutItem } from '../scene/types'; |
||||||
|
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; |
||||||
|
|
||||||
|
export class VizPanelEditPaneBehavior extends SceneObjectBase implements EditableDashboardElement { |
||||||
|
public isEditableDashboardElement: true = true; |
||||||
|
|
||||||
|
private getPanel(): VizPanel { |
||||||
|
const panel = this.parent; |
||||||
|
|
||||||
|
if (!(panel instanceof VizPanel)) { |
||||||
|
throw new Error('VizPanelEditPaneBehavior must have a VizPanel parent'); |
||||||
|
} |
||||||
|
|
||||||
|
return panel; |
||||||
|
} |
||||||
|
|
||||||
|
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] { |
||||||
|
const panel = this.getPanel(); |
||||||
|
const layoutElement = panel.parent!; |
||||||
|
|
||||||
|
const panelOptions = useMemo(() => { |
||||||
|
return new OptionsPaneCategoryDescriptor({ |
||||||
|
title: 'Panel options', |
||||||
|
id: 'panel-options', |
||||||
|
isOpenDefault: true, |
||||||
|
}) |
||||||
|
.addItem( |
||||||
|
new OptionsPaneItemDescriptor({ |
||||||
|
title: 'Title', |
||||||
|
value: panel.state.title, |
||||||
|
popularRank: 1, |
||||||
|
render: function renderTitle() { |
||||||
|
return <PanelFrameTitleInput panel={panel} />; |
||||||
|
}, |
||||||
|
}) |
||||||
|
) |
||||||
|
.addItem( |
||||||
|
new OptionsPaneItemDescriptor({ |
||||||
|
title: 'Description', |
||||||
|
value: panel.state.description, |
||||||
|
render: function renderDescription() { |
||||||
|
return <PanelDescriptionTextArea panel={panel} />; |
||||||
|
}, |
||||||
|
}) |
||||||
|
) |
||||||
|
.addItem( |
||||||
|
new OptionsPaneItemDescriptor({ |
||||||
|
title: 'Transparent background', |
||||||
|
render: function renderTransparent() { |
||||||
|
return <PanelBackgroundSwitch panel={panel} />; |
||||||
|
}, |
||||||
|
}) |
||||||
|
); |
||||||
|
}, [panel]); |
||||||
|
|
||||||
|
const layoutCategory = useMemo(() => { |
||||||
|
if (isDashboardLayoutItem(layoutElement) && layoutElement.getOptions) { |
||||||
|
return layoutElement.getOptions(); |
||||||
|
} |
||||||
|
return undefined; |
||||||
|
}, [layoutElement]); |
||||||
|
|
||||||
|
const { options, fieldConfig, _pluginInstanceState } = panel.useState(); |
||||||
|
const dataProvider = sceneGraph.getData(panel); |
||||||
|
const { data } = dataProvider.useState(); |
||||||
|
|
||||||
|
const visualizationOptions = useMemo(() => { |
||||||
|
const plugin = panel.getPlugin(); |
||||||
|
if (!plugin) { |
||||||
|
return []; |
||||||
|
} |
||||||
|
|
||||||
|
return getVisualizationOptions2({ |
||||||
|
panel, |
||||||
|
data, |
||||||
|
plugin: plugin, |
||||||
|
eventBus: panel.getPanelContext().eventBus, |
||||||
|
instanceState: _pluginInstanceState, |
||||||
|
}); |
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [data, panel, options, fieldConfig, _pluginInstanceState]); |
||||||
|
|
||||||
|
const categories = [panelOptions]; |
||||||
|
if (layoutCategory) { |
||||||
|
categories.push(layoutCategory); |
||||||
|
} |
||||||
|
|
||||||
|
categories.push(...visualizationOptions); |
||||||
|
|
||||||
|
return categories; |
||||||
|
} |
||||||
|
|
||||||
|
public getTypeName(): string { |
||||||
|
return 'Panel'; |
||||||
|
} |
||||||
|
|
||||||
|
public onDelete = () => { |
||||||
|
const layout = dashboardSceneGraph.getLayoutManagerFor(this); |
||||||
|
layout.removePanel(this.getPanel()); |
||||||
|
}; |
||||||
|
|
||||||
|
public renderActions(): React.ReactNode { |
||||||
|
return ( |
||||||
|
<> |
||||||
|
<Button size="sm" variant="secondary"> |
||||||
|
Edit |
||||||
|
</Button> |
||||||
|
<Button size="sm" variant="secondary"> |
||||||
|
Copy |
||||||
|
</Button> |
||||||
|
<Button size="sm" variant="destructive" fill="outline" onClick={this.onDelete}> |
||||||
|
Delete |
||||||
|
</Button> |
||||||
|
</> |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,242 @@ |
|||||||
|
import { css, cx } from '@emotion/css'; |
||||||
|
import { useMemo, useRef } from 'react'; |
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data'; |
||||||
|
import { selectors } from '@grafana/e2e-selectors'; |
||||||
|
import { SceneObjectState, SceneObjectBase, SceneComponentProps, sceneGraph } from '@grafana/scenes'; |
||||||
|
import { Button, Icon, Input, RadioButtonGroup, Switch, useStyles2 } from '@grafana/ui'; |
||||||
|
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; |
||||||
|
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; |
||||||
|
|
||||||
|
import { getDashboardSceneFor, getDefaultVizPanel } from '../../utils/utils'; |
||||||
|
import { useLayoutCategory } from '../layouts-shared/DashboardLayoutSelector'; |
||||||
|
import { DashboardLayoutManager, EditableDashboardElement, LayoutParent } from '../types'; |
||||||
|
|
||||||
|
import { RowsLayoutManager } from './RowsLayoutManager'; |
||||||
|
|
||||||
|
export interface RowItemState extends SceneObjectState { |
||||||
|
layout: DashboardLayoutManager; |
||||||
|
title?: string; |
||||||
|
isCollapsed?: boolean; |
||||||
|
isHeaderHidden?: boolean; |
||||||
|
height?: 'expand' | 'min'; |
||||||
|
} |
||||||
|
|
||||||
|
export class RowItem extends SceneObjectBase<RowItemState> implements LayoutParent, EditableDashboardElement { |
||||||
|
public isEditableDashboardElement: true = true; |
||||||
|
|
||||||
|
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] { |
||||||
|
const row = this; |
||||||
|
|
||||||
|
const rowOptions = useMemo(() => { |
||||||
|
return new OptionsPaneCategoryDescriptor({ |
||||||
|
title: 'Row options', |
||||||
|
id: 'row-options', |
||||||
|
isOpenDefault: true, |
||||||
|
}) |
||||||
|
.addItem( |
||||||
|
new OptionsPaneItemDescriptor({ |
||||||
|
title: 'Title', |
||||||
|
render: () => <RowTitleInput row={row} />, |
||||||
|
}) |
||||||
|
) |
||||||
|
.addItem( |
||||||
|
new OptionsPaneItemDescriptor({ |
||||||
|
title: 'Height', |
||||||
|
render: () => <RowHeightSelect row={row} />, |
||||||
|
}) |
||||||
|
) |
||||||
|
.addItem( |
||||||
|
new OptionsPaneItemDescriptor({ |
||||||
|
title: 'Hide row header', |
||||||
|
render: () => <RowHeaderSwitch row={row} />, |
||||||
|
}) |
||||||
|
); |
||||||
|
}, [row]); |
||||||
|
|
||||||
|
const { layout } = this.useState(); |
||||||
|
const layoutOptions = useLayoutCategory(layout); |
||||||
|
|
||||||
|
return [rowOptions, layoutOptions]; |
||||||
|
} |
||||||
|
|
||||||
|
public getTypeName(): string { |
||||||
|
return 'Row'; |
||||||
|
} |
||||||
|
|
||||||
|
public onDelete = () => { |
||||||
|
const layout = sceneGraph.getAncestor(this, RowsLayoutManager); |
||||||
|
layout.removeRow(this); |
||||||
|
}; |
||||||
|
|
||||||
|
public renderActions(): React.ReactNode { |
||||||
|
return ( |
||||||
|
<> |
||||||
|
<Button size="sm" variant="secondary"> |
||||||
|
Copy |
||||||
|
</Button> |
||||||
|
<Button size="sm" variant="primary" onClick={this.onAddPanel} fill="outline"> |
||||||
|
Add panel |
||||||
|
</Button> |
||||||
|
<Button size="sm" variant="destructive" fill="outline" onClick={this.onDelete}> |
||||||
|
Delete |
||||||
|
</Button> |
||||||
|
</> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
public getLayout(): DashboardLayoutManager { |
||||||
|
return this.state.layout; |
||||||
|
} |
||||||
|
|
||||||
|
public switchLayout(layout: DashboardLayoutManager): void { |
||||||
|
this.setState({ layout }); |
||||||
|
} |
||||||
|
|
||||||
|
public onCollapseToggle = () => { |
||||||
|
this.setState({ isCollapsed: !this.state.isCollapsed }); |
||||||
|
}; |
||||||
|
|
||||||
|
public onAddPanel = () => { |
||||||
|
const vizPanel = getDefaultVizPanel(); |
||||||
|
this.state.layout.addPanel(vizPanel); |
||||||
|
}; |
||||||
|
|
||||||
|
public onEdit = () => { |
||||||
|
const dashboard = getDashboardSceneFor(this); |
||||||
|
dashboard.state.editPane.selectObject(this); |
||||||
|
}; |
||||||
|
|
||||||
|
public static Component = ({ model }: SceneComponentProps<RowItem>) => { |
||||||
|
const { layout, title, isCollapsed, height = 'expand' } = model.useState(); |
||||||
|
const { isEditing } = getDashboardSceneFor(model).useState(); |
||||||
|
const styles = useStyles2(getStyles); |
||||||
|
const titleInterpolated = sceneGraph.interpolate(model, title, undefined, 'text'); |
||||||
|
const ref = useRef<HTMLDivElement>(null); |
||||||
|
const shouldGrow = !isCollapsed && height === 'expand'; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div |
||||||
|
className={cx(styles.wrapper, isCollapsed && styles.wrapperCollapsed, shouldGrow && styles.wrapperGrow)} |
||||||
|
ref={ref} |
||||||
|
> |
||||||
|
<div className={styles.rowHeader}> |
||||||
|
<button |
||||||
|
onClick={model.onCollapseToggle} |
||||||
|
className={styles.rowTitleButton} |
||||||
|
aria-label={isCollapsed ? 'Expand row' : 'Collapse row'} |
||||||
|
data-testid={selectors.components.DashboardRow.title(titleInterpolated)} |
||||||
|
> |
||||||
|
<Icon name={isCollapsed ? 'angle-right' : 'angle-down'} /> |
||||||
|
<span className={styles.rowTitle} role="heading"> |
||||||
|
{titleInterpolated} |
||||||
|
</span> |
||||||
|
</button> |
||||||
|
{isEditing && <Button icon="pen" variant="secondary" size="sm" fill="text" onClick={() => model.onEdit()} />} |
||||||
|
</div> |
||||||
|
{!isCollapsed && <layout.Component model={layout} />} |
||||||
|
</div> |
||||||
|
); |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
function getStyles(theme: GrafanaTheme2) { |
||||||
|
return { |
||||||
|
rowHeader: css({ |
||||||
|
width: '100%', |
||||||
|
display: 'flex', |
||||||
|
gap: theme.spacing(1), |
||||||
|
padding: theme.spacing(0, 0, 0.5, 0), |
||||||
|
margin: theme.spacing(0, 0, 1, 0), |
||||||
|
alignItems: 'center', |
||||||
|
|
||||||
|
'&:hover, &:focus-within': { |
||||||
|
'& > div': { |
||||||
|
opacity: 1, |
||||||
|
}, |
||||||
|
}, |
||||||
|
|
||||||
|
'& > div': { |
||||||
|
marginBottom: 0, |
||||||
|
marginRight: theme.spacing(1), |
||||||
|
}, |
||||||
|
}), |
||||||
|
rowTitleButton: css({ |
||||||
|
display: 'flex', |
||||||
|
alignItems: 'center', |
||||||
|
cursor: 'pointer', |
||||||
|
background: 'transparent', |
||||||
|
border: 'none', |
||||||
|
minWidth: 0, |
||||||
|
gap: theme.spacing(1), |
||||||
|
}), |
||||||
|
rowTitle: css({ |
||||||
|
fontSize: theme.typography.h5.fontSize, |
||||||
|
fontWeight: theme.typography.fontWeightMedium, |
||||||
|
whiteSpace: 'nowrap', |
||||||
|
overflow: 'hidden', |
||||||
|
textOverflow: 'ellipsis', |
||||||
|
maxWidth: '100%', |
||||||
|
flexGrow: 1, |
||||||
|
minWidth: 0, |
||||||
|
}), |
||||||
|
wrapper: css({ |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
width: '100%', |
||||||
|
}), |
||||||
|
wrapperGrow: css({ |
||||||
|
flexGrow: 1, |
||||||
|
}), |
||||||
|
wrapperCollapsed: css({ |
||||||
|
flexGrow: 0, |
||||||
|
borderBottom: `1px solid ${theme.colors.border.weak}`, |
||||||
|
}), |
||||||
|
rowActions: css({ |
||||||
|
display: 'flex', |
||||||
|
opacity: 0, |
||||||
|
}), |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
export function RowTitleInput({ row }: { row: RowItem }) { |
||||||
|
const { title } = row.useState(); |
||||||
|
|
||||||
|
return <Input value={title} onChange={(e) => row.setState({ title: e.currentTarget.value })} />; |
||||||
|
} |
||||||
|
|
||||||
|
export function RowHeaderSwitch({ row }: { row: RowItem }) { |
||||||
|
const { isHeaderHidden } = row.useState(); |
||||||
|
|
||||||
|
return ( |
||||||
|
<Switch |
||||||
|
value={isHeaderHidden} |
||||||
|
onChange={() => { |
||||||
|
row.setState({ |
||||||
|
isHeaderHidden: !row.state.isHeaderHidden, |
||||||
|
}); |
||||||
|
}} |
||||||
|
/> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function RowHeightSelect({ row }: { row: RowItem }) { |
||||||
|
const { height = 'expand' } = row.useState(); |
||||||
|
|
||||||
|
const options = [ |
||||||
|
{ label: 'Expand', value: 'expand' as const }, |
||||||
|
{ label: 'Min', value: 'min' as const }, |
||||||
|
]; |
||||||
|
|
||||||
|
return ( |
||||||
|
<RadioButtonGroup |
||||||
|
options={options} |
||||||
|
value={height} |
||||||
|
onChange={(option) => |
||||||
|
row.setState({ |
||||||
|
height: option, |
||||||
|
}) |
||||||
|
} |
||||||
|
/> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,113 @@ |
|||||||
|
import { css } from '@emotion/css'; |
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data'; |
||||||
|
import { SceneComponentProps, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes'; |
||||||
|
import { useStyles2 } from '@grafana/ui'; |
||||||
|
|
||||||
|
import { ResponsiveGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager'; |
||||||
|
import { DashboardLayoutManager, LayoutRegistryItem } from '../types'; |
||||||
|
|
||||||
|
import { RowItem } from './RowItem'; |
||||||
|
|
||||||
|
interface RowsLayoutManagerState extends SceneObjectState { |
||||||
|
rows: RowItem[]; |
||||||
|
} |
||||||
|
|
||||||
|
export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> implements DashboardLayoutManager { |
||||||
|
public isDashboardLayoutManager: true = true; |
||||||
|
|
||||||
|
public editModeChanged(isEditing: boolean): void {} |
||||||
|
|
||||||
|
public addPanel(vizPanel: VizPanel): void {} |
||||||
|
|
||||||
|
public addNewRow(): void { |
||||||
|
this.setState({ |
||||||
|
rows: [ |
||||||
|
...this.state.rows, |
||||||
|
new RowItem({ |
||||||
|
title: 'New row', |
||||||
|
layout: ResponsiveGridLayoutManager.createEmpty(), |
||||||
|
}), |
||||||
|
], |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
public getNextPanelId(): number { |
||||||
|
return 0; |
||||||
|
} |
||||||
|
|
||||||
|
public removePanel(panel: VizPanel) {} |
||||||
|
|
||||||
|
public removeRow(row: RowItem) { |
||||||
|
this.setState({ |
||||||
|
rows: this.state.rows.filter((r) => r !== row), |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
public duplicatePanel(panel: VizPanel): void { |
||||||
|
throw new Error('Method not implemented.'); |
||||||
|
} |
||||||
|
|
||||||
|
public getVizPanels(): VizPanel[] { |
||||||
|
const panels: VizPanel[] = []; |
||||||
|
|
||||||
|
for (const row of this.state.rows) { |
||||||
|
const innerPanels = row.state.layout.getVizPanels(); |
||||||
|
panels.push(...innerPanels); |
||||||
|
} |
||||||
|
|
||||||
|
return panels; |
||||||
|
} |
||||||
|
|
||||||
|
public getOptions() { |
||||||
|
return []; |
||||||
|
} |
||||||
|
|
||||||
|
public getDescriptor(): LayoutRegistryItem { |
||||||
|
return RowsLayoutManager.getDescriptor(); |
||||||
|
} |
||||||
|
|
||||||
|
public static getDescriptor(): LayoutRegistryItem { |
||||||
|
return { |
||||||
|
name: 'Rows', |
||||||
|
description: 'Rows layout', |
||||||
|
id: 'rows-layout', |
||||||
|
createFromLayout: RowsLayoutManager.createFromLayout, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
public static createEmpty() { |
||||||
|
return new RowsLayoutManager({ rows: [] }); |
||||||
|
} |
||||||
|
|
||||||
|
public static createFromLayout(layout: DashboardLayoutManager): RowsLayoutManager { |
||||||
|
const row = new RowItem({ layout: layout.clone(), title: 'Row title' }); |
||||||
|
|
||||||
|
return new RowsLayoutManager({ rows: [row] }); |
||||||
|
} |
||||||
|
|
||||||
|
public static Component = ({ model }: SceneComponentProps<RowsLayoutManager>) => { |
||||||
|
const { rows } = model.useState(); |
||||||
|
const styles = useStyles2(getStyles); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={styles.wrapper}> |
||||||
|
{rows.map((row) => ( |
||||||
|
<RowItem.Component model={row} key={row.state.key!} /> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
); |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
function getStyles(theme: GrafanaTheme2) { |
||||||
|
return { |
||||||
|
wrapper: css({ |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
gap: theme.spacing(1), |
||||||
|
height: '100%', |
||||||
|
width: '100%', |
||||||
|
}), |
||||||
|
}; |
||||||
|
} |
@ -0,0 +1,66 @@ |
|||||||
|
import { useMemo } from 'react'; |
||||||
|
|
||||||
|
import { Select } from '@grafana/ui'; |
||||||
|
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; |
||||||
|
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; |
||||||
|
|
||||||
|
import { DashboardLayoutManager, isLayoutParent, LayoutRegistryItem } from '../types'; |
||||||
|
|
||||||
|
import { layoutRegistry } from './layoutRegistry'; |
||||||
|
|
||||||
|
export interface Props { |
||||||
|
layoutManager: DashboardLayoutManager; |
||||||
|
} |
||||||
|
|
||||||
|
export function DashboardLayoutSelector({ layoutManager }: { layoutManager: DashboardLayoutManager }) { |
||||||
|
const layouts = layoutRegistry.list(); |
||||||
|
const options = layouts.map((layout) => ({ |
||||||
|
label: layout.name, |
||||||
|
value: layout, |
||||||
|
})); |
||||||
|
|
||||||
|
const currentLayoutId = layoutManager.getDescriptor().id; |
||||||
|
const currentLayoutOption = options.find((option) => option.value.id === currentLayoutId); |
||||||
|
|
||||||
|
return ( |
||||||
|
<Select |
||||||
|
options={options} |
||||||
|
value={currentLayoutOption} |
||||||
|
onChange={(option) => changeLayoutTo(layoutManager, option.value!)} |
||||||
|
/> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function useLayoutCategory(layoutManager: DashboardLayoutManager) { |
||||||
|
return useMemo(() => { |
||||||
|
const layoutCategory = new OptionsPaneCategoryDescriptor({ |
||||||
|
title: 'Layout', |
||||||
|
id: 'layout-options', |
||||||
|
isOpenDefault: true, |
||||||
|
}); |
||||||
|
|
||||||
|
layoutCategory.addItem( |
||||||
|
new OptionsPaneItemDescriptor({ |
||||||
|
title: 'Type', |
||||||
|
render: function renderTitle() { |
||||||
|
return <DashboardLayoutSelector layoutManager={layoutManager} />; |
||||||
|
}, |
||||||
|
}) |
||||||
|
); |
||||||
|
|
||||||
|
if (layoutManager.getOptions) { |
||||||
|
for (const option of layoutManager.getOptions()) { |
||||||
|
layoutCategory.addItem(option); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return layoutCategory; |
||||||
|
}, [layoutManager]); |
||||||
|
} |
||||||
|
|
||||||
|
function changeLayoutTo(currentLayout: DashboardLayoutManager, newLayoutDescriptor: LayoutRegistryItem) { |
||||||
|
const layoutParent = currentLayout.parent; |
||||||
|
if (layoutParent && isLayoutParent(layoutParent)) { |
||||||
|
layoutParent.switchLayout(newLayoutDescriptor.createFromLayout(currentLayout)); |
||||||
|
} |
||||||
|
} |
@ -1,105 +0,0 @@ |
|||||||
import { css } from '@emotion/css'; |
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data'; |
|
||||||
import { useStyles2, Field, Select } from '@grafana/ui'; |
|
||||||
|
|
||||||
import { getDashboardSceneFor } from '../../utils/utils'; |
|
||||||
import { DashboardLayoutManager, isLayoutParent, LayoutRegistryItem } from '../types'; |
|
||||||
|
|
||||||
import { layoutRegistry } from './layoutRegistry'; |
|
||||||
|
|
||||||
interface Props { |
|
||||||
layoutManager: DashboardLayoutManager; |
|
||||||
children: React.ReactNode; |
|
||||||
} |
|
||||||
|
|
||||||
export function LayoutEditChrome({ layoutManager, children }: Props) { |
|
||||||
const styles = useStyles2(getStyles); |
|
||||||
const { isEditing } = getDashboardSceneFor(layoutManager).useState(); |
|
||||||
|
|
||||||
const layouts = layoutRegistry.list(); |
|
||||||
const options = layouts.map((layout) => ({ |
|
||||||
label: layout.name, |
|
||||||
value: layout, |
|
||||||
})); |
|
||||||
|
|
||||||
const currentLayoutId = layoutManager.getDescriptor().id; |
|
||||||
const currentLayoutOption = options.find((option) => option.value.id === currentLayoutId); |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className={styles.wrapper}> |
|
||||||
{isEditing && ( |
|
||||||
<div className={styles.editHeader}> |
|
||||||
<Field label="Layout type"> |
|
||||||
<Select |
|
||||||
options={options} |
|
||||||
value={currentLayoutOption} |
|
||||||
onChange={(option) => changeLayoutTo(layoutManager, option.value!)} |
|
||||||
/> |
|
||||||
</Field> |
|
||||||
{layoutManager.renderEditor?.()} |
|
||||||
</div> |
|
||||||
)} |
|
||||||
{children} |
|
||||||
</div> |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
function getStyles(theme: GrafanaTheme2) { |
|
||||||
return { |
|
||||||
editHeader: css({ |
|
||||||
width: '100%', |
|
||||||
display: 'flex', |
|
||||||
gap: theme.spacing(1), |
|
||||||
padding: theme.spacing(0, 1, 0.5, 1), |
|
||||||
margin: theme.spacing(0, 0, 1, 0), |
|
||||||
alignItems: 'flex-end', |
|
||||||
borderBottom: `1px solid ${theme.colors.border.weak}`, |
|
||||||
paddingBottom: theme.spacing(1), |
|
||||||
|
|
||||||
'&:hover, &:focus-within': { |
|
||||||
'& > div': { |
|
||||||
opacity: 1, |
|
||||||
}, |
|
||||||
}, |
|
||||||
|
|
||||||
'& > div': { |
|
||||||
marginBottom: 0, |
|
||||||
marginRight: theme.spacing(1), |
|
||||||
}, |
|
||||||
}), |
|
||||||
wrapper: css({ |
|
||||||
display: 'flex', |
|
||||||
flexDirection: 'column', |
|
||||||
flex: '1 1 0', |
|
||||||
width: '100%', |
|
||||||
}), |
|
||||||
icon: css({ |
|
||||||
display: 'flex', |
|
||||||
alignItems: 'center', |
|
||||||
cursor: 'pointer', |
|
||||||
background: 'transparent', |
|
||||||
border: 'none', |
|
||||||
gap: theme.spacing(1), |
|
||||||
}), |
|
||||||
rowTitle: css({}), |
|
||||||
rowActions: css({ |
|
||||||
display: 'flex', |
|
||||||
opacity: 0, |
|
||||||
[theme.transitions.handleMotion('no-preference', 'reduce')]: { |
|
||||||
transition: 'opacity 200ms ease-in', |
|
||||||
}, |
|
||||||
|
|
||||||
'&:hover, &:focus-within': { |
|
||||||
opacity: 1, |
|
||||||
}, |
|
||||||
}), |
|
||||||
}; |
|
||||||
} |
|
||||||
|
|
||||||
function changeLayoutTo(currentLayout: DashboardLayoutManager, newLayoutDescriptor: LayoutRegistryItem) { |
|
||||||
const layoutParent = currentLayout.parent; |
|
||||||
if (layoutParent && isLayoutParent(layoutParent)) { |
|
||||||
layoutParent.switchLayout(newLayoutDescriptor.createFromLayout(currentLayout)); |
|
||||||
} |
|
||||||
} |
|
@ -1,21 +0,0 @@ |
|||||||
import { SelectableValue } from '@grafana/data'; |
|
||||||
|
|
||||||
import { AzureCredentialsType, AzureAuthType } from '../types'; |
|
||||||
|
|
||||||
export enum AzureCloud { |
|
||||||
Public = 'AzureCloud', |
|
||||||
None = '', |
|
||||||
} |
|
||||||
|
|
||||||
export const KnownAzureClouds: Array<SelectableValue<AzureCloud>> = [{ value: AzureCloud.Public, label: 'Azure' }]; |
|
||||||
|
|
||||||
export function isCredentialsComplete(credentials: AzureCredentialsType): boolean { |
|
||||||
switch (credentials.authType) { |
|
||||||
case AzureAuthType.MSI: |
|
||||||
return true; |
|
||||||
case AzureAuthType.CLIENT_SECRET: |
|
||||||
return !!(credentials.azureCloud && credentials.tenantId && credentials.clientId && credentials.clientSecret); |
|
||||||
case AzureAuthType.AD_PASSWORD: |
|
||||||
return !!(credentials.clientId && credentials.password && credentials.userId); |
|
||||||
} |
|
||||||
} |
|
@ -1,167 +1,26 @@ |
|||||||
import { DataSourceSettings } from '@grafana/data'; |
|
||||||
import { GrafanaBootConfig } from '@grafana/runtime'; |
|
||||||
|
|
||||||
import { |
import { |
||||||
AzureCloud, |
AzureCredentials, |
||||||
AzureCredentialsType, |
AzureDataSourceSettings, |
||||||
ConcealedSecretType, |
getDatasourceCredentials, |
||||||
AzureAuthSecureJSONDataType, |
getDefaultAzureCloud, |
||||||
AzureAuthJSONDataType, |
} from '@grafana/azure-sdk'; |
||||||
AzureAuthType, |
import { config } from '@grafana/runtime'; |
||||||
} from '../types'; |
|
||||||
|
export const getDefaultCredentials = (): AzureCredentials => { |
||||||
export const getDefaultCredentials = (managedIdentityEnabled: boolean, cloud: string): AzureCredentialsType => { |
if (config.azure.managedIdentityEnabled) { |
||||||
if (managedIdentityEnabled) { |
return { authType: 'msi' }; |
||||||
return { authType: AzureAuthType.MSI }; |
|
||||||
} else { |
} else { |
||||||
return { authType: AzureAuthType.CLIENT_SECRET, azureCloud: cloud }; |
return { authType: 'clientsecret', azureCloud: getDefaultAzureCloud() }; |
||||||
} |
} |
||||||
}; |
}; |
||||||
|
|
||||||
export const getSecret = ( |
export const getCredentials = (dsSettings: AzureDataSourceSettings): AzureCredentials => { |
||||||
storedServerSide: boolean, |
const credentials = getDatasourceCredentials(dsSettings); |
||||||
secret: string | symbol | undefined |
if (credentials) { |
||||||
): undefined | string | ConcealedSecretType => { |
return credentials; |
||||||
const concealedSecret: ConcealedSecretType = Symbol('Concealed client secret'); |
|
||||||
if (storedServerSide) { |
|
||||||
// The secret is concealed server side, so return the symbol
|
|
||||||
return concealedSecret; |
|
||||||
} else { |
|
||||||
return typeof secret === 'string' && secret.length > 0 ? secret : undefined; |
|
||||||
} |
} |
||||||
}; |
|
||||||
|
|
||||||
export const getCredentials = ( |
|
||||||
dsSettings: DataSourceSettings<AzureAuthJSONDataType, AzureAuthSecureJSONDataType>, |
|
||||||
bootConfig: GrafanaBootConfig |
|
||||||
): AzureCredentialsType => { |
|
||||||
// JSON data
|
|
||||||
const credentials = dsSettings.jsonData?.azureCredentials; |
|
||||||
|
|
||||||
// Secure JSON data/fields
|
|
||||||
const clientSecretStoredServerSide = dsSettings.secureJsonFields?.azureClientSecret; |
|
||||||
const clientSecret = dsSettings.secureJsonData?.azureClientSecret; |
|
||||||
const passwordStoredServerSide = dsSettings.secureJsonFields?.password; |
|
||||||
const password = dsSettings.secureJsonData?.password; |
|
||||||
|
|
||||||
// BootConfig data
|
|
||||||
const managedIdentityEnabled = !!bootConfig.azure?.managedIdentityEnabled; |
|
||||||
const cloud = bootConfig.azure?.cloud || AzureCloud.Public; |
|
||||||
|
|
||||||
// If no credentials saved, then return empty credentials
|
// If no credentials saved, then return empty credentials
|
||||||
// of type based on whether the managed identity enabled
|
// of type based on whether the managed identity enabled
|
||||||
if (!credentials) { |
return getDefaultCredentials(); |
||||||
return getDefaultCredentials(managedIdentityEnabled, cloud); |
|
||||||
} |
|
||||||
|
|
||||||
switch (credentials.authType) { |
|
||||||
case AzureAuthType.MSI: |
|
||||||
if (managedIdentityEnabled) { |
|
||||||
return { |
|
||||||
authType: AzureAuthType.MSI, |
|
||||||
}; |
|
||||||
} else { |
|
||||||
// If authentication type is managed identity but managed identities were disabled in Grafana config,
|
|
||||||
// then we should fallback to an empty app registration (client secret) configuration
|
|
||||||
return { |
|
||||||
authType: AzureAuthType.CLIENT_SECRET, |
|
||||||
azureCloud: cloud, |
|
||||||
}; |
|
||||||
} |
|
||||||
case AzureAuthType.CLIENT_SECRET: |
|
||||||
return { |
|
||||||
authType: AzureAuthType.CLIENT_SECRET, |
|
||||||
azureCloud: credentials.azureCloud || cloud, |
|
||||||
tenantId: credentials.tenantId, |
|
||||||
clientId: credentials.clientId, |
|
||||||
clientSecret: getSecret(clientSecretStoredServerSide, clientSecret), |
|
||||||
}; |
|
||||||
case AzureAuthType.AD_PASSWORD: |
|
||||||
return { |
|
||||||
authType: AzureAuthType.AD_PASSWORD, |
|
||||||
userId: credentials.userId, |
|
||||||
clientId: credentials.clientId, |
|
||||||
password: getSecret(passwordStoredServerSide, password), |
|
||||||
}; |
|
||||||
} |
|
||||||
}; |
|
||||||
|
|
||||||
export const updateCredentials = ( |
|
||||||
dsSettings: DataSourceSettings<AzureAuthJSONDataType>, |
|
||||||
bootConfig: GrafanaBootConfig, |
|
||||||
credentials: AzureCredentialsType |
|
||||||
): DataSourceSettings<AzureAuthJSONDataType> => { |
|
||||||
// BootConfig data
|
|
||||||
const managedIdentityEnabled = !!bootConfig.azure?.managedIdentityEnabled; |
|
||||||
const cloud = bootConfig.azure?.cloud || AzureCloud.Public; |
|
||||||
|
|
||||||
switch (credentials.authType) { |
|
||||||
case AzureAuthType.MSI: |
|
||||||
if (!managedIdentityEnabled) { |
|
||||||
throw new Error('Managed Identity authentication is not enabled in Grafana config.'); |
|
||||||
} |
|
||||||
|
|
||||||
dsSettings = { |
|
||||||
...dsSettings, |
|
||||||
jsonData: { |
|
||||||
...dsSettings.jsonData, |
|
||||||
azureCredentials: { |
|
||||||
authType: AzureAuthType.MSI, |
|
||||||
}, |
|
||||||
}, |
|
||||||
}; |
|
||||||
|
|
||||||
return dsSettings; |
|
||||||
|
|
||||||
case AzureAuthType.CLIENT_SECRET: |
|
||||||
dsSettings = { |
|
||||||
...dsSettings, |
|
||||||
jsonData: { |
|
||||||
...dsSettings.jsonData, |
|
||||||
azureCredentials: { |
|
||||||
authType: AzureAuthType.CLIENT_SECRET, |
|
||||||
azureCloud: credentials.azureCloud || cloud, |
|
||||||
tenantId: credentials.tenantId, |
|
||||||
clientId: credentials.clientId, |
|
||||||
}, |
|
||||||
}, |
|
||||||
secureJsonData: { |
|
||||||
...dsSettings.secureJsonData, |
|
||||||
azureClientSecret: |
|
||||||
typeof credentials.clientSecret === 'string' && credentials.clientSecret.length > 0 |
|
||||||
? credentials.clientSecret |
|
||||||
: undefined, |
|
||||||
}, |
|
||||||
secureJsonFields: { |
|
||||||
...dsSettings.secureJsonFields, |
|
||||||
azureClientSecret: typeof credentials.clientSecret === 'symbol', |
|
||||||
}, |
|
||||||
}; |
|
||||||
|
|
||||||
return dsSettings; |
|
||||||
|
|
||||||
case AzureAuthType.AD_PASSWORD: |
|
||||||
return { |
|
||||||
...dsSettings, |
|
||||||
jsonData: { |
|
||||||
...dsSettings.jsonData, |
|
||||||
azureCredentials: { |
|
||||||
authType: AzureAuthType.AD_PASSWORD, |
|
||||||
userId: credentials.userId, |
|
||||||
clientId: credentials.clientId, |
|
||||||
}, |
|
||||||
}, |
|
||||||
secureJsonData: { |
|
||||||
...dsSettings.secureJsonData, |
|
||||||
password: |
|
||||||
typeof credentials.password === 'string' && credentials.password.length > 0 |
|
||||||
? credentials.password |
|
||||||
: undefined, |
|
||||||
}, |
|
||||||
secureJsonFields: { |
|
||||||
...dsSettings.secureJsonFields, |
|
||||||
password: typeof credentials.password === 'symbol', |
|
||||||
}, |
|
||||||
}; |
|
||||||
} |
|
||||||
}; |
}; |
||||||
|
Loading…
Reference in new issue