rozetko
2 years ago
24 changed files with 1510 additions and 348 deletions
@ -1,51 +0,0 @@
|
||||
import React from 'react'; |
||||
import { render, screen } from '@testing-library/react'; |
||||
import { PluginType } from '@grafana/data'; |
||||
import { AppConfig, AppConfigProps } from './AppConfig'; |
||||
|
||||
describe('Components/AppConfig', () => { |
||||
let props: AppConfigProps; |
||||
|
||||
beforeEach(() => { |
||||
jest.resetAllMocks(); |
||||
|
||||
props = { |
||||
plugin: { |
||||
meta: { |
||||
id: 'sample-app', |
||||
name: 'Sample App', |
||||
type: PluginType.app, |
||||
enabled: true, |
||||
jsonData: {}, |
||||
}, |
||||
}, |
||||
query: {}, |
||||
} as unknown as AppConfigProps; |
||||
}); |
||||
|
||||
test('renders without an error"', () => { |
||||
render(<AppConfig plugin={props.plugin} query={props.query} />); |
||||
|
||||
expect(screen.queryByText(/Enable \/ Disable/i)).toBeInTheDocument(); |
||||
}); |
||||
|
||||
test('renders an "Enable" button if the plugin is disabled', () => { |
||||
const plugin = { meta: { ...props.plugin.meta, enabled: false } }; |
||||
|
||||
// @ts-ignore - We don't need to provide `addConfigPage()` and `setChannelSupport()` for these tests
|
||||
render(<AppConfig plugin={plugin} query={props.query} />); |
||||
|
||||
expect(screen.queryByText(/The plugin is currently not enabled./i)).toBeInTheDocument(); |
||||
expect(screen.queryByText(/The plugin is currently enabled./i)).not.toBeInTheDocument(); |
||||
}); |
||||
|
||||
test('renders a "Disable" button if the plugin is enabled', () => { |
||||
const plugin = { meta: { ...props.plugin.meta, enabled: true } }; |
||||
|
||||
// @ts-ignore - We don't need to provide `addConfigPage()` and `setChannelSupport()` for these tests
|
||||
render(<AppConfig plugin={plugin} query={props.query} />); |
||||
|
||||
expect(screen.queryByText(/The plugin is currently enabled./i)).toBeInTheDocument(); |
||||
expect(screen.queryByText(/The plugin is currently not enabled./i)).not.toBeInTheDocument(); |
||||
}); |
||||
}); |
@ -1,92 +0,0 @@
|
||||
import React from 'react'; |
||||
import { Button, Legend, useStyles2 } from '@grafana/ui'; |
||||
import { PluginConfigPageProps, AppPluginMeta, PluginMeta, GrafanaTheme2 } from '@grafana/data'; |
||||
import { getBackendSrv } from '@grafana/runtime'; |
||||
import { css } from '@emotion/css'; |
||||
import { lastValueFrom } from 'rxjs'; |
||||
|
||||
export type AppPluginSettings = {}; |
||||
|
||||
export interface AppConfigProps extends PluginConfigPageProps<AppPluginMeta<AppPluginSettings>> {} |
||||
|
||||
export const AppConfig = ({ plugin }: AppConfigProps) => { |
||||
const s = useStyles2(getStyles); |
||||
const { enabled, jsonData } = plugin.meta; |
||||
|
||||
return ( |
||||
<div className="gf-form-group"> |
||||
<div> |
||||
{/* Enable the plugin */} |
||||
<Legend>Enable / Disable</Legend> |
||||
{!enabled && ( |
||||
<> |
||||
<div className={s.colorWeak}>The plugin is currently not enabled.</div> |
||||
<Button |
||||
className={s.marginTop} |
||||
variant="primary" |
||||
onClick={() => |
||||
updatePluginAndReload(plugin.meta.id, { |
||||
enabled: true, |
||||
pinned: true, |
||||
jsonData, |
||||
}) |
||||
} |
||||
> |
||||
Enable plugin |
||||
</Button> |
||||
</> |
||||
)} |
||||
|
||||
{/* Disable the plugin */} |
||||
{enabled && ( |
||||
<> |
||||
<div className={s.colorWeak}>The plugin is currently enabled.</div> |
||||
<Button |
||||
className={s.marginTop} |
||||
variant="destructive" |
||||
onClick={() => |
||||
updatePluginAndReload(plugin.meta.id, { |
||||
enabled: false, |
||||
pinned: false, |
||||
jsonData, |
||||
}) |
||||
} |
||||
> |
||||
Disable plugin |
||||
</Button> |
||||
</> |
||||
)} |
||||
</div> |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
colorWeak: css` |
||||
color: ${theme.colors.text.secondary}; |
||||
`,
|
||||
marginTop: css` |
||||
margin-top: ${theme.spacing(3)}; |
||||
`,
|
||||
}); |
||||
|
||||
const updatePluginAndReload = async (pluginId: string, data: Partial<PluginMeta>) => { |
||||
try { |
||||
await updatePlugin(pluginId, data); |
||||
|
||||
// Reloading the page as the changes made here wouldn't be propagated to the actual plugin otherwise.
|
||||
// This is not ideal, however unfortunately currently there is no supported way for updating the plugin state.
|
||||
window.location.reload(); |
||||
} catch (e) { |
||||
console.error('Error while updating the plugin', e); |
||||
} |
||||
}; |
||||
|
||||
export const updatePlugin = async (pluginId: string, data: Partial<PluginMeta>) => { |
||||
const response = getBackendSrv().fetch({ |
||||
url: `/api/plugins/${pluginId}/settings`, |
||||
method: 'POST', |
||||
data, |
||||
}); |
||||
return lastValueFrom(response); |
||||
}; |
@ -0,0 +1,28 @@
|
||||
.root { |
||||
padding: 16px; |
||||
border-radius: 2px; |
||||
|
||||
&--withBackGround { |
||||
background: var(--secondary-background); |
||||
} |
||||
|
||||
&--fullWidth { |
||||
width: 100%; |
||||
} |
||||
|
||||
&--hover:hover { |
||||
background: var(--hover-selected); |
||||
} |
||||
} |
||||
|
||||
:global(.theme-dark) .root_bordered { |
||||
border: var(--border-weak); |
||||
} |
||||
|
||||
:global(.theme-light) .root_bordered { |
||||
border: var(--border-weak); |
||||
} |
||||
|
||||
:global(.theme-dark) .root_shadowed { |
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.6); |
||||
} |
@ -0,0 +1,44 @@
|
||||
import { css } from '@emotion/css'; |
||||
import React, { FC, HTMLAttributes } from 'react'; |
||||
|
||||
// import './Block.module.scss';
|
||||
|
||||
interface BlockProps extends HTMLAttributes<HTMLElement> { |
||||
bordered?: boolean; |
||||
shadowed?: boolean; |
||||
withBackground?: boolean; |
||||
hover?: boolean; |
||||
fullWidth?: boolean; |
||||
} |
||||
|
||||
const Block: FC<BlockProps> = (props) => { |
||||
const { |
||||
children, |
||||
style, |
||||
className, |
||||
bordered = false, |
||||
fullWidth = false, |
||||
hover = false, |
||||
shadowed = false, |
||||
withBackground = false, |
||||
...rest |
||||
} = props; |
||||
|
||||
return ( |
||||
<div |
||||
className={css('root', className, { |
||||
root_bordered: bordered, |
||||
root_shadowed: shadowed, |
||||
'root--fullWidth': fullWidth, |
||||
'root--withBackground': withBackground, |
||||
'root--hover': hover, |
||||
})} |
||||
style={style} |
||||
{...rest} |
||||
> |
||||
{children} |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
export default Block; |
@ -0,0 +1,198 @@
|
||||
import React, { FC, useCallback, useEffect, useState } from 'react'; |
||||
|
||||
import { Legend, LoadingPlaceholder } from '@grafana/ui'; |
||||
import { useLocation } from 'react-router-dom'; |
||||
|
||||
// import logo from '../../img/logo.svg';
|
||||
import PluginState from '../../plugin_state'; |
||||
|
||||
import ConfigurationForm from './parts/ConfigurationForm'; |
||||
import RemoveCurrentConfigurationButton from './parts/RemoveCurrentConfigurationButton'; |
||||
import StatusMessageBlock from './parts/StatusMessageBlock'; |
||||
import { DataExporterPluginConfigPageProps } from 'types'; |
||||
|
||||
const PLUGIN_CONFIGURED_QUERY_PARAM = 'pluginConfigured'; |
||||
const PLUGIN_CONFIGURED_QUERY_PARAM_TRUTHY_VALUE = 'true'; |
||||
|
||||
const DEFAULT_API_URL = 'http://localhost:8080'; |
||||
|
||||
/** |
||||
* When everything is successfully configured, reload the page, and pass along a few query parameters |
||||
* so that we avoid an infinite configuration-check/data-sync loop |
||||
* |
||||
* Don't refresh the page if the plugin is already enabled.. |
||||
*/ |
||||
export const reloadPageWithPluginConfiguredQueryParams = (pluginEnabled: boolean): void => { |
||||
if (!pluginEnabled) { |
||||
window.location.href = `${window.location.href}?${PLUGIN_CONFIGURED_QUERY_PARAM}=${PLUGIN_CONFIGURED_QUERY_PARAM_TRUTHY_VALUE}`; |
||||
} |
||||
}; |
||||
|
||||
/** |
||||
* remove the query params used to track state for a page reload after successful configuration, without triggering |
||||
* a page reload |
||||
* https://stackoverflow.com/a/19279428
|
||||
*/ |
||||
export const removePluginConfiguredQueryParams = (pluginIsEnabled?: boolean): void => { |
||||
if (window.history.pushState && pluginIsEnabled) { |
||||
const newurl = `${window.location.protocol}//${window.location.host}${window.location.pathname}`; |
||||
window.history.pushState({ path: newurl }, '', newurl); |
||||
} |
||||
}; |
||||
|
||||
export const PluginConfigPage: FC<DataExporterPluginConfigPageProps> = ({ |
||||
plugin: { |
||||
meta: { jsonData, enabled: pluginIsEnabled }, |
||||
}, |
||||
}) => { |
||||
const { search } = useLocation(); |
||||
const queryParams = new URLSearchParams(search); |
||||
const pluginConfiguredQueryParam = queryParams.get(PLUGIN_CONFIGURED_QUERY_PARAM); |
||||
|
||||
const pluginConfiguredRedirect = pluginConfiguredQueryParam === PLUGIN_CONFIGURED_QUERY_PARAM_TRUTHY_VALUE; |
||||
|
||||
const [checkingIfPluginIsConnected, setCheckingIfPluginIsConnected] = useState<boolean>(!pluginConfiguredRedirect); |
||||
const [pluginConnectionCheckError, setPluginConnectionCheckError] = useState<string | null>(null); |
||||
|
||||
const [pluginIsConnected, setPluginIsConnected] = useState<Boolean | null>(pluginConfiguredRedirect); |
||||
|
||||
const [resettingPlugin, setResettingPlugin] = useState<boolean>(false); |
||||
const [pluginResetError, setPluginResetError] = useState<string | null>(null); |
||||
|
||||
const pluginMetaDataExporterApiUrl = jsonData?.dataExporterApiUrl; |
||||
const dataExporterApiUrl = pluginMetaDataExporterApiUrl || DEFAULT_API_URL; |
||||
|
||||
const resetQueryParams = useCallback(() => removePluginConfiguredQueryParams(pluginIsEnabled), [pluginIsEnabled]); |
||||
|
||||
useEffect(resetQueryParams, [resetQueryParams]); |
||||
|
||||
useEffect(() => { |
||||
const configurePluginAndSyncData = async () => { |
||||
/** |
||||
* If the plugin has never been configured, DataExporterApiUrl will be undefined in the plugin's jsonData |
||||
* In that case, check to see if DataExporter_API_URL has been supplied as an env var. |
||||
* Supplying the env var basically allows to skip the configuration form |
||||
* (check webpack.config.js to see how this is set) |
||||
*/ |
||||
if (!pluginMetaDataExporterApiUrl) { |
||||
/** |
||||
* DataExporterApiUrl is not yet saved in the grafana plugin settings, but has been supplied as an env var |
||||
* lets auto-trigger a self-hosted plugin install w/ the DataExporterApiUrl passed in as an env var |
||||
*/ |
||||
const errorMsg = await PluginState.installPlugin(dataExporterApiUrl); |
||||
if (errorMsg) { |
||||
setPluginConnectionCheckError(errorMsg); |
||||
setCheckingIfPluginIsConnected(false); |
||||
return; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* If the DataExporterApiUrl is not set in the plugin settings, and not supplied via an env var |
||||
* there's no reason to check if the plugin is connected, we know it can't be |
||||
*/ |
||||
if (dataExporterApiUrl) { |
||||
const pluginConnectionResponse = await PluginState.checkIfPluginIsConnected(dataExporterApiUrl); |
||||
|
||||
if (typeof pluginConnectionResponse === 'string') { |
||||
setPluginConnectionCheckError(pluginConnectionResponse); |
||||
} |
||||
} |
||||
setCheckingIfPluginIsConnected(false); |
||||
}; |
||||
|
||||
/** |
||||
* don't check the plugin status (or trigger a data sync) if the user was just redirected after a successful |
||||
* plugin setup |
||||
*/ |
||||
if (!pluginConfiguredRedirect) { |
||||
configurePluginAndSyncData(); |
||||
} |
||||
}, [pluginMetaDataExporterApiUrl, dataExporterApiUrl, pluginConfiguredRedirect]); |
||||
|
||||
const resetState = useCallback(() => { |
||||
setPluginResetError(null); |
||||
setPluginConnectionCheckError(null); |
||||
setPluginIsConnected(null); |
||||
resetQueryParams(); |
||||
}, [resetQueryParams]); |
||||
|
||||
/** |
||||
* NOTE: there is a possible edge case when resetting the plugin, that would lead to an error message being shown |
||||
* (which could be fixed by just reloading the page) |
||||
* This would happen if the user removes the plugin configuration, leaves the page, then comes back to the plugin |
||||
* configuration. |
||||
* |
||||
* This is because the props being passed into this component wouldn't reflect the actual plugin |
||||
* provisioning state. The props would still have DataExporterApiUrl set in the plugin jsonData, so when we make the API |
||||
* call to check the plugin state w/ DataExporter API the plugin-proxy would return a 502 Bad Gateway because the actual |
||||
* provisioned plugin doesn't know about the DataExporterApiUrl. |
||||
* |
||||
* This could be fixed by instead of passing in the plugin provisioning information as props always fetching it |
||||
* when this component renders (via a useEffect). We probably don't need to worry about this because it should happen |
||||
* very rarely, if ever |
||||
*/ |
||||
const triggerPluginReset = useCallback(async () => { |
||||
setResettingPlugin(true); |
||||
resetState(); |
||||
|
||||
try { |
||||
await PluginState.resetPlugin(); |
||||
} catch (e) { |
||||
// this should rarely, if ever happen, but we should handle the case nevertheless
|
||||
setPluginResetError('There was an error resetting your plugin, try again.'); |
||||
} |
||||
|
||||
setResettingPlugin(false); |
||||
}, [resetState]); |
||||
|
||||
const onSuccessfulSetup = useCallback(async () => { |
||||
reloadPageWithPluginConfiguredQueryParams(false); |
||||
}, []); |
||||
|
||||
const RemoveConfigButton = useCallback( |
||||
() => <RemoveCurrentConfigurationButton disabled={resettingPlugin} onClick={triggerPluginReset} />, |
||||
[resettingPlugin, triggerPluginReset] |
||||
); |
||||
|
||||
let content: React.ReactNode; |
||||
|
||||
if (checkingIfPluginIsConnected) { |
||||
content = <LoadingPlaceholder text="Validating your plugin connection..." />; |
||||
} else if (pluginConnectionCheckError || pluginResetError) { |
||||
content = ( |
||||
<> |
||||
<StatusMessageBlock text={(pluginConnectionCheckError || pluginResetError) as string} /> |
||||
<RemoveConfigButton /> |
||||
</> |
||||
); |
||||
} else if (!pluginIsConnected) { |
||||
content = ( |
||||
<ConfigurationForm |
||||
onSuccessfulSetup={onSuccessfulSetup} |
||||
defaultDataExporterApiUrl={DEFAULT_API_URL} |
||||
/> |
||||
); |
||||
} else { |
||||
// plugin is fully connected and synced
|
||||
content = <RemoveConfigButton />; |
||||
} |
||||
|
||||
return ( |
||||
<> |
||||
<Legend>Configure DataExporter</Legend> |
||||
{pluginIsConnected ? ( |
||||
<> |
||||
<p> |
||||
Plugin is connected! Continue to DataExporter by clicking the{' '} |
||||
{/* <img alt="DataExporter Logo" src={logo} width={18} /> icon over there 👈 */} |
||||
</p> |
||||
<StatusMessageBlock text={`Connected to DataExporter`} /> |
||||
</> |
||||
) : ( |
||||
<p>This page will help you configure the DataExporter plugin 👋</p> |
||||
)} |
||||
{content} |
||||
</> |
||||
); |
||||
}; |
@ -0,0 +1 @@
|
||||
export * from './PluginConfigPage'; |
@ -0,0 +1,4 @@
|
||||
.info-block { |
||||
margin-bottom: 24px; |
||||
margin-top: 24px; |
||||
} |
@ -0,0 +1,147 @@
|
||||
import React, { FC, useCallback, useState } from 'react'; |
||||
|
||||
import { Button, Field, Form, Input } from '@grafana/ui'; |
||||
// import cn from 'classnames/bind';
|
||||
import { isEmpty } from 'lodash-es'; |
||||
import { SubmitHandler } from 'react-hook-form'; |
||||
|
||||
import Block from '../../../GBlock/Block'; |
||||
import Text from '../../../Text/Text'; |
||||
import PluginState from '../../../../plugin_state'; |
||||
import { css } from '@emotion/css'; |
||||
|
||||
// import styles from './ConfigurationForm.module.css';
|
||||
|
||||
// const cx = cn.bind(styles);
|
||||
|
||||
type Props = { |
||||
onSuccessfulSetup: () => void; |
||||
defaultDataExporterApiUrl: string; |
||||
}; |
||||
|
||||
type FormProps = { |
||||
dataExporterApiUrl: string; |
||||
}; |
||||
|
||||
/** |
||||
* https://stackoverflow.com/a/43467144
|
||||
*/ |
||||
const isValidUrl = (url: string): boolean => { |
||||
try { |
||||
new URL(url); |
||||
return true; |
||||
} catch (_) { |
||||
return false; |
||||
} |
||||
}; |
||||
|
||||
const FormErrorMessage: FC<{ errorMsg: string }> = ({ errorMsg }) => ( |
||||
<> |
||||
<pre> |
||||
<Text type="link">{errorMsg}</Text> |
||||
</pre> |
||||
<Block |
||||
withBackground |
||||
className={css({ |
||||
'margin-bottom': '24px', |
||||
'margin-top': '24px', |
||||
})} |
||||
> |
||||
<Text type="secondary"> |
||||
Need help? |
||||
<br />- Reach out to the OnCall team in the{' '} |
||||
<a href="https://grafana.slack.com/archives/C02LSUUSE2G" target="_blank" rel="noreferrer"> |
||||
<Text type="link">#grafana-oncall</Text> |
||||
</a>{' '} |
||||
community Slack channel |
||||
<br />- Ask questions on our GitHub Discussions page{' '} |
||||
<a href="https://github.com/grafana/oncall/discussions/categories/q-a" target="_blank" rel="noreferrer"> |
||||
<Text type="link">here</Text> |
||||
</a>{' '} |
||||
<br />- Or file bugs on our GitHub Issues page{' '} |
||||
<a href="https://github.com/grafana/oncall/issues" target="_blank" rel="noreferrer"> |
||||
<Text type="link">here</Text> |
||||
</a> |
||||
</Text> |
||||
</Block> |
||||
</> |
||||
); |
||||
|
||||
const ConfigurationForm: FC<Props> = ({ onSuccessfulSetup, defaultDataExporterApiUrl }) => { |
||||
const [setupErrorMsg, setSetupErrorMsg] = useState<string | null>(null); |
||||
const [formLoading, setFormLoading] = useState<boolean>(false); |
||||
|
||||
const setupPlugin: SubmitHandler<FormProps> = useCallback(async ({ dataExporterApiUrl }) => { |
||||
setFormLoading(true); |
||||
|
||||
const errorMsg = await PluginState.installPlugin(dataExporterApiUrl); |
||||
|
||||
if (!errorMsg) { |
||||
onSuccessfulSetup(); |
||||
} else { |
||||
setSetupErrorMsg(errorMsg); |
||||
setFormLoading(false); |
||||
} |
||||
}, []); |
||||
|
||||
return ( |
||||
<Form<FormProps> |
||||
defaultValues={{ dataExporterApiUrl: defaultDataExporterApiUrl }} |
||||
onSubmit={setupPlugin} |
||||
data-testid="plugin-configuration-form" |
||||
> |
||||
{({ register, errors }) => ( |
||||
<> |
||||
<div |
||||
className={css({ |
||||
'margin-bottom': '24px', |
||||
'margin-top': '24px', |
||||
})} |
||||
> |
||||
<p>1. Launch the OnCall backend</p> |
||||
<Text type="secondary"> |
||||
Run hobby, dev or production backend. See{' '} |
||||
<a href="https://github.com/grafana/oncall#getting-started" target="_blank" rel="noreferrer"> |
||||
<Text type="link">here</Text> |
||||
</a>{' '} |
||||
on how to get started. |
||||
</Text> |
||||
</div> |
||||
|
||||
<div |
||||
className={css({ |
||||
'margin-bottom': '24px', |
||||
'margin-top': '24px', |
||||
})} |
||||
> |
||||
<p>2. Let us know the base URL of your OnCall API</p> |
||||
<Text type="secondary"> |
||||
The OnCall backend must be reachable from your Grafana installation. Some examples are: |
||||
<br /> |
||||
- http://host.docker.internal:8080
|
||||
<br />- http://localhost:8080
|
||||
</Text> |
||||
</div> |
||||
|
||||
<Field label="OnCall backend URL" invalid={!!errors.dataExporterApiUrl} error="Must be a valid URL"> |
||||
<Input |
||||
data-testid="dataExporterApiUrl" |
||||
{...register('dataExporterApiUrl', { |
||||
required: true, |
||||
validate: isValidUrl, |
||||
})} |
||||
/> |
||||
</Field> |
||||
|
||||
{setupErrorMsg && <FormErrorMessage errorMsg={setupErrorMsg} />} |
||||
|
||||
<Button type="submit" size="md" disabled={formLoading || !isEmpty(errors)}> |
||||
Connect |
||||
</Button> |
||||
</> |
||||
)} |
||||
</Form> |
||||
); |
||||
}; |
||||
|
||||
export default ConfigurationForm; |
@ -0,0 +1,32 @@
|
||||
import React from 'react'; |
||||
|
||||
import { render, screen } from '@testing-library/react'; |
||||
import userEvent from '@testing-library/user-event'; |
||||
|
||||
import RemoveCurrentConfigurationButton from '.'; |
||||
|
||||
describe('RemoveCurrentConfigurationButton', () => { |
||||
test('It renders properly when enabled', () => { |
||||
const component = render(<RemoveCurrentConfigurationButton onClick={() => {}} disabled={false} />); |
||||
expect(component.baseElement).toMatchSnapshot(); |
||||
}); |
||||
|
||||
test('It renders properly when disabled', () => { |
||||
const component = render(<RemoveCurrentConfigurationButton onClick={() => {}} disabled />); |
||||
expect(component.baseElement).toMatchSnapshot(); |
||||
}); |
||||
|
||||
test('It calls the onClick handler when clicked', async () => { |
||||
const mockedOnClick = jest.fn(); |
||||
|
||||
render(<RemoveCurrentConfigurationButton onClick={mockedOnClick} disabled={false} />); |
||||
|
||||
// click the button, which opens the modal
|
||||
await userEvent.click(screen.getByRole('button')); |
||||
// click the confirm button within the modal, which actually triggers the callback
|
||||
await userEvent.click(screen.getByText('Remove')); |
||||
|
||||
expect(mockedOnClick).toHaveBeenCalledWith(); |
||||
expect(mockedOnClick).toHaveBeenCalledTimes(1); |
||||
}); |
||||
}); |
@ -0,0 +1,20 @@
|
||||
import React, { FC } from 'react'; |
||||
|
||||
import { Button } from '@grafana/ui'; |
||||
|
||||
import WithConfirm from '../../../WithConfirm/WithConfirm'; |
||||
|
||||
type Props = { |
||||
disabled: boolean; |
||||
onClick: () => void; |
||||
}; |
||||
|
||||
const RemoveCurrentConfigurationButton: FC<Props> = ({ disabled, onClick }) => ( |
||||
<WithConfirm title="Are you sure to delete the plugin configuration?" confirmText="Remove"> |
||||
<Button variant="destructive" onClick={onClick} size="md" disabled={disabled}> |
||||
Remove current configuration |
||||
</Button> |
||||
</WithConfirm> |
||||
); |
||||
|
||||
export default RemoveCurrentConfigurationButton; |
@ -0,0 +1,12 @@
|
||||
import React from 'react'; |
||||
|
||||
import { render } from '@testing-library/react'; |
||||
|
||||
import StatusMessageBlock from '.'; |
||||
|
||||
describe('StatusMessageBlock', () => { |
||||
test('It renders properly', async () => { |
||||
const component = render(<StatusMessageBlock text="helloooo" />); |
||||
expect(component.baseElement).toMatchSnapshot(); |
||||
}); |
||||
}); |
@ -0,0 +1,15 @@
|
||||
import React, { FC } from 'react'; |
||||
|
||||
import Text from '../../../Text/Text'; |
||||
|
||||
type Props = { |
||||
text: string; |
||||
}; |
||||
|
||||
const StatusMessageBlock: FC<Props> = ({ text }) => ( |
||||
<pre data-testid="status-message-block"> |
||||
<Text>{text}</Text> |
||||
</pre> |
||||
); |
||||
|
||||
export default StatusMessageBlock; |
@ -0,0 +1,72 @@
|
||||
.root { |
||||
display: inline; |
||||
} |
||||
|
||||
.text { |
||||
&--primary { |
||||
color: var(--primary-text-color); |
||||
} |
||||
|
||||
&--secondary { |
||||
color: var(--secondary-text-color); |
||||
} |
||||
|
||||
&--disabled { |
||||
color: var(--disabled-text-color); |
||||
} |
||||
|
||||
&--warning { |
||||
color: var(--warning-text-color); |
||||
} |
||||
|
||||
&--link { |
||||
color: var(--primary-text-link); |
||||
} |
||||
|
||||
&--success { |
||||
color: var(--green-5); |
||||
} |
||||
|
||||
&--strong { |
||||
font-weight: bold; |
||||
} |
||||
|
||||
&--underline { |
||||
text-decoration: underline; |
||||
} |
||||
|
||||
&--small { |
||||
font-size: 12px; |
||||
} |
||||
|
||||
&--large { |
||||
font-size: 20px; |
||||
} |
||||
} |
||||
|
||||
.no-wrap { |
||||
white-space: nowrap; |
||||
} |
||||
|
||||
.keyboard { |
||||
margin: 0 0.2em; |
||||
padding: 0.15em 0.4em 0.1em; |
||||
font-size: 90%; |
||||
background: hsla(0, 0%, 58.8%, 0.06); |
||||
border: solid hsla(0, 0%, 39.2%, 0.2); |
||||
border-width: 1px 1px 2px; |
||||
border-radius: 3px; |
||||
} |
||||
|
||||
.title { |
||||
margin: 0; |
||||
} |
||||
|
||||
.icon-button { |
||||
margin-left: 4px; |
||||
display: none; |
||||
} |
||||
|
||||
.root:hover .icon-button { |
||||
display: inline-block; |
||||
} |
@ -0,0 +1,164 @@
|
||||
import React, { FC, HTMLAttributes, ChangeEvent, useState, useCallback } from 'react'; |
||||
|
||||
import { IconButton, Modal, Input, HorizontalGroup, Button, VerticalGroup } from '@grafana/ui'; |
||||
import CopyToClipboard from 'react-copy-to-clipboard'; |
||||
|
||||
import { openNotification } from '../../utils'; |
||||
|
||||
// import './Text.module.scss';
|
||||
import { css } from '@emotion/css'; |
||||
|
||||
export type TextType = 'primary' | 'secondary' | 'disabled' | 'link' | 'success' | 'warning'; |
||||
|
||||
interface TextProps extends HTMLAttributes<HTMLElement> { |
||||
type?: TextType; |
||||
strong?: boolean; |
||||
underline?: boolean; |
||||
size?: 'small' | 'medium' | 'large'; |
||||
keyboard?: boolean; |
||||
className?: string; |
||||
wrap?: boolean; |
||||
copyable?: boolean; |
||||
editable?: boolean; |
||||
onTextChange?: (value?: string) => void; |
||||
clearBeforeEdit?: boolean; |
||||
hidden?: boolean; |
||||
editModalTitle?: string; |
||||
} |
||||
|
||||
interface TextInterface extends React.FC<TextProps> { |
||||
Title: React.FC<TitleProps>; |
||||
} |
||||
|
||||
const PLACEHOLDER = '**********'; |
||||
|
||||
const Text: TextInterface = (props) => { |
||||
const { |
||||
type, |
||||
size = 'medium', |
||||
strong = false, |
||||
underline = false, |
||||
children, |
||||
onClick, |
||||
keyboard = false, |
||||
className, |
||||
wrap = true, |
||||
copyable = false, |
||||
editable = false, |
||||
onTextChange, |
||||
clearBeforeEdit = false, |
||||
hidden = false, |
||||
editModalTitle = 'New value', |
||||
style, |
||||
} = props; |
||||
|
||||
const [isEditMode, setIsEditMode] = useState<boolean>(false); |
||||
const [value, setValue] = useState<string | undefined>(); |
||||
|
||||
const handleEditClick = useCallback(() => { |
||||
setValue(clearBeforeEdit || hidden ? '' : (children as string)); |
||||
|
||||
setIsEditMode(true); |
||||
}, [clearBeforeEdit, hidden, children]); |
||||
|
||||
const handleCancelEdit = useCallback(() => { |
||||
setIsEditMode(false); |
||||
}, []); |
||||
|
||||
const handleConfirmEdit = useCallback(() => { |
||||
setIsEditMode(false); |
||||
//@ts-ignore
|
||||
onTextChange(value); |
||||
}, [value, onTextChange]); |
||||
|
||||
const handleInputChange = useCallback((e: ChangeEvent<HTMLInputElement>) => { |
||||
setValue(e.target.value); |
||||
}, []); |
||||
|
||||
return ( |
||||
<span |
||||
onClick={onClick} |
||||
className={css('root', 'text', className, { |
||||
[`text--${type}`]: true, |
||||
[`text--${size}`]: true, |
||||
'text--strong': strong, |
||||
'text--underline': underline, |
||||
'no-wrap': !wrap, |
||||
keyboard, |
||||
})} |
||||
style={style} |
||||
> |
||||
{hidden ? PLACEHOLDER : children} |
||||
{editable && ( |
||||
<IconButton |
||||
onClick={handleEditClick} |
||||
variant="primary" |
||||
className={css('icon-button')} |
||||
tooltip="Edit" |
||||
tooltipPlacement="top" |
||||
name="edit" |
||||
/> |
||||
)} |
||||
{copyable && ( |
||||
<CopyToClipboard |
||||
text={children as string} |
||||
onCopy={() => { |
||||
openNotification('Text copied'); |
||||
}} |
||||
> |
||||
<IconButton |
||||
variant="primary" |
||||
className={css('icon-button')} |
||||
tooltip="Copy to clipboard" |
||||
tooltipPlacement="top" |
||||
name="copy" |
||||
/> |
||||
</CopyToClipboard> |
||||
)} |
||||
{isEditMode && ( |
||||
<Modal onDismiss={handleCancelEdit} closeOnEscape isOpen title={editModalTitle}> |
||||
<VerticalGroup> |
||||
<Input |
||||
autoFocus |
||||
ref={(node) => { |
||||
if (node) { |
||||
node.focus(); |
||||
} |
||||
}} |
||||
value={value} |
||||
onChange={handleInputChange} |
||||
/> |
||||
<HorizontalGroup justify="flex-end"> |
||||
<Button variant="secondary" onClick={handleCancelEdit}> |
||||
Cancel |
||||
</Button> |
||||
<Button variant="primary" onClick={handleConfirmEdit}> |
||||
Ok |
||||
</Button> |
||||
</HorizontalGroup> |
||||
</VerticalGroup> |
||||
</Modal> |
||||
)} |
||||
</span> |
||||
); |
||||
}; |
||||
|
||||
interface TitleProps extends TextProps { |
||||
level: 1 | 2 | 3 | 4 | 5 | 6; |
||||
} |
||||
|
||||
const Title: FC<TitleProps> = (props) => { |
||||
const { level, className, style, ...restProps } = props; |
||||
// @ts-ignore
|
||||
const Tag: keyof JSX.IntrinsicElements = `h${level}`; |
||||
|
||||
return ( |
||||
<Tag className={css('title', className)} style={style}> |
||||
<Text {...restProps} /> |
||||
</Tag> |
||||
); |
||||
}; |
||||
|
||||
Text.Title = Title; |
||||
|
||||
export default Text; |
@ -0,0 +1,55 @@
|
||||
import React, { ReactElement, useCallback, useState } from 'react'; |
||||
|
||||
import { ConfirmModal } from '@grafana/ui'; |
||||
|
||||
interface WithConfirmProps { |
||||
children: ReactElement; |
||||
title?: string; |
||||
body?: React.ReactNode; |
||||
confirmText?: string; |
||||
disabled?: boolean; |
||||
} |
||||
|
||||
const WithConfirm = (props: WithConfirmProps) => { |
||||
const { children, title = 'Are you sure to delete?', body, confirmText = 'Delete', disabled } = props; |
||||
|
||||
const [showConfirmation, setShowConfirmation] = useState<boolean>(false); |
||||
|
||||
const onClickCallback = useCallback((event: any) => { |
||||
event.stopPropagation(); |
||||
|
||||
setShowConfirmation(true); |
||||
}, []); |
||||
|
||||
const onConfirmCallback = useCallback(() => { |
||||
if (children.props.onClick) { |
||||
children.props.onClick(); |
||||
} |
||||
|
||||
setShowConfirmation(false); |
||||
}, [children]); |
||||
|
||||
return ( |
||||
<> |
||||
{showConfirmation && ( |
||||
<ConfirmModal |
||||
isOpen |
||||
title={title} |
||||
confirmText={confirmText} |
||||
dismissText="Cancel" |
||||
onConfirm={onConfirmCallback} |
||||
body={body} |
||||
onDismiss={() => { |
||||
setShowConfirmation(false); |
||||
}} |
||||
/> |
||||
)} |
||||
{React.cloneElement(children, { |
||||
disabled: children.props.disabled || disabled, |
||||
onClick: onClickCallback, |
||||
})} |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
export default WithConfirm; |
@ -1,12 +1,12 @@
|
||||
import { AppPlugin } from '@grafana/data'; |
||||
import { App } from './components/App'; |
||||
import { AppConfig } from './components/AppConfig'; |
||||
import { PluginConfigPage } from './components/PluginConfigPage'; |
||||
|
||||
export const plugin = new AppPlugin<{}>().setRootPage(App).addConfigPage({ |
||||
title: 'Configuration', |
||||
icon: 'fa fa-cog', |
||||
// @ts-ignore - Would expect a Class component, however works absolutely fine with a functional one
|
||||
// Implementation: https://github.com/grafana/grafana/blob/fd44c01675e54973370969dfb9e78f173aff7910/public/app/features/plugins/PluginPage.tsx#L157
|
||||
body: AppConfig, |
||||
body: PluginConfigPage, |
||||
id: 'configuration', |
||||
}); |
||||
|
@ -0,0 +1,189 @@
|
||||
import { makeRequest } from './services/network_service'; |
||||
import { |
||||
DataExporterAppPluginMeta, |
||||
DataExporterPluginMetaJSONData, |
||||
DataExporterPluginMetaSecureJSONData, |
||||
} from './types'; |
||||
|
||||
import { getBackendSrv } from '@grafana/runtime'; |
||||
|
||||
import axios from 'axios'; |
||||
|
||||
export type UpdateGrafanaPluginSettingsProps = { |
||||
jsonData?: Partial<DataExporterPluginMetaJSONData>; |
||||
secureJsonData?: Partial<DataExporterPluginMetaSecureJSONData>; |
||||
}; |
||||
|
||||
type InstallPluginResponse<DataExporterAPIResponse = any> = Pick< |
||||
DataExporterPluginMetaSecureJSONData, |
||||
'grafanaToken' |
||||
> & { |
||||
dataExporterAPIResponse: DataExporterAPIResponse; |
||||
}; |
||||
|
||||
type PluginConnectedStatusResponse = string; |
||||
|
||||
class PluginState { |
||||
static DATA_EXPORTER_BASE_URL = '/plugin'; |
||||
static GRAFANA_PLUGIN_SETTINGS_URL = '/api/plugins/corpglory-dataexporter-app/settings'; |
||||
static grafanaBackend = getBackendSrv(); |
||||
|
||||
static generateInvalidDataExporterApiURLErrorMsg = (dataExporterApiUrl: string): string => |
||||
`Could not communicate with your DataExporter API at ${dataExporterApiUrl}.\nValidate that the URL is correct, your DataExporter API is running, and that it is accessible from your Grafana instance.`; |
||||
|
||||
static generateUnknownErrorMsg = (dataExporterApiUrl: string): string => |
||||
`An unknown error occured when trying to install the plugin. Are you sure that your DataExporter API URL, ${dataExporterApiUrl}, is correct?\nRefresh your page and try again, or try removing your plugin configuration and reconfiguring.`; |
||||
|
||||
static getHumanReadableErrorFromDataExporterError = (e: any, dataExporterApiUrl: string): string => { |
||||
let errorMsg: string; |
||||
const unknownErrorMsg = this.generateUnknownErrorMsg(dataExporterApiUrl); |
||||
const consoleMsg = `occured while trying to install the plugin w/ the DataExporter backend`; |
||||
|
||||
if (axios.isAxiosError(e)) { |
||||
const statusCode = e.response?.status; |
||||
|
||||
console.warn(`An HTTP related error ${consoleMsg}`, e.response); |
||||
|
||||
if (statusCode === 502) { |
||||
// 502 occurs when the plugin-proxy cannot communicate w/ the DataExporter API using the provided URL
|
||||
errorMsg = this.generateInvalidDataExporterApiURLErrorMsg(dataExporterApiUrl); |
||||
} else if (statusCode === 400) { |
||||
/** |
||||
* A 400 is 'bubbled-up' from the DataExporter API. It indicates one of three cases: |
||||
* 1. there is a communication error when DataExporter API tries to contact Grafana's API |
||||
* 2. there is an auth error when DataExporter API tries to contact Grafana's API |
||||
* 3. (likely rare) user inputs an DataExporterApiUrl that is not RFC 1034/1035 compliant |
||||
* |
||||
* Check if the response body has an 'error' JSON attribute, if it does, assume scenario 1 or 2 |
||||
* Use the error message provided to give the user more context/helpful debugging information |
||||
*/ |
||||
errorMsg = e.response?.data?.error || unknownErrorMsg; |
||||
} else { |
||||
// this scenario shouldn't occur..
|
||||
errorMsg = unknownErrorMsg; |
||||
} |
||||
} else { |
||||
// a non-axios related error occured.. this scenario shouldn't occur...
|
||||
console.warn(`An unknown error ${consoleMsg}`, e); |
||||
errorMsg = unknownErrorMsg; |
||||
} |
||||
return errorMsg; |
||||
}; |
||||
|
||||
static getHumanReadableErrorFromGrafanaProvisioningError = (e: any, dataExporterApiUrl: string): string => { |
||||
let errorMsg: string; |
||||
|
||||
if (axios.isAxiosError(e)) { |
||||
// The user likely put in a bogus URL for the DataExporter API URL
|
||||
console.warn('An HTTP related error occured while trying to provision the plugin w/ Grafana', e.response); |
||||
errorMsg = this.generateInvalidDataExporterApiURLErrorMsg(dataExporterApiUrl); |
||||
} else { |
||||
// a non-axios related error occured.. this scenario shouldn't occur...
|
||||
console.warn('An unknown error occured while trying to provision the plugin w/ Grafana', e); |
||||
errorMsg = this.generateUnknownErrorMsg(dataExporterApiUrl); |
||||
} |
||||
return errorMsg; |
||||
}; |
||||
|
||||
static getGrafanaPluginSettings = async (): Promise<DataExporterAppPluginMeta> => |
||||
this.grafanaBackend.get<DataExporterAppPluginMeta>(this.GRAFANA_PLUGIN_SETTINGS_URL); |
||||
|
||||
static updateGrafanaPluginSettings = async (data: UpdateGrafanaPluginSettingsProps, enabled = true) => |
||||
this.grafanaBackend.post(this.GRAFANA_PLUGIN_SETTINGS_URL, { ...data, enabled, pinned: true }); |
||||
|
||||
static createGrafanaToken = async () => { |
||||
const baseUrl = '/api/auth/keys'; |
||||
const keys = await this.grafanaBackend.get(baseUrl); |
||||
const existingKey = keys.find((key: { id: number; name: string; role: string }) => key.name === 'DataExporter'); |
||||
|
||||
if (existingKey) { |
||||
await this.grafanaBackend.delete(`${baseUrl}/${existingKey.id}`); |
||||
} |
||||
|
||||
return await this.grafanaBackend.post(baseUrl, { |
||||
name: 'DataExporter', |
||||
role: 'Admin', |
||||
secondsToLive: null, |
||||
}); |
||||
}; |
||||
|
||||
static timeout = (pollCount: number) => new Promise((resolve) => setTimeout(resolve, 10 * 2 ** pollCount)); |
||||
|
||||
static connectBackend = async (): Promise<InstallPluginResponse<string>> => { |
||||
const { key: grafanaToken } = await this.createGrafanaToken(); |
||||
await this.updateGrafanaPluginSettings({ secureJsonData: { grafanaToken } }); |
||||
const dataExporterAPIResponse = await makeRequest<string>(`${this.DATA_EXPORTER_BASE_URL}/connect`, { |
||||
method: 'POST', |
||||
}); |
||||
return { grafanaToken, dataExporterAPIResponse }; |
||||
}; |
||||
|
||||
static installPlugin = async (dataExporterApiUrl: string): Promise<string | null> => { |
||||
let pluginInstallationDataExporterResponse: InstallPluginResponse<string>; |
||||
|
||||
// Step 1. Try provisioning the plugin w/ the Grafana API
|
||||
try { |
||||
await this.updateGrafanaPluginSettings({ jsonData: { dataExporterApiUrl } }); |
||||
} catch (e) { |
||||
return this.getHumanReadableErrorFromGrafanaProvisioningError(e, dataExporterApiUrl); |
||||
} |
||||
|
||||
/** |
||||
* Step 2: |
||||
* - Create a grafana token |
||||
* - store that token in the Grafana plugin settings |
||||
* - configure the plugin in DataExporter's backend |
||||
*/ |
||||
try { |
||||
pluginInstallationDataExporterResponse = await this.connectBackend(); |
||||
} catch (e) { |
||||
return this.getHumanReadableErrorFromDataExporterError(e, dataExporterApiUrl); |
||||
} |
||||
|
||||
// Step 3. reprovision the Grafana plugin settings, storing information that we get back from DataExporter's backend
|
||||
try { |
||||
const { grafanaToken } = pluginInstallationDataExporterResponse; |
||||
|
||||
await this.updateGrafanaPluginSettings({ |
||||
jsonData: { |
||||
dataExporterApiUrl, |
||||
}, |
||||
secureJsonData: { |
||||
grafanaToken, |
||||
}, |
||||
}); |
||||
} catch (e) { |
||||
return this.getHumanReadableErrorFromGrafanaProvisioningError(e, dataExporterApiUrl); |
||||
} |
||||
|
||||
return null; |
||||
}; |
||||
|
||||
static checkIfPluginIsConnected = async (DataExporterApiUrl: string): Promise<string> => { |
||||
try { |
||||
return await makeRequest<PluginConnectedStatusResponse>(`${this.DATA_EXPORTER_BASE_URL}/status`, { |
||||
method: 'GET', |
||||
}); |
||||
} catch (e) { |
||||
return this.getHumanReadableErrorFromDataExporterError(e, DataExporterApiUrl); |
||||
} |
||||
}; |
||||
|
||||
static resetPlugin = (): Promise<void> => { |
||||
/** |
||||
* mark both of these objects as Required.. this will ensure that we are resetting every attribute back to null |
||||
* and throw a type error in the event that DataExporterPluginMetaJSONData or DataExporterPluginMetaSecureJSONData is updated |
||||
* but we forget to add the attribute here |
||||
*/ |
||||
const jsonData: Required<DataExporterPluginMetaJSONData> = { |
||||
dataExporterApiUrl: null, |
||||
}; |
||||
const secureJsonData: Required<DataExporterPluginMetaSecureJSONData> = { |
||||
grafanaToken: null, |
||||
}; |
||||
|
||||
return this.updateGrafanaPluginSettings({ jsonData, secureJsonData }, false); |
||||
}; |
||||
} |
||||
|
||||
export default PluginState; |
@ -0,0 +1,30 @@
|
||||
import axios from 'axios'; |
||||
|
||||
export const API_HOST = `${window.location.protocol}//${window.location.host}/`; |
||||
export const API_PROXY_PREFIX = 'api/plugin-proxy/corpglory-dataexporter-app'; |
||||
|
||||
const instance = axios.create(); |
||||
|
||||
interface RequestConfig { |
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'OPTIONS'; |
||||
params?: any; |
||||
data?: any; |
||||
withCredentials?: boolean; |
||||
validateStatus?: (status: number) => boolean; |
||||
} |
||||
|
||||
export const makeRequest = async <RT = any>(path: string, config: RequestConfig) => { |
||||
const { method = 'GET', params, data, validateStatus } = config; |
||||
|
||||
const url = `${API_PROXY_PREFIX}${path}`; |
||||
|
||||
const response = await instance({ |
||||
method, |
||||
url, |
||||
params, |
||||
data, |
||||
validateStatus, |
||||
}); |
||||
|
||||
return response.data as RT; |
||||
}; |
@ -0,0 +1,16 @@
|
||||
import { AppRootProps as BaseAppRootProps, AppPluginMeta, PluginConfigPageProps } from '@grafana/data'; |
||||
|
||||
export type DataExporterPluginMetaJSONData = { |
||||
dataExporterApiUrl: string | null; |
||||
}; |
||||
|
||||
export type DataExporterPluginMetaSecureJSONData = { |
||||
grafanaToken: string | null; |
||||
}; |
||||
|
||||
export type AppRootProps = BaseAppRootProps<DataExporterPluginMetaJSONData>; |
||||
|
||||
// NOTE: it is possible that plugin.meta.jsonData is null (ex. on first-ever setup)
|
||||
// the typing on AppPluginMeta does not seem correct atm..
|
||||
export type DataExporterAppPluginMeta = AppPluginMeta<DataExporterPluginMetaJSONData>; |
||||
export type DataExporterPluginConfigPageProps = PluginConfigPageProps<DataExporterAppPluginMeta>; |
@ -0,0 +1,7 @@
|
||||
import { AppEvents } from '@grafana/data'; |
||||
// @ts-ignore
|
||||
import appEvents from 'grafana/app/core/app_events'; |
||||
|
||||
export function openNotification(message: React.ReactNode) { |
||||
appEvents.emit(AppEvents.alertSuccess, [message]); |
||||
} |
Loading…
Reference in new issue