Browse Source
In this PR, I've copied App Config page from Grafana OnCall App https://github.com/grafana/oncall Reviewed-on: #1pull/2/head
rozetko
2 years ago
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 |
||||
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 { 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', |
||||
}); |
||||
|
@ -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