36 changed files with 5198 additions and 761 deletions
@ -1,2 +0,0 @@ |
|||||||
export const SOURCE_DIR = 'src'; |
|
||||||
export const DIST_DIR = 'dist'; |
|
@ -1,9 +0,0 @@ |
|||||||
{ |
|
||||||
"compilerOptions": { |
|
||||||
"module": "commonjs", |
|
||||||
"target": "es5", |
|
||||||
"esModuleInterop": true |
|
||||||
}, |
|
||||||
"transpileOnly": true, |
|
||||||
"transpiler": "ts-node/transpilers/swc-experimental" |
|
||||||
} |
|
@ -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); |
|
||||||
}, {}); |
|
||||||
} |
|
@ -1,2 +1,5 @@ |
|||||||
node_modules |
node_modules |
||||||
dist |
dist |
||||||
|
.eslintcache |
||||||
|
yarn-error.log |
||||||
|
coverage |
||||||
|
@ -1,2 +0,0 @@ |
|||||||
// Jest setup provided by Grafana scaffolding
|
|
||||||
import './.config/jest-setup'; |
|
@ -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(); |
|
||||||
}); |
|
||||||
}); |
|
@ -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,51 +0,0 @@ |
|||||||
import React from 'react'; |
|
||||||
import { render, screen } from '@testing-library/react'; |
|
||||||
import { PluginType } from '@grafana/data'; |
|
||||||
import { AppConfig, AppConfigProps } from './AppConfig'; |
|
||||||
|
|
||||||
describe('Components/AppConfig', () => { |
|
||||||
let props: AppConfigProps; |
|
||||||
|
|
||||||
beforeEach(() => { |
|
||||||
jest.resetAllMocks(); |
|
||||||
|
|
||||||
props = { |
|
||||||
plugin: { |
|
||||||
meta: { |
|
||||||
id: 'sample-app', |
|
||||||
name: 'Sample App', |
|
||||||
type: PluginType.app, |
|
||||||
enabled: true, |
|
||||||
jsonData: {}, |
|
||||||
}, |
|
||||||
}, |
|
||||||
query: {}, |
|
||||||
} as unknown as AppConfigProps; |
|
||||||
}); |
|
||||||
|
|
||||||
test('renders without an error"', () => { |
|
||||||
render(<AppConfig plugin={props.plugin} query={props.query} />); |
|
||||||
|
|
||||||
expect(screen.queryByText(/Enable \/ Disable/i)).toBeInTheDocument(); |
|
||||||
}); |
|
||||||
|
|
||||||
test('renders an "Enable" button if the plugin is disabled', () => { |
|
||||||
const plugin = { meta: { ...props.plugin.meta, enabled: false } }; |
|
||||||
|
|
||||||
// @ts-ignore - We don't need to provide `addConfigPage()` and `setChannelSupport()` for these tests
|
|
||||||
render(<AppConfig plugin={plugin} query={props.query} />); |
|
||||||
|
|
||||||
expect(screen.queryByText(/The plugin is currently not enabled./i)).toBeInTheDocument(); |
|
||||||
expect(screen.queryByText(/The plugin is currently enabled./i)).not.toBeInTheDocument(); |
|
||||||
}); |
|
||||||
|
|
||||||
test('renders a "Disable" button if the plugin is enabled', () => { |
|
||||||
const plugin = { meta: { ...props.plugin.meta, enabled: true } }; |
|
||||||
|
|
||||||
// @ts-ignore - We don't need to provide `addConfigPage()` and `setChannelSupport()` for these tests
|
|
||||||
render(<AppConfig plugin={plugin} query={props.query} />); |
|
||||||
|
|
||||||
expect(screen.queryByText(/The plugin is currently enabled./i)).toBeInTheDocument(); |
|
||||||
expect(screen.queryByText(/The plugin is currently not enabled./i)).not.toBeInTheDocument(); |
|
||||||
}); |
|
||||||
}); |
|
@ -1,92 +0,0 @@ |
|||||||
import React from 'react'; |
|
||||||
import { Button, Legend, useStyles2 } from '@grafana/ui'; |
|
||||||
import { PluginConfigPageProps, AppPluginMeta, PluginMeta, GrafanaTheme2 } from '@grafana/data'; |
|
||||||
import { getBackendSrv } from '@grafana/runtime'; |
|
||||||
import { css } from '@emotion/css'; |
|
||||||
import { lastValueFrom } from 'rxjs'; |
|
||||||
|
|
||||||
export type AppPluginSettings = {}; |
|
||||||
|
|
||||||
export interface AppConfigProps extends PluginConfigPageProps<AppPluginMeta<AppPluginSettings>> {} |
|
||||||
|
|
||||||
export const AppConfig = ({ plugin }: AppConfigProps) => { |
|
||||||
const s = useStyles2(getStyles); |
|
||||||
const { enabled, jsonData } = plugin.meta; |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className="gf-form-group"> |
|
||||||
<div> |
|
||||||
{/* Enable the plugin */} |
|
||||||
<Legend>Enable / Disable</Legend> |
|
||||||
{!enabled && ( |
|
||||||
<> |
|
||||||
<div className={s.colorWeak}>The plugin is currently not enabled.</div> |
|
||||||
<Button |
|
||||||
className={s.marginTop} |
|
||||||
variant="primary" |
|
||||||
onClick={() => |
|
||||||
updatePluginAndReload(plugin.meta.id, { |
|
||||||
enabled: true, |
|
||||||
pinned: true, |
|
||||||
jsonData, |
|
||||||
}) |
|
||||||
} |
|
||||||
> |
|
||||||
Enable plugin |
|
||||||
</Button> |
|
||||||
</> |
|
||||||
)} |
|
||||||
|
|
||||||
{/* Disable the plugin */} |
|
||||||
{enabled && ( |
|
||||||
<> |
|
||||||
<div className={s.colorWeak}>The plugin is currently enabled.</div> |
|
||||||
<Button |
|
||||||
className={s.marginTop} |
|
||||||
variant="destructive" |
|
||||||
onClick={() => |
|
||||||
updatePluginAndReload(plugin.meta.id, { |
|
||||||
enabled: false, |
|
||||||
pinned: false, |
|
||||||
jsonData, |
|
||||||
}) |
|
||||||
} |
|
||||||
> |
|
||||||
Disable plugin |
|
||||||
</Button> |
|
||||||
</> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
); |
|
||||||
}; |
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => ({ |
|
||||||
colorWeak: css` |
|
||||||
color: ${theme.colors.text.secondary}; |
|
||||||
`,
|
|
||||||
marginTop: css` |
|
||||||
margin-top: ${theme.spacing(3)}; |
|
||||||
`,
|
|
||||||
}); |
|
||||||
|
|
||||||
const updatePluginAndReload = async (pluginId: string, data: Partial<PluginMeta>) => { |
|
||||||
try { |
|
||||||
await updatePlugin(pluginId, data); |
|
||||||
|
|
||||||
// Reloading the page as the changes made here wouldn't be propagated to the actual plugin otherwise.
|
|
||||||
// This is not ideal, however unfortunately currently there is no supported way for updating the plugin state.
|
|
||||||
window.location.reload(); |
|
||||||
} catch (e) { |
|
||||||
console.error('Error while updating the plugin', e); |
|
||||||
} |
|
||||||
}; |
|
||||||
|
|
||||||
export const updatePlugin = async (pluginId: string, data: Partial<PluginMeta>) => { |
|
||||||
const response = getBackendSrv().fetch({ |
|
||||||
url: `/api/plugins/${pluginId}/settings`, |
|
||||||
method: 'POST', |
|
||||||
data, |
|
||||||
}); |
|
||||||
return lastValueFrom(response); |
|
||||||
}; |
|
@ -0,0 +1,28 @@ |
|||||||
|
.root { |
||||||
|
padding: 16px; |
||||||
|
border-radius: 2px; |
||||||
|
|
||||||
|
&--withBackground { |
||||||
|
background: var(--secondary-background); |
||||||
|
} |
||||||
|
|
||||||
|
&--fullWidth { |
||||||
|
width: 100%; |
||||||
|
} |
||||||
|
|
||||||
|
&--hover:hover { |
||||||
|
background: var(--hover-selected); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
:global(.theme-dark) .root_bordered { |
||||||
|
border: var(--border-weak); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.theme-light) .root_bordered { |
||||||
|
border: var(--border-weak); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.theme-dark) .root_shadowed { |
||||||
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.6); |
||||||
|
} |
@ -0,0 +1,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; |
@ -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} |
||||||
|
</> |
||||||
|
); |
||||||
|
}; |
@ -0,0 +1,3 @@ |
|||||||
|
import '../../style/vars.css'; |
||||||
|
import '../../style/global.css'; |
||||||
|
export * from './PluginConfigPage'; |
@ -0,0 +1,4 @@ |
|||||||
|
.info-block { |
||||||
|
margin-bottom: 24px; |
||||||
|
margin-top: 24px; |
||||||
|
} |
@ -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; |
@ -0,0 +1,20 @@ |
|||||||
|
import React, { FC } from 'react'; |
||||||
|
|
||||||
|
import { Button } from '@grafana/ui'; |
||||||
|
|
||||||
|
import WithConfirm from '../../../WithConfirm/WithConfirm'; |
||||||
|
|
||||||
|
type Props = { |
||||||
|
disabled: boolean; |
||||||
|
onClick: () => void; |
||||||
|
}; |
||||||
|
|
||||||
|
const RemoveCurrentConfigurationButton: FC<Props> = ({ disabled, onClick }) => ( |
||||||
|
<WithConfirm title="Are you sure to delete the plugin configuration?" confirmText="Remove"> |
||||||
|
<Button variant="destructive" onClick={onClick} size="md" disabled={disabled}> |
||||||
|
Remove current configuration |
||||||
|
</Button> |
||||||
|
</WithConfirm> |
||||||
|
); |
||||||
|
|
||||||
|
export default RemoveCurrentConfigurationButton; |
@ -0,0 +1,15 @@ |
|||||||
|
import React, { FC } from 'react'; |
||||||
|
|
||||||
|
import Text from '../../../Text/Text'; |
||||||
|
|
||||||
|
type Props = { |
||||||
|
text: string; |
||||||
|
}; |
||||||
|
|
||||||
|
const StatusMessageBlock: FC<Props> = ({ text }) => ( |
||||||
|
<pre data-testid="status-message-block"> |
||||||
|
<Text>{text}</Text> |
||||||
|
</pre> |
||||||
|
); |
||||||
|
|
||||||
|
export default StatusMessageBlock; |
@ -0,0 +1,72 @@ |
|||||||
|
.root { |
||||||
|
display: inline; |
||||||
|
} |
||||||
|
|
||||||
|
.text { |
||||||
|
&--primary { |
||||||
|
color: var(--primary-text-color); |
||||||
|
} |
||||||
|
|
||||||
|
&--secondary { |
||||||
|
color: var(--secondary-text-color); |
||||||
|
} |
||||||
|
|
||||||
|
&--disabled { |
||||||
|
color: var(--disabled-text-color); |
||||||
|
} |
||||||
|
|
||||||
|
&--warning { |
||||||
|
color: var(--warning-text-color); |
||||||
|
} |
||||||
|
|
||||||
|
&--link { |
||||||
|
color: var(--primary-text-link); |
||||||
|
} |
||||||
|
|
||||||
|
&--success { |
||||||
|
color: var(--green-5); |
||||||
|
} |
||||||
|
|
||||||
|
&--strong { |
||||||
|
font-weight: bold; |
||||||
|
} |
||||||
|
|
||||||
|
&--underline { |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
|
|
||||||
|
&--small { |
||||||
|
font-size: 12px; |
||||||
|
} |
||||||
|
|
||||||
|
&--large { |
||||||
|
font-size: 20px; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.no-wrap { |
||||||
|
white-space: nowrap; |
||||||
|
} |
||||||
|
|
||||||
|
.keyboard { |
||||||
|
margin: 0 0.2em; |
||||||
|
padding: 0.15em 0.4em 0.1em; |
||||||
|
font-size: 90%; |
||||||
|
background: hsla(0, 0%, 58.8%, 0.06); |
||||||
|
border: solid hsla(0, 0%, 39.2%, 0.2); |
||||||
|
border-width: 1px 1px 2px; |
||||||
|
border-radius: 3px; |
||||||
|
} |
||||||
|
|
||||||
|
.title { |
||||||
|
margin: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.icon-button { |
||||||
|
margin-left: 4px; |
||||||
|
display: none; |
||||||
|
} |
||||||
|
|
||||||
|
.root:hover .icon-button { |
||||||
|
display: inline-block; |
||||||
|
} |
@ -0,0 +1,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; |
@ -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; |
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.8 KiB |
@ -0,0 +1,6 @@ |
|||||||
|
declare module '*.css'; |
||||||
|
|
||||||
|
declare module '*.scss' { |
||||||
|
const content: Record<string, string>; |
||||||
|
export default content; |
||||||
|
} |
@ -1,12 +1,12 @@ |
|||||||
|
import { PluginConfigPage } from './components/PluginConfigPage'; |
||||||
|
|
||||||
import { AppPlugin } from '@grafana/data'; |
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', |
title: 'Configuration', |
||||||
icon: 'fa fa-cog', |
icon: 'fa fa-cog', |
||||||
// @ts-ignore - Would expect a Class component, however works absolutely fine with a functional one
|
// @ts-ignore - Would expect a Class component, however works absolutely fine with a functional one
|
||||||
// Implementation: https://github.com/grafana/grafana/blob/fd44c01675e54973370969dfb9e78f173aff7910/public/app/features/plugins/PluginPage.tsx#L157
|
// Implementation: https://github.com/grafana/grafana/blob/fd44c01675e54973370969dfb9e78f173aff7910/public/app/features/plugins/PluginPage.tsx#L157
|
||||||
body: AppConfig, |
body: PluginConfigPage, |
||||||
id: 'configuration', |
id: 'configuration', |
||||||
}); |
}); |
||||||
|
@ -0,0 +1,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; |
@ -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; |
||||||
|
}; |
@ -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; |
||||||
|
} |
@ -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); |
||||||
|
} |
@ -0,0 +1,16 @@ |
|||||||
|
import { AppRootProps as BaseAppRootProps, AppPluginMeta, PluginConfigPageProps } from '@grafana/data'; |
||||||
|
|
||||||
|
export type DataExporterPluginMetaJSONData = { |
||||||
|
dataExporterApiUrl: string | null; |
||||||
|
}; |
||||||
|
|
||||||
|
export type DataExporterPluginMetaSecureJSONData = { |
||||||
|
grafanaToken: string | null; |
||||||
|
}; |
||||||
|
|
||||||
|
export type AppRootProps = BaseAppRootProps<DataExporterPluginMetaJSONData>; |
||||||
|
|
||||||
|
// NOTE: it is possible that plugin.meta.jsonData is null (ex. on first-ever setup)
|
||||||
|
// the typing on AppPluginMeta does not seem correct atm..
|
||||||
|
export type DataExporterAppPluginMeta = AppPluginMeta<DataExporterPluginMetaJSONData>; |
||||||
|
export type DataExporterPluginConfigPageProps = PluginConfigPageProps<DataExporterAppPluginMeta>; |
@ -0,0 +1,7 @@ |
|||||||
|
import { AppEvents } from '@grafana/data'; |
||||||
|
// @ts-ignore
|
||||||
|
import appEvents from 'grafana/app/core/app_events'; |
||||||
|
|
||||||
|
export function openNotification(message: React.ReactNode) { |
||||||
|
appEvents.emit(AppEvents.alertSuccess, [message]); |
||||||
|
} |
@ -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; |
||||||
|
}; |
Loading…
Reference in new issue