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 { AppPlugin } from '@grafana/data'; |
||||||
import { App } from './components/App'; |
import { App } from './components/App'; |
||||||
import { AppConfig } from './components/AppConfig'; |
import { PluginConfigPage } from './components/PluginConfigPage'; |
||||||
|
|
||||||
export const plugin = new AppPlugin<{}>().setRootPage(App).addConfigPage({ |
export const plugin = new AppPlugin<{}>().setRootPage(App).addConfigPage({ |
||||||
title: 'Configuration', |
title: 'Configuration', |
||||||
icon: 'fa fa-cog', |
icon: 'fa fa-cog', |
||||||
// @ts-ignore - Would expect a Class component, however works absolutely fine with a functional one
|
// @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
|
// Implementation: https://github.com/grafana/grafana/blob/fd44c01675e54973370969dfb9e78f173aff7910/public/app/features/plugins/PluginPage.tsx#L157
|
||||||
body: AppConfig, |
body: PluginConfigPage, |
||||||
id: 'configuration', |
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