Browse Source

WIP: config page

pull/1/head
rozetko 2 years ago
parent
commit
a75abdc3f7
  1. 41
      .config/webpack/webpack.config.ts
  2. 12
      package.json
  3. 51
      src/components/AppConfig/AppConfig.test.tsx
  4. 92
      src/components/AppConfig/AppConfig.tsx
  5. 1
      src/components/AppConfig/index.tsx
  6. 28
      src/components/GBlock/Block.module.scss
  7. 44
      src/components/GBlock/Block.tsx
  8. 198
      src/components/PluginConfigPage/PluginConfigPage.tsx
  9. 1
      src/components/PluginConfigPage/index.tsx
  10. 4
      src/components/PluginConfigPage/parts/ConfigurationForm/ConfigurationForm.module.css
  11. 147
      src/components/PluginConfigPage/parts/ConfigurationForm/index.tsx
  12. 32
      src/components/PluginConfigPage/parts/RemoveCurrentConfigurationButton/RemoveCurrentConfigurationButton.test.tsx
  13. 20
      src/components/PluginConfigPage/parts/RemoveCurrentConfigurationButton/index.tsx
  14. 12
      src/components/PluginConfigPage/parts/StatusMessageBlock/StatusMessageBlock.test.tsx
  15. 15
      src/components/PluginConfigPage/parts/StatusMessageBlock/index.tsx
  16. 72
      src/components/Text/Text.module.scss
  17. 164
      src/components/Text/Text.tsx
  18. 55
      src/components/WithConfirm/WithConfirm.tsx
  19. 4
      src/module.ts
  20. 189
      src/plugin_state.ts
  21. 30
      src/services/network_service.ts
  22. 16
      src/types.ts
  23. 7
      src/utils/index.ts
  24. 623
      yarn.lock

41
.config/webpack/webpack.config.ts

@ -59,6 +59,7 @@ const config = async (env): Promise<Configuration> => ({
const stripPrefix = (request) => request.substr(prefix.length); const stripPrefix = (request) => request.substr(prefix.length);
if (hasPrefix(request)) { if (hasPrefix(request)) {
// @ts-ignore
return callback(null, stripPrefix(request)); return callback(null, stripPrefix(request));
} }
@ -90,28 +91,24 @@ const config = async (env): Promise<Configuration> => ({
}, },
}, },
}, },
{ // {
test: /\.(png|jpe?g|gif|svg)$/, // test: /\.(png|jpe?g|gif|svg)$/,
use: [ // type: 'asset/resource',
{ // options: {
loader: 'asset/resource', // outputPath: '/',
options: { // name: Boolean(env.production) ? '[path][hash].[ext]' : '[path][name].[ext]',
outputPath: '/', // },
name: Boolean(env.production) ? '[path][hash].[ext]' : '[path][name].[ext]', // },
}, // {
}, // test: /\.(woff|woff2|eot|ttf|otf)(\?v=\d+\.\d+\.\d+)?$/,
], // type: 'asset/resource',
}, // options: {
{ // // Keep publicPath relative for host.com/grafana/ deployments
test: /\.(woff|woff2|eot|ttf|otf)(\?v=\d+\.\d+\.\d+)?$/, // publicPath: `public/plugins/${getPluginId()}/fonts`,
loader: 'asset/resource', // outputPath: 'fonts',
options: { // name: Boolean(env.production) ? '[hash].[ext]' : '[name].[ext]',
// Keep publicPath relative for host.com/grafana/ deployments // },
publicPath: `public/plugins/${getPluginId()}/fonts`, // },
outputPath: 'fonts',
name: Boolean(env.production) ? '[hash].[ext]' : '[name].[ext]',
},
},
], ],
}, },

12
package.json

@ -27,12 +27,16 @@
"@swc/jest": "^0.2.20", "@swc/jest": "^0.2.20",
"@testing-library/jest-dom": "^5.16.2", "@testing-library/jest-dom": "^5.16.2",
"@testing-library/react": "^12.1.3", "@testing-library/react": "^12.1.3",
"@testing-library/user-event": "^14.4.3",
"@types/glob": "^8.0.0", "@types/glob": "^8.0.0",
"@types/jest": "^27.4.1", "@types/jest": "^27.4.1",
"@types/react-router-dom": "^5.3.3", "@types/lodash-es": "^4.17.6",
"@types/node": "^17.0.19", "@types/node": "^17.0.19",
"@types/react-copy-to-clipboard": "^5.0.4",
"@types/react-router-dom": "^5.3.3",
"@typescript-eslint/eslint-plugin": "^4.33.0", "@typescript-eslint/eslint-plugin": "^4.33.0",
"@typescript-eslint/parser": "^4.33.0", "@typescript-eslint/parser": "^4.33.0",
"axios": "^1.2.1",
"copy-webpack-plugin": "^10.0.0", "copy-webpack-plugin": "^10.0.0",
"eslint": "^7.32.0", "eslint": "^7.32.0",
"eslint-config-prettier": "^8.3.0", "eslint-config-prettier": "^8.3.0",
@ -41,14 +45,16 @@
"eslint-plugin-react": "^7.26.1", "eslint-plugin-react": "^7.26.1",
"eslint-plugin-react-hooks": "^4.2.0", "eslint-plugin-react-hooks": "^4.2.0",
"eslint-webpack-plugin": "^3.1.1", "eslint-webpack-plugin": "^3.1.1",
"fork-ts-checker-webpack-plugin": "^7.2.0",
"glob": "^8.0.3", "glob": "^8.0.3",
"jest": "27.5.0", "jest": "27.5.0",
"fork-ts-checker-webpack-plugin": "^7.2.0", "lodash-es": "^4.17.21",
"prettier": "^2.5.0", "prettier": "^2.5.0",
"react-copy-to-clipboard": "^5.1.0",
"replace-in-file-webpack-plugin": "^1.0.6", "replace-in-file-webpack-plugin": "^1.0.6",
"swc-loader": "^0.1.15", "swc-loader": "^0.1.15",
"tsconfig-paths": "^3.12.0",
"ts-node": "^10.5.0", "ts-node": "^10.5.0",
"tsconfig-paths": "^3.12.0",
"typescript": "^4.4.0", "typescript": "^4.4.0",
"webpack": "^5.69.1", "webpack": "^5.69.1",
"webpack-cli": "^4.9.2", "webpack-cli": "^4.9.2",

51
src/components/AppConfig/AppConfig.test.tsx

@ -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();
});
});

92
src/components/AppConfig/AppConfig.tsx

@ -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);
};

1
src/components/AppConfig/index.tsx

@ -1 +0,0 @@
export * from './AppConfig';

28
src/components/GBlock/Block.module.scss

@ -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);
}

44
src/components/GBlock/Block.tsx

@ -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;

198
src/components/PluginConfigPage/PluginConfigPage.tsx

@ -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}
</>
);
};

1
src/components/PluginConfigPage/index.tsx

@ -0,0 +1 @@
export * from './PluginConfigPage';

4
src/components/PluginConfigPage/parts/ConfigurationForm/ConfigurationForm.module.css

@ -0,0 +1,4 @@
.info-block {
margin-bottom: 24px;
margin-top: 24px;
}

147
src/components/PluginConfigPage/parts/ConfigurationForm/index.tsx

@ -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;

32
src/components/PluginConfigPage/parts/RemoveCurrentConfigurationButton/RemoveCurrentConfigurationButton.test.tsx

@ -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);
});
});

20
src/components/PluginConfigPage/parts/RemoveCurrentConfigurationButton/index.tsx

@ -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;

12
src/components/PluginConfigPage/parts/StatusMessageBlock/StatusMessageBlock.test.tsx

@ -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();
});
});

15
src/components/PluginConfigPage/parts/StatusMessageBlock/index.tsx

@ -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;

72
src/components/Text/Text.module.scss

@ -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;
}

164
src/components/Text/Text.tsx

@ -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;

55
src/components/WithConfirm/WithConfirm.tsx

@ -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;

4
src/module.ts

@ -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',
}); });

189
src/plugin_state.ts

@ -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;

30
src/services/network_service.ts

@ -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;
};

16
src/types.ts

@ -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>;

7
src/utils/index.ts

@ -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]);
}

623
yarn.lock

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save