Browse Source

config page (#1)

In this PR, I've copied App Config page from Grafana OnCall App https://github.com/grafana/oncall

Reviewed-on: #1
pull/2/head
rozetko 1 year ago
parent
commit
32ece10121
  1. 2
      .config/webpack/constants.ts
  2. 9
      .config/webpack/tsconfig.webpack.json
  3. 42
      .config/webpack/utils.ts
  4. 185
      .config/webpack/webpack.config.ts
  5. 3
      .gitignore
  6. 2
      jest-setup.js
  7. 59
      package.json
  8. 32
      src/components/App/App.test.tsx
  9. 8
      src/components/App/App.tsx
  10. 1
      src/components/App/index.tsx
  11. 51
      src/components/AppConfig/AppConfig.test.tsx
  12. 92
      src/components/AppConfig/AppConfig.tsx
  13. 1
      src/components/AppConfig/index.tsx
  14. 28
      src/components/GBlock/Block.module.scss
  15. 47
      src/components/GBlock/Block.tsx
  16. 180
      src/components/PluginConfigPage/PluginConfigPage.tsx
  17. 3
      src/components/PluginConfigPage/index.tsx
  18. 4
      src/components/PluginConfigPage/parts/ConfigurationForm/ConfigurationForm.module.css
  19. 125
      src/components/PluginConfigPage/parts/ConfigurationForm/index.tsx
  20. 20
      src/components/PluginConfigPage/parts/RemoveCurrentConfigurationButton/index.tsx
  21. 15
      src/components/PluginConfigPage/parts/StatusMessageBlock/index.tsx
  22. 72
      src/components/Text/Text.module.scss
  23. 167
      src/components/Text/Text.tsx
  24. 55
      src/components/WithConfirm/WithConfirm.tsx
  25. 4
      src/img/logo.svg
  26. 6
      src/index.d.ts
  27. 8
      src/module.ts
  28. 11
      src/plugin.json
  29. 200
      src/plugin_state.ts
  30. 41
      src/services/network_service.ts
  31. 45
      src/style/global.css
  32. 78
      src/style/vars.css
  33. 16
      src/types.ts
  34. 7
      src/utils/index.ts
  35. 147
      webpack.config.ts
  36. 4193
      yarn.lock

2
.config/webpack/constants.ts

@ -1,2 +0,0 @@
export const SOURCE_DIR = 'src';
export const DIST_DIR = 'dist';

9
.config/webpack/tsconfig.webpack.json

@ -1,9 +0,0 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "es5",
"esModuleInterop": true
},
"transpileOnly": true,
"transpiler": "ts-node/transpilers/swc-experimental"
}

42
.config/webpack/utils.ts

@ -1,42 +0,0 @@
import fs from 'fs';
import path from 'path';
import util from 'util';
import glob from 'glob';
import { SOURCE_DIR } from './constants';
const globAsync = util.promisify(glob);
export function getPackageJson() {
return require(path.resolve(process.cwd(), 'package.json'));
}
export function getPluginId() {
const { id } = require(path.resolve(process.cwd(), `${SOURCE_DIR}/plugin.json`));
return id;
}
export function hasReadme() {
return fs.existsSync(path.resolve(process.cwd(), SOURCE_DIR, 'README.md'));
}
export async function getEntries(): Promise<Record<string, string>> {
const parent = '..';
const pluginsJson = await globAsync('**/src/**/plugin.json');
const plugins = await Promise.all(pluginsJson.map(pluginJson => {
const folder = path.dirname(pluginJson);
return globAsync(`${folder}/module.{ts,tsx,js}`);
}));
return plugins.reduce((result, modules) => {
return modules.reduce((result, module) => {
const pluginPath = path.resolve(path.dirname(module), parent);
const pluginName = path.basename(pluginPath);
const entryName = plugins.length > 1 ? `${pluginName}/module` : 'module';
result[entryName] = path.join(parent, module);
return result;
}, result);
}, {});
}

185
.config/webpack/webpack.config.ts

@ -1,185 +0,0 @@
/*
* THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY.
*
* In order to extend the configuration follow the steps in .config/README.md
*/
import CopyWebpackPlugin from 'copy-webpack-plugin';
import ESLintPlugin from 'eslint-webpack-plugin';
import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin';
import LiveReloadPlugin from 'webpack-livereload-plugin';
import path from 'path';
import ReplaceInFileWebpackPlugin from 'replace-in-file-webpack-plugin';
import { Configuration } from 'webpack';
import { getPackageJson, getPluginId, hasReadme, getEntries } from './utils';
import { SOURCE_DIR, DIST_DIR } from './constants';
const config = async (env): Promise<Configuration> => ({
cache: {
type: 'filesystem',
buildDependencies: {
config: [__filename],
},
},
context: path.join(process.cwd(), SOURCE_DIR),
devtool: env.production ? 'source-map' : 'eval-source-map',
entry: await getEntries(),
externals: [
'lodash',
'jquery',
'moment',
'slate',
'emotion',
'@emotion/react',
'@emotion/css',
'prismjs',
'slate-plain-serializer',
'@grafana/slate-react',
'react',
'react-dom',
'react-redux',
'redux',
'rxjs',
'react-router-dom',
'd3',
'angular',
'@grafana/ui',
'@grafana/runtime',
'@grafana/data',
// Mark legacy SDK imports as external if their name starts with the "grafana/" prefix
({ request }, callback) => {
const prefix = 'grafana/';
const hasPrefix = (request) => request.indexOf(prefix) === 0;
const stripPrefix = (request) => request.substr(prefix.length);
if (hasPrefix(request)) {
return callback(null, stripPrefix(request));
}
callback();
},
],
mode: env.production ? 'production' : 'development',
module: {
rules: [
{
exclude: /(node_modules)/,
test: /\.[tj]sx?$/,
use: {
loader: 'swc-loader',
options: {
jsc: {
baseUrl: './src',
target: 'es2015',
loose: false,
parser: {
syntax: 'typescript',
tsx: true,
decorators: false,
dynamicImport: true,
},
},
},
},
},
{
test: /\.(png|jpe?g|gif|svg)$/,
use: [
{
loader: 'asset/resource',
options: {
outputPath: '/',
name: Boolean(env.production) ? '[path][hash].[ext]' : '[path][name].[ext]',
},
},
],
},
{
test: /\.(woff|woff2|eot|ttf|otf)(\?v=\d+\.\d+\.\d+)?$/,
loader: 'asset/resource',
options: {
// Keep publicPath relative for host.com/grafana/ deployments
publicPath: `public/plugins/${getPluginId()}/fonts`,
outputPath: 'fonts',
name: Boolean(env.production) ? '[hash].[ext]' : '[name].[ext]',
},
},
],
},
output: {
clean: true,
filename: '[name].js',
libraryTarget: 'amd',
path: path.resolve(process.cwd(), DIST_DIR),
publicPath: '/',
},
plugins: [
new CopyWebpackPlugin({
patterns: [
// If src/README.md exists use it; otherwise the root README
// To `compiler.options.output`
{ from: hasReadme() ? 'README.md' : '../README.md', to: '.', force: true },
{ from: 'plugin.json', to: '.' },
{ from: '../LICENSE', to: '.' },
{ from: '../CHANGELOG.md', to: '.', force: true },
{ from: '**/*.json', to: '.' }, // TODO<Add an error for checking the basic structure of the repo>
{ from: '**/*.svg', to: '.', noErrorOnMissing: true }, // Optional
{ from: '**/*.png', to: '.', noErrorOnMissing: true }, // Optional
{ from: '**/*.html', to: '.', noErrorOnMissing: true }, // Optional
{ from: 'img/**/*', to: '.', noErrorOnMissing: true }, // Optional
{ from: 'libs/**/*', to: '.', noErrorOnMissing: true }, // Optional
{ from: 'static/**/*', to: '.', noErrorOnMissing: true }, // Optional
],
}),
// Replace certain template-variables in the README and plugin.json
new ReplaceInFileWebpackPlugin([
{
dir: DIST_DIR,
files: ['plugin.json', 'README.md'],
rules: [
{
search: /\%VERSION\%/g,
replace: getPackageJson().version,
},
{
search: /\%TODAY\%/g,
replace: new Date().toISOString().substring(0, 10),
},
{
search: /\%PLUGIN_ID\%/g,
replace: getPluginId(),
},
],
},
]),
new ForkTsCheckerWebpackPlugin({
async: Boolean(env.development),
issue: {
include: [{ file: '**/*.{ts,tsx}' }],
},
typescript: { configFile: path.join(process.cwd(), 'tsconfig.json') },
}),
new ESLintPlugin({
extensions: ['.ts', '.tsx'],
lintDirtyModulesOnly: Boolean(env.development), // don't lint on start, only lint changed files
}),
...(env.development ? [new LiveReloadPlugin()] : []),
],
resolve: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
unsafeCache: true,
},
});
export default config;

3
.gitignore vendored

@ -1,2 +1,5 @@
node_modules
dist
.eslintcache
yarn-error.log
coverage

2
jest-setup.js

@ -1,2 +0,0 @@
// Jest setup provided by Grafana scaffolding
import './.config/jest-setup';

59
package.json

@ -3,16 +3,35 @@
"version": "1.0.0",
"description": "",
"scripts": {
"build": "TS_NODE_PROJECT=\"./.config/webpack/tsconfig.webpack.json\" webpack -c ./.config/webpack/webpack.config.ts --env production",
"dev": "TS_NODE_PROJECT=\"./.config/webpack/tsconfig.webpack.json\" webpack -w -c ./.config/webpack/webpack.config.ts --env development",
"test": "jest --watch --onlyChanged",
"test:ci": "jest --maxWorkers 4",
"typecheck": "tsc --noEmit",
"lint": "eslint --cache --ignore-path ./.gitignore --ext .js,.jsx,.ts,.tsx .",
"lint:fix": "yarn lint --fix",
"e2e": "yarn cypress install && yarn grafana-e2e run",
"e2e:update": "yarn cypress install && yarn grafana-e2e run --update-screenshots",
"server": "docker-compose up --build"
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx --max-warnings=0 ./src",
"lint:fix": "eslint --fix --cache --ext .js,.jsx,.ts,.tsx --max-warnings=0 --quiet ./src",
"stylelint": "stylelint ./src/**/*.{css,scss,module.css,module.scss}",
"stylelint:fix": "stylelint --fix ./src/**/*.{css,scss,module.css,module.scss}",
"build": "grafana-toolkit plugin:build",
"build:dev": "grafana-toolkit plugin:build --skipTest --skipLint",
"test": "jest --verbose",
"dev": "grafana-toolkit plugin:dev",
"watch": "grafana-toolkit plugin:dev --watch",
"sign": "grafana-toolkit plugin:sign",
"ci-build:finish": "grafana-toolkit plugin:ci-build --finish",
"ci-package": "grafana-toolkit plugin:ci-package",
"ci-report": "grafana-toolkit plugin:ci-report",
"start": "yarn watch",
"plop": "plop",
"setversion": "setversion"
},
"lint-staged": {
"*.ts?(x)": [
"prettier --write",
"eslint --fix"
],
"*.js?(x)": [
"prettier --write",
"eslint --fix"
],
"*.css": [
"stylelint --fix"
]
},
"author": "CorpGlory Inc.",
"license": "Apache-2.0",
@ -21,19 +40,27 @@
"@grafana/e2e": "9.1.2",
"@grafana/e2e-selectors": "9.1.2",
"@grafana/eslint-config": "^2.5.0",
"@grafana/toolkit": "^9.3.2",
"@grafana/tsconfig": "^1.2.0-rc1",
"@swc/core": "^1.2.144",
"@swc/helpers": "^0.3.6",
"@swc/jest": "^0.2.20",
"@testing-library/jest-dom": "^5.16.2",
"@testing-library/react": "^12.1.3",
"@testing-library/user-event": "^14.4.3",
"@types/glob": "^8.0.0",
"@types/jest": "^27.4.1",
"@types/react-router-dom": "^5.3.3",
"@types/lodash-es": "^4.17.6",
"@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/parser": "^4.33.0",
"axios": "^1.2.1",
"circular-dependency-plugin": "^5.2.2",
"classnames": "^2.3.2",
"copy-webpack-plugin": "^10.0.0",
"css-loader": "^6.7.3",
"eslint": "^7.32.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-jsdoc": "^36.1.0",
@ -41,14 +68,20 @@
"eslint-plugin-react": "^7.26.1",
"eslint-plugin-react-hooks": "^4.2.0",
"eslint-webpack-plugin": "^3.1.1",
"fork-ts-checker-webpack-plugin": "^7.2.0",
"glob": "^8.0.3",
"jest": "27.5.0",
"fork-ts-checker-webpack-plugin": "^7.2.0",
"lodash-es": "^4.17.21",
"postcss-loader": "^7.0.2",
"prettier": "^2.5.0",
"react-copy-to-clipboard": "^5.1.0",
"replace-in-file-webpack-plugin": "^1.0.6",
"sass": "^1.57.0",
"sass-loader": "^13.2.0",
"style-loader": "^3.3.1",
"swc-loader": "^0.1.15",
"tsconfig-paths": "^3.12.0",
"ts-node": "^10.5.0",
"tsconfig-paths": "^3.12.0",
"typescript": "^4.4.0",
"webpack": "^5.69.1",
"webpack-cli": "^4.9.2",

32
src/components/App/App.test.tsx

@ -1,32 +0,0 @@
import React from 'react';
import { AppRootProps, PluginType } from '@grafana/data';
import { render, screen } from '@testing-library/react';
import { App } from './App';
describe('Components/App', () => {
let props: AppRootProps;
beforeEach(() => {
jest.resetAllMocks();
props = {
basename: 'a/sample-app',
meta: {
id: 'sample-app',
name: 'Sample App',
type: PluginType.app,
enabled: true,
jsonData: {},
},
query: {},
path: '',
onNavChanged: jest.fn(),
} as unknown as AppRootProps;
});
test('renders without an error"', () => {
render(<App {...props} />);
expect(screen.queryByText(/Hello Grafana!/i)).toBeInTheDocument();
});
});

8
src/components/App/App.tsx

@ -1,8 +0,0 @@
import * as React from 'react';
import { AppRootProps } from '@grafana/data';
export class App extends React.PureComponent<AppRootProps> {
render() {
return <div className="page-container">Hello Grafana!</div>;
}
}

1
src/components/App/index.tsx

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

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

47
src/components/GBlock/Block.tsx

@ -0,0 +1,47 @@
import styles from './Block.module.scss';
import cn from 'classnames/bind';
import React, { FC, HTMLAttributes } from 'react';
interface BlockProps extends HTMLAttributes<HTMLElement> {
bordered?: boolean;
shadowed?: boolean;
withBackground?: boolean;
hover?: boolean;
fullWidth?: boolean;
}
const cx = cn.bind(styles);
const Block: FC<BlockProps> = (props) => {
const {
children,
style,
className,
bordered = false,
fullWidth = false,
hover = false,
shadowed = false,
withBackground = false,
...rest
} = props;
return (
<div
className={cx('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;

180
src/components/PluginConfigPage/PluginConfigPage.tsx

@ -0,0 +1,180 @@
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, { PluginConnectedStatusResponse } 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 PLUGIN_CONFIGURED_VERSION_QUERY_PARAM = 'pluginConfiguredVersion';
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 = (
{ version }: PluginConnectedStatusResponse,
pluginEnabled: boolean
): void => {
if (!pluginEnabled) {
window.location.href = `${window.location.href}?${PLUGIN_CONFIGURED_QUERY_PARAM}=${PLUGIN_CONFIGURED_QUERY_PARAM_TRUTHY_VALUE}&${PLUGIN_CONFIGURED_VERSION_QUERY_PARAM}=${version}`;
}
};
/**
* 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 pluginConfiguredVersionQueryParam = queryParams.get(PLUGIN_CONFIGURED_VERSION_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<PluginConnectedStatusResponse | null>(
pluginConfiguredRedirect ? { version: pluginConfiguredVersionQueryParam as string } : null
);
const [resettingPlugin, setResettingPlugin] = useState<boolean>(false);
const [pluginResetError, setPluginResetError] = useState<string | null>(null);
const pluginMetaDataExporterApiUrl = jsonData?.dataExporterApiUrl;
const resetQueryParams = useCallback(() => removePluginConfiguredQueryParams(pluginIsEnabled), [pluginIsEnabled]);
const checkConnection = useCallback(async () => {
setCheckingIfPluginIsConnected(true);
setPluginConnectionCheckError(null);
if (!pluginMetaDataExporterApiUrl) {
setCheckingIfPluginIsConnected(false);
return;
}
const pluginConnectionResponse = await PluginState.checkIfPluginIsConnected(pluginMetaDataExporterApiUrl);
if (typeof pluginConnectionResponse === 'string') {
setPluginConnectionCheckError(pluginConnectionResponse);
} else {
setPluginIsConnected(pluginConnectionResponse);
}
setCheckingIfPluginIsConnected(false);
}, [pluginMetaDataExporterApiUrl]);
useEffect(resetQueryParams, [resetQueryParams]);
useEffect(() => {
/**
* don't check the plugin status if the user was just redirected after a successful
* plugin setup
*/
if (!pluginConfiguredRedirect) {
checkConnection();
}
}, [pluginMetaDataExporterApiUrl, pluginConfiguredRedirect, checkConnection]);
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 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={checkConnection} 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}
</>
);
};

3
src/components/PluginConfigPage/index.tsx

@ -0,0 +1,3 @@
import '../../style/vars.css';
import '../../style/global.css';
export * from './PluginConfigPage';

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

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

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

@ -0,0 +1,125 @@
import styles from './ConfigurationForm.module.css';
import Block from '../../../GBlock/Block';
import Text from '../../../Text/Text';
import PluginState from '../../../../plugin_state';
import { Button, Field, Form, Input } from '@grafana/ui';
import cn from 'classnames/bind';
import { SubmitHandler } from 'react-hook-form';
import React, { FC, useCallback, useState } from 'react';
import { isEmpty } from 'lodash-es';
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={cx('info-block')}>
<Text type="secondary">
Need help?
<br />- file bugs on our GitHub Issues page{' '}
<a
href="https://code.corpglory.net/corpglory/grafana-data-exporter-app/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);
}
},
[onSuccessfulSetup]
);
return (
<Form<FormProps> defaultValues={{ dataExporterApiUrl: defaultDataExporterApiUrl }} onSubmit={setupPlugin}>
{({ register, errors }) => (
<>
<div className={cx('info-block')}>
<p>1. Launch the DataExporter backend</p>
<Text type="secondary">
Run hobby, dev or production backend. See{' '}
<a href="https://code.corpglory.net/corpglory/grafana-data-exporter" target="_blank" rel="noreferrer">
<Text type="link">here</Text>
</a>{' '}
on how to get started.
</Text>
</div>
<div className={cx('info-block')}>
<p>2. Let us know the base URL of your DataExporter API</p>
<Text type="secondary">
The DataExporter 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="DataExporter backend URL" invalid={!!errors.dataExporterApiUrl} error="Must be a valid URL">
<Input
{...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;

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;

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

167
src/components/Text/Text.tsx

@ -0,0 +1,167 @@
import styles from './Text.module.scss';
import { openNotification } from '../../utils';
import { IconButton, Modal, Input, HorizontalGroup, Button, VerticalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import CopyToClipboard from 'react-copy-to-clipboard';
import React, { FC, HTMLAttributes, ChangeEvent, useState, useCallback } from 'react';
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 cx = cn.bind(styles);
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={cx('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={cx('icon-button')}
tooltip="Edit"
tooltipPlacement="top"
name="edit"
/>
)}
{copyable && (
<CopyToClipboard
text={children as string}
onCopy={() => {
openNotification('Text copied');
}}
>
<IconButton
variant="primary"
className={cx('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={cx('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/img/logo.svg

@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 81.9 71.52"><defs><style>.cls-1{fill:#84aff1;}.cls-2{fill:#3865ab;}.cls-3{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" x1="42.95" y1="16.88" x2="81.9" y2="16.88" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f2cc0c"/><stop offset="1" stop-color="#ff9830"/></linearGradient></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M55.46,62.43A2,2,0,0,1,54.07,59l4.72-4.54a2,2,0,0,1,2.2-.39l3.65,1.63,3.68-3.64a2,2,0,1,1,2.81,2.84l-4.64,4.6a2,2,0,0,1-2.22.41L60.6,58.26l-3.76,3.61A2,2,0,0,1,55.46,62.43Z"/><path class="cls-2" d="M37,0H2A2,2,0,0,0,0,2V31.76a2,2,0,0,0,2,2H37a2,2,0,0,0,2-2V2A2,2,0,0,0,37,0ZM4,29.76V8.84H35V29.76Z"/><path class="cls-3" d="M79.9,0H45a2,2,0,0,0-2,2V31.76a2,2,0,0,0,2,2h35a2,2,0,0,0,2-2V2A2,2,0,0,0,79.9,0ZM47,29.76V8.84h31V29.76Z"/><path class="cls-2" d="M37,37.76H2a2,2,0,0,0-2,2V69.52a2,2,0,0,0,2,2H37a2,2,0,0,0,2-2V39.76A2,2,0,0,0,37,37.76ZM4,67.52V46.6H35V67.52Z"/><path class="cls-2" d="M79.9,37.76H45a2,2,0,0,0-2,2V69.52a2,2,0,0,0,2,2h35a2,2,0,0,0,2-2V39.76A2,2,0,0,0,79.9,37.76ZM47,67.52V46.6h31V67.52Z"/><rect class="cls-1" x="10.48" y="56.95" width="4" height="5.79"/><rect class="cls-1" x="17.43" y="53.95" width="4" height="8.79"/><rect class="cls-1" x="24.47" y="50.95" width="4" height="11.79"/><path class="cls-1" d="M19.47,25.8a6.93,6.93,0,1,1,6.93-6.92A6.93,6.93,0,0,1,19.47,25.8Zm0-9.85a2.93,2.93,0,1,0,2.93,2.93A2.93,2.93,0,0,0,19.47,16Z"/></g></g></svg>
<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M41.1733 38.7634V42.4019C41.1733 42.7229 41.2816 42.9994 41.4982 43.2313C41.7148 43.4631 41.9856 43.5791 42.3105 43.5791C42.6354 43.5791 42.9152 43.4631 43.1498 43.2313C43.3845 42.9994 43.5018 42.7229 43.5018 42.4019V35.8204C43.5018 35.5351 43.4025 35.3032 43.204 35.1249C43.0054 34.9465 42.7798 34.8573 42.5271 34.8573H35.8123C35.4874 34.8573 35.2166 34.9732 35 35.2051C34.7834 35.437 34.6751 35.6956 34.6751 35.981C34.6751 36.302 34.7834 36.5785 35 36.8103C35.2166 37.0422 35.4874 37.1581 35.8123 37.1581H39.4946L34.4043 42.1879C34.1516 42.4376 34.0253 42.7229 34.0253 43.044C34.0253 43.365 34.1516 43.6326 34.4043 43.8466C34.6209 44.0606 34.8917 44.1677 35.2166 44.1677C35.5415 44.1677 35.8123 44.0606 36.0289 43.8466L41.1733 38.7634ZM39.0614 50C36.0289 50 33.4477 48.9477 31.3177 46.843C29.1877 44.7384 28.1227 42.2057 28.1227 39.2449C28.1227 36.2842 29.1877 33.7337 31.3177 31.5933C33.4477 29.453 36.0289 28.3829 39.0614 28.3829C42.0578 28.3829 44.63 29.453 46.778 31.5933C48.926 33.7337 50 36.2842 50 39.2449C50 42.2057 48.926 44.7384 46.778 46.843C44.63 48.9477 42.0578 50 39.0614 50ZM16.1011 17.7348H34.6751C35.2166 17.7348 35.6769 17.5476 36.056 17.173C36.435 16.7985 36.6245 16.3258 36.6245 15.7551C36.6245 15.22 36.435 14.7652 36.056 14.3906C35.6769 14.0161 35.2166 13.8288 34.6751 13.8288H16.1011C15.5235 13.8288 15.0451 14.0161 14.6661 14.3906C14.287 14.7652 14.0975 15.22 14.0975 15.7551C14.0975 16.3258 14.287 16.7985 14.6661 17.173C15.0451 17.5476 15.5235 17.7348 16.1011 17.7348ZM26.2274 45.1843H8.95307C7.83394 45.1843 6.89531 44.8097 6.13718 44.0606C5.37906 43.3115 5 42.3841 5 41.2782V8.90606C5 7.83591 5.37906 6.91736 6.13718 6.15042C6.89531 5.38347 7.83394 5 8.95307 5H41.769C42.8881 5 43.8267 5.38347 44.5848 6.15042C45.343 6.91736 45.722 7.83591 45.722 8.90606V26.9382C44.7112 26.3674 43.6372 25.9394 42.5 25.654C41.3628 25.3686 40.2166 25.2259 39.0614 25.2259C38.6643 25.2259 38.2581 25.2527 37.843 25.3062C37.4278 25.3597 37.0217 25.4221 36.6245 25.4935V24.9584C36.6245 24.459 36.417 24.0309 36.0018 23.6742C35.5866 23.3175 35.1444 23.1391 34.6751 23.1391H16.1011C15.5235 23.1391 15.0451 23.3353 14.6661 23.7277C14.287 24.1201 14.0975 24.5838 14.0975 25.1189C14.0975 25.654 14.287 26.1088 14.6661 26.4834C15.0451 26.8579 15.5235 27.0452 16.1011 27.0452H32.4007C31.1733 27.6516 30.0722 28.4185 29.0975 29.346C28.1227 30.2735 27.3285 31.3258 26.7148 32.503H16.1011C15.5235 32.503 15.0451 32.6902 14.6661 33.0648C14.287 33.4394 14.0975 33.8942 14.0975 34.4292C14.0975 35 14.287 35.4727 14.6661 35.8472C15.0451 36.2218 15.5235 36.409 16.1011 36.409H25.1986C25.0903 36.8728 25.009 37.3543 24.9549 37.8537C24.9007 38.3531 24.8736 38.8347 24.8736 39.2985C24.8736 40.3329 24.982 41.3496 25.1986 42.3484C25.4152 43.3472 25.7581 44.2925 26.2274 45.1843Z" fill="#7191ED"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

6
src/index.d.ts vendored

@ -0,0 +1,6 @@
declare module '*.css';
declare module '*.scss' {
const content: Record<string, string>;
export default content;
}

8
src/module.ts

@ -1,12 +1,12 @@
import { PluginConfigPage } from './components/PluginConfigPage';
import { AppPlugin } from '@grafana/data';
import { App } from './components/App';
import { AppConfig } from './components/AppConfig';
export const plugin = new AppPlugin<{}>().setRootPage(App).addConfigPage({
export const plugin = new AppPlugin<{}>().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',
});

11
src/plugin.json

@ -17,14 +17,11 @@
"version": "%VERSION%",
"updated": "%TODAY%"
},
"includes": [
"routes": [
{
"type": "page",
"name": "Default",
"path": "/a/%PLUGIN_ID%",
"role": "Admin",
"addToNav": true,
"defaultNav": true
"path": "api/*",
"method": "*",
"url": "{{ .JsonData.dataExporterApiUrl }}/api/"
}
],
"dependencies": {

200
src/plugin_state.ts

@ -0,0 +1,200 @@
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;
};
export type PluginConnectedStatusResponse = {
version: string;
};
class PluginState {
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 <RT>(): Promise<InstallPluginResponse<RT>> => {
// TODO: try to disable success alerts from Grafana API
const { key: grafanaToken } = await this.createGrafanaToken();
await this.updateGrafanaPluginSettings({ secureJsonData: { grafanaToken } });
const dataExporterAPIResponse = await makeRequest<RT>(`/connect`, {
method: 'POST',
});
return { grafanaToken, dataExporterAPIResponse };
};
static installPlugin = async (dataExporterApiUrl: string): Promise<string | null> => {
let pluginInstallationDataExporterResponse: InstallPluginResponse<{ version: 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<{ version: string }>();
} 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<PluginConnectedStatusResponse | string> => {
try {
const resp = await makeRequest<PluginConnectedStatusResponse>(`/status`, {
method: 'GET',
});
// TODO: check if the server version is compatible with the plugin
if (resp.version) {
return resp;
} else {
throw new Error(`Something is working at ${dataExporterApiUrl} but it's not DataExporter backend`);
}
} 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;

41
src/services/network_service.ts

@ -0,0 +1,41 @@
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';
export const API_PATH_PREFIX = '/api';
const instance = axios.create();
instance.interceptors.request.use(function (config) {
config.validateStatus = (status) => {
return status >= 200 && status < 300; // default
};
return {
...config,
};
});
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}${API_PATH_PREFIX}${path}`;
const response = await instance({
method,
url,
params,
data,
validateStatus,
});
return response.data as RT;
};

45
src/style/global.css

@ -0,0 +1,45 @@
.configure-plugin {
margin-top: 10px;
}
@keyframes fadeIn {
from {
opacity: 0;
}
}
/* Spinner */
.spin {
width: 100%;
margin-top: 200px;
margin-bottom: 200px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.spin-text {
margin-top: 20px;
}
/* Tables */
.disabled-row {
background: #f0f0f0;
}
.highlighted-row {
background: var(--highlighted-row-bg);
}
/* Navigation */
.navbarRootFallback {
margin-top: 24px;
}
.page-title {
margin-bottom: 16px;
}

78
src/style/vars.css

@ -0,0 +1,78 @@
:root {
--maintenance-background: repeating-linear-gradient(45deg, #f6ba52, #f6ba52 20px, #ffd180 20px, #ffd180 40px);
--gren-5: #6ccf8e;
--green-6: #73d13d;
--red-5: #ff4d4f;
--orange-5: #ffa940;
--blue-2: #bae7ff;
--gray-5: #d9d9d9;
--gray-8: #595959;
--gray-9: #434343;
--cyan-1: #e6fffb;
--purple-9: #22075e;
--border-radius: 2px;
--gradient-brandHorizontal: linear-gradient(90deg, #f83 0%, #f53e4c 100%);
--gradient-brandVertical: linear-gradient(0.01deg, #f53e4c -31.2%, #f83 113.07%);
--always-gray: #ccccdc;
--title-marginBottom: 16px;
}
.theme-light {
--cards-background: var(--blue-2);
--highlighted-row-bg: var(--cyan-1);
--disabled-button-color: #bdbdbd;
--primary-background: rgb(255, 255, 255);
--secondary-background: rgb(244, 245, 245);
--border: 1px solid rgba(36, 41, 46, 0.12);
--primary-text-color: rgb(36, 41, 46);
--secondary-text-color: rgba(36, 41, 46, 0.75);
--disabled-text-color: rgba(36, 41, 46, 0.5);
--warning-text-color: #8a6c00;
--success-text-color: rgb(10, 118, 78);
--error-text-color: rgb(207, 14, 91);
--primary-text-link: #1f62e0;
--timeline-icon-background: rgba(70, 76, 84, 0);
--timeline-icon-background-resolution-note: rgba(50, 116, 217, 0);
--oncall-icon-stroke-color: #fff;
--hover-selected: #f4f5f5;
--background-canvas: #f4f5f5;
--background-primary: #fff;
--background-secondary: #f4f5f5;
--border-medium: 1px solid rgba(36, 41, 46, 0.3);
--border-strong: 1px solid rgba(36, 41, 46, 0.4);
--border-weak: 1px solid rgba(36, 41, 46, 0.12);
--shadows-z1: 0 1px 2px rgba(24, 26, 27, 0.2);
--shadows-z2: 0 4px 8px rgba(24, 26, 27, 0.2);
--shadows-z3: 0 13px 20px 1px rgba(24, 26, 27, 0.18);
}
.theme-dark {
--cards-background: var(--gray-9);
--highlighted-row-bg: var(--gray-9);
--disabled-button-color: hsla(0, 0%, 100%, 0.08);
--primary-background: rgb(24, 27, 31);
--secondary-background: rgb(34, 37, 43);
--border: 1px solid rgba(204, 204, 220, 0.15);
--primary-text-color: rgb(204, 204, 220);
--secondary-text-color: rgba(204, 204, 220, 0.65);
--disabled-text-color: rgba(204, 204, 220, 0.4);
--warning-text-color: #f8d06b;
--success-text-color: rgb(108, 207, 142);
--error-text-color: rgb(255, 82, 134);
--primary-text-link: #6e9fff;
--timeline-icon-background: rgba(70, 76, 84, 1);
--timeline-icon-background-resolution-note: rgba(50, 116, 217, 1);
--focused-box-shadow: rgb(17 18 23) 0 0 0 2px, rgb(61 113 217) 0 0 0 4px;
--hover-selected: rgba(204, 204, 220, 0.12);
--hover-selected-hardcoded: #34363d;
--oncall-icon-stroke-color: #181b1f;
--background-canvas: #111217;
--background-primary: #181b1f;
--background-secondary: #22252b;
--border-medium: 1px solid rgba(204, 204, 220, 0.15);
--border-strong: 1px solid rgba(204, 204, 220, 0.25);
--border-weak: 1px solid rgba(204, 204, 220, 0.07);
--shadows-z1: 0 1px 2px rgba(24, 26, 27, 0.75);
--shadows-z2: 0 4px 8px rgba(24, 26, 27, 0.75);
--shadows-z3: 0 8px 24px rgb(1, 4, 9);
}

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

147
webpack.config.ts

@ -0,0 +1,147 @@
const webpack = require('webpack');
const path = require('path');
const CircularDependencyPlugin = require('circular-dependency-plugin');
const MONACO_DIR = path.resolve(__dirname, './node_modules/monaco-editor');
Object.defineProperty(RegExp.prototype, 'toJSON', {
value: RegExp.prototype.toString,
});
module.exports.getWebpackConfig = (config, options) => {
const cssLoader = config.module.rules.find((rule) => rule.test.toString() === '/\\.css$/');
cssLoader.exclude.push(/\.module\.css$/, MONACO_DIR);
const grafanaRules = config.module.rules.filter((a) => a.test.toString() !== /\.s[ac]ss$/.toString());
const newConfig = {
...config,
module: {
...config.module,
rules: [
...grafanaRules,
{
test: /\.(ts|tsx)$/,
exclude: /node_modules/,
use: [
{
loader: 'babel-loader',
options: {
cacheDirectory: true,
cacheCompression: false,
presets: [
[
'@babel/preset-env',
{
modules: false,
},
],
[
'@babel/preset-typescript',
{
allowNamespaces: true,
allowDeclareFields: true,
},
],
['@babel/preset-react'],
],
plugins: [
[
'@babel/plugin-transform-typescript',
{
allowNamespaces: true,
allowDeclareFields: true,
},
],
'@babel/plugin-proposal-class-properties',
[
'@babel/plugin-proposal-object-rest-spread',
{
loose: true,
},
],
[
'@babel/plugin-proposal-decorators',
{
legacy: true,
},
],
'@babel/plugin-transform-react-constant-elements',
'@babel/plugin-proposal-nullish-coalescing-operator',
'@babel/plugin-proposal-optional-chaining',
'@babel/plugin-syntax-dynamic-import',
],
},
},
'ts-loader',
],
},
{
test: /\.module\.css$/,
exclude: /node_modules/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
importLoaders: 1,
sourceMap: true,
modules: {
localIdentName: options.production ? '[name]__[hash:base64]' : '[path][name]__[local]',
},
},
},
],
},
{
test: /\.module\.scss$/i,
exclude: /node_modules/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
importLoaders: 1,
sourceMap: true,
modules: {
localIdentName: options.production ? '[name]__[hash:base64]' : '[path][name]__[local]',
},
},
},
'postcss-loader',
'sass-loader',
],
},
],
},
plugins: [
...config.plugins,
new CircularDependencyPlugin({
// exclude detection of files based on a RegExp
exclude: /node_modules/,
// include specific files based on a RegExp
// add errors to webpack instead of warnings
failOnError: true,
// allow import cycles that include an asyncronous import,
// e.g. via import(/* webpackMode: "weak" */ './file.js')
allowAsyncCycles: false,
// set the current working directory for displaying module paths
cwd: process.cwd(),
}),
],
resolve: {
...config.resolve,
symlinks: false,
modules: [path.resolve(__dirname, './frontend_enterprise/src'), ...config.resolve.modules],
},
};
return newConfig;
};

4193
yarn.lock

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