mirror of
https://github.com/harness/drone.git
synced 2025-05-31 11:43:15 +00:00
561 lines
21 KiB
TypeScript
561 lines
21 KiB
TypeScript
import React, { useCallback, useEffect, useState } from 'react'
|
|
import { Formik } from 'formik'
|
|
import { parse } from 'yaml'
|
|
import { capitalize, get, omit, set } from 'lodash-es'
|
|
import { Classes, PopoverInteractionKind, PopoverPosition } from '@blueprintjs/core'
|
|
import type { TypesPlugin } from 'services/code'
|
|
import { Color, FontVariation } from '@harnessio/design-system'
|
|
import { Icon, type IconName } from '@harnessio/icons'
|
|
import {
|
|
Accordion,
|
|
Button,
|
|
ButtonVariation,
|
|
Container,
|
|
ExpandingSearchInput,
|
|
FormInput,
|
|
FormikForm,
|
|
Layout,
|
|
Popover,
|
|
Text
|
|
} from '@harnessio/uicore'
|
|
import { useStrings } from 'framework/strings'
|
|
|
|
import css from './PluginsPanel.module.scss'
|
|
|
|
enum PluginCategory {
|
|
Harness,
|
|
Drone
|
|
}
|
|
|
|
enum PluginPanelView {
|
|
Category,
|
|
Listing,
|
|
Configuration
|
|
}
|
|
|
|
interface PluginInput {
|
|
type: 'string'
|
|
description?: string
|
|
default?: string
|
|
options?: { isExtended?: boolean }
|
|
}
|
|
|
|
interface PluginCategoryInterface {
|
|
category: PluginCategory
|
|
name: string
|
|
description: string
|
|
icon: IconName
|
|
}
|
|
|
|
const RunStep: TypesPlugin = {
|
|
uid: 'run',
|
|
description: 'Run a script',
|
|
spec: '{"kind":"run","type":"step","name":"Run","spec":{"name":"run","description":"Run a script","inputs":{"image":{"type":"string","description":"Container image","required":true},"script":{"type":"string","description":"Script to execute","required":true,"options":{"isExtended":true}}}}}'
|
|
}
|
|
|
|
interface PluginInsertionTemplateInterface {
|
|
name?: string
|
|
type: 'plugin'
|
|
spec: {
|
|
name: string
|
|
inputs: { [key: string]: string }
|
|
}
|
|
}
|
|
|
|
const PluginInsertionTemplate: PluginInsertionTemplateInterface = {
|
|
name: '<step-name>',
|
|
type: 'plugin',
|
|
spec: {
|
|
name: '<plugin-uid-from-database>',
|
|
inputs: {
|
|
'<param1>': '<value1>',
|
|
'<param2>': '<value2>'
|
|
}
|
|
}
|
|
}
|
|
|
|
const PluginNameFieldPath = 'spec.name'
|
|
const PluginInputsFieldPath = 'spec.inputs'
|
|
|
|
const LIST_FETCHING_LIMIT = 100
|
|
|
|
export interface PluginsPanelInterface {
|
|
onPluginAddUpdate: (isUpdate: boolean, pluginFormData: Record<string, any>) => void
|
|
}
|
|
|
|
export const PluginsPanel = ({ onPluginAddUpdate }: PluginsPanelInterface): JSX.Element => {
|
|
const { getString } = useStrings()
|
|
const [category, setCategory] = useState<PluginCategory>()
|
|
const [panelView, setPanelView] = useState<PluginPanelView>(PluginPanelView.Category)
|
|
const [plugin, setPlugin] = useState<TypesPlugin>()
|
|
const [plugins, setPlugins] = useState<TypesPlugin[]>([])
|
|
const [query, setQuery] = useState<string>('')
|
|
const [loading, setLoading] = useState<boolean>(false)
|
|
|
|
const PluginCategories: PluginCategoryInterface[] = [
|
|
{
|
|
category: PluginCategory.Harness,
|
|
name: 'Run',
|
|
description: getString('pluginsPanel.run.helptext'),
|
|
icon: 'run-step'
|
|
},
|
|
{
|
|
category: PluginCategory.Drone,
|
|
name: 'Plugin',
|
|
description: getString('pluginsPanel.plugins.helptext'),
|
|
icon: 'ci-infra'
|
|
}
|
|
]
|
|
|
|
const fetchAllPlugins = useCallback((): void => {
|
|
try {
|
|
setLoading(true)
|
|
let allPlugins: TypesPlugin[] = []
|
|
fetch(`/api/v1/plugins?page=${1}&limit=${LIST_FETCHING_LIMIT}`)
|
|
.then(async response => {
|
|
const plugins = await response.json()
|
|
allPlugins = [...plugins]
|
|
fetch(`/api/v1/plugins?page=${2}&limit=${LIST_FETCHING_LIMIT}`).then(async response => {
|
|
const plugins = await response.json()
|
|
setPlugins([...allPlugins, ...plugins])
|
|
})
|
|
})
|
|
.catch(_err => {
|
|
/* ignore error */
|
|
})
|
|
.catch(_err => {
|
|
/* ignore error */
|
|
})
|
|
setLoading(false)
|
|
} catch (ex) {
|
|
/* ignore exception */
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (category === PluginCategory.Drone) {
|
|
fetchAllPlugins()
|
|
}
|
|
}, [category])
|
|
|
|
useEffect(() => {
|
|
if (panelView === PluginPanelView.Listing) {
|
|
if (query) {
|
|
setPlugins(existingPlugins => existingPlugins.filter((item: TypesPlugin) => item.uid?.includes(query)))
|
|
} else {
|
|
fetchAllPlugins()
|
|
}
|
|
}
|
|
}, [query])
|
|
|
|
const renderPluginCategories = (): JSX.Element => {
|
|
return (
|
|
<>
|
|
{PluginCategories.map((item: PluginCategoryInterface) => {
|
|
const { name, category: pluginCategory, description, icon } = item
|
|
return (
|
|
<Layout.Horizontal
|
|
onClick={() => {
|
|
setCategory(pluginCategory)
|
|
if (pluginCategory === PluginCategory.Drone) {
|
|
setPanelView(PluginPanelView.Listing)
|
|
} else if (pluginCategory === PluginCategory.Harness) {
|
|
setPlugin(RunStep)
|
|
setPanelView(PluginPanelView.Configuration)
|
|
}
|
|
}}
|
|
key={pluginCategory}
|
|
padding={{ left: 'medium', right: 'medium', top: 'medium', bottom: 'medium' }}
|
|
flex={{ justifyContent: 'flex-start' }}
|
|
className={css.plugin}>
|
|
<Container padding="small" className={css.pluginIcon}>
|
|
<Icon name={icon} />
|
|
</Container>
|
|
<Layout.Vertical padding={{ left: 'small' }}>
|
|
<Text color={Color.PRIMARY_7} font={{ variation: FontVariation.BODY2 }}>
|
|
{name}
|
|
</Text>
|
|
<Text font={{ variation: FontVariation.SMALL }}>{description}</Text>
|
|
</Layout.Vertical>
|
|
</Layout.Horizontal>
|
|
)
|
|
})}
|
|
</>
|
|
)
|
|
}
|
|
|
|
const renderPlugins = useCallback((): JSX.Element => {
|
|
return loading ? (
|
|
<Container flex={{ justifyContent: 'center' }} padding="large">
|
|
<Icon name="steps-spinner" color={Color.PRIMARY_7} size={25} />
|
|
</Container>
|
|
) : (
|
|
<Layout.Vertical spacing="small" padding={{ top: 'small' }}>
|
|
<Layout.Horizontal flex={{ justifyContent: 'space-between' }} padding={{ left: 'small', right: 'xlarge' }}>
|
|
<Layout.Horizontal
|
|
flex={{ justifyContent: 'flex-start', alignItems: 'center' }}
|
|
spacing="small"
|
|
padding={{ top: 'medium', bottom: 'medium', left: 'medium' }}>
|
|
<Icon
|
|
name="arrow-left"
|
|
size={18}
|
|
onClick={() => {
|
|
setPanelView(PluginPanelView.Category)
|
|
}}
|
|
className={css.arrow}
|
|
/>
|
|
<Text font={{ variation: FontVariation.H5 }}>{getString('plugins.select')}</Text>
|
|
</Layout.Horizontal>
|
|
<ExpandingSearchInput
|
|
autoFocus={true}
|
|
alwaysExpanded={true}
|
|
defaultValue={query}
|
|
onChange={setQuery}
|
|
className={css.search}
|
|
/>
|
|
</Layout.Horizontal>
|
|
<Container className={css.plugins}>
|
|
{plugins?.map((pluginItem: TypesPlugin) => {
|
|
const { uid, description } = pluginItem
|
|
return (
|
|
<Layout.Horizontal
|
|
flex={{ justifyContent: 'flex-start' }}
|
|
padding={{ left: 'large', top: 'medium', bottom: 'medium', right: 'large' }}
|
|
className={css.plugin}
|
|
onClick={() => {
|
|
setPanelView(PluginPanelView.Configuration)
|
|
setPlugin(pluginItem)
|
|
}}
|
|
key={uid}>
|
|
<Icon name={'gear'} size={25} />
|
|
<Layout.Vertical padding={{ left: 'small' }}>
|
|
<Text font={{ variation: FontVariation.BODY2 }} color={Color.PRIMARY_7}>
|
|
{uid}
|
|
</Text>
|
|
<Text font={{ variation: FontVariation.SMALL }} className={css.pluginDesc}>
|
|
{description}
|
|
</Text>
|
|
</Layout.Vertical>
|
|
</Layout.Horizontal>
|
|
)
|
|
})}
|
|
</Container>
|
|
</Layout.Vertical>
|
|
)
|
|
}, [loading, plugins, query])
|
|
|
|
const generateFriendlyName = useCallback((pluginName: string): string => {
|
|
return capitalize(pluginName.split('_').join(' '))
|
|
}, [])
|
|
|
|
const generateLabelForPluginField = useCallback(
|
|
({ name, properties }: { name: string; properties: PluginInput }): JSX.Element | string => {
|
|
const { description } = properties
|
|
return (
|
|
<Layout.Horizontal spacing="small" flex={{ alignItems: 'center', justifyContent: 'flex-start' }}>
|
|
{name && <Text font={{ variation: FontVariation.FORM_LABEL }}>{generateFriendlyName(name)}</Text>}
|
|
{description && (
|
|
<Popover
|
|
interactionKind={PopoverInteractionKind.HOVER}
|
|
boundary="viewport"
|
|
position={PopoverPosition.RIGHT}
|
|
popoverClassName={Classes.DARK}
|
|
content={
|
|
<Container padding="medium">
|
|
<Text font={{ variation: FontVariation.SMALL }} color={Color.WHITE}>
|
|
{description}
|
|
</Text>
|
|
</Container>
|
|
}>
|
|
<Icon name="info" color={Color.PRIMARY_7} size={10} padding={{ bottom: 'small' }} />
|
|
</Popover>
|
|
)}
|
|
</Layout.Horizontal>
|
|
)
|
|
},
|
|
[]
|
|
)
|
|
|
|
const renderPluginFormField = ({ name, properties }: { name: string; properties: PluginInput }): JSX.Element => {
|
|
const { type, options } = properties
|
|
const { isExtended } = options || {}
|
|
const WrapperComponent = isExtended ? FormInput.TextArea : FormInput.Text
|
|
return type === 'string' ? (
|
|
<WrapperComponent
|
|
name={name}
|
|
label={generateLabelForPluginField({ name, properties })}
|
|
style={{ width: '100%' }}
|
|
key={name}
|
|
/>
|
|
) : (
|
|
<></>
|
|
)
|
|
}
|
|
|
|
const constructPayloadForYAMLInsertion = (
|
|
pluginFormData: Record<string, any>,
|
|
pluginMetadata?: TypesPlugin
|
|
): Record<string, any> => {
|
|
const { name } = pluginFormData
|
|
switch (category) {
|
|
case PluginCategory.Drone:
|
|
let payload = { ...PluginInsertionTemplate }
|
|
/* Step name is optional, set only if specified by user */
|
|
if (name) {
|
|
set(payload, 'name', name)
|
|
} else {
|
|
payload = omit(payload, 'name')
|
|
}
|
|
set(payload, PluginNameFieldPath, pluginMetadata?.uid)
|
|
set(payload, PluginInputsFieldPath, omit(pluginFormData, 'name'))
|
|
return payload as PluginInsertionTemplateInterface
|
|
case PluginCategory.Harness:
|
|
return {
|
|
...(name && { name }),
|
|
type: 'run',
|
|
spec: pluginFormData
|
|
}
|
|
default:
|
|
return {}
|
|
}
|
|
}
|
|
|
|
const insertNameFieldToPluginInputs = (existingInputs: {
|
|
[key: string]: PluginInput
|
|
}): { [key: string]: PluginInput } => {
|
|
const inputsClone = Object.assign(
|
|
{
|
|
name: {
|
|
type: 'string',
|
|
description: 'Name of the step'
|
|
}
|
|
},
|
|
existingInputs
|
|
)
|
|
return inputsClone
|
|
}
|
|
|
|
const getPluginInputsFromSpec = useCallback((pluginSpec: string): Record<string, any> => {
|
|
if (!pluginSpec) {
|
|
return {}
|
|
}
|
|
try {
|
|
const pluginSpecAsObj = parse(pluginSpec)
|
|
return get(pluginSpecAsObj, 'spec.inputs', {})
|
|
} catch (ex) {}
|
|
return {}
|
|
}, [])
|
|
|
|
const renderPluginConfigForm = useCallback((): JSX.Element => {
|
|
const pluginInputs = getPluginInputsFromSpec(get(plugin, 'spec', '') as string)
|
|
if (category === PluginCategory.Drone && Object.keys(pluginInputs).length === 0) {
|
|
return <></>
|
|
}
|
|
const allPluginInputs = insertNameFieldToPluginInputs(pluginInputs)
|
|
return (
|
|
<Layout.Vertical spacing="large" className={css.configForm}>
|
|
<Layout.Horizontal spacing="small" flex={{ justifyContent: 'flex-start' }}>
|
|
<Icon
|
|
name="arrow-left"
|
|
size={18}
|
|
onClick={() => {
|
|
setPlugin(undefined)
|
|
if (category === PluginCategory.Drone) {
|
|
setPanelView(PluginPanelView.Listing)
|
|
} else if (category === PluginCategory.Harness) {
|
|
setPanelView(PluginPanelView.Category)
|
|
}
|
|
}}
|
|
className={css.arrow}
|
|
/>
|
|
{plugin?.uid && (
|
|
<Text font={{ variation: FontVariation.H5 }}>
|
|
{getString('addLabel')} {plugin.uid} {getString('plugins.stepLabel')}
|
|
</Text>
|
|
)}
|
|
</Layout.Horizontal>
|
|
<Container className={css.form}>
|
|
<Formik
|
|
initialValues={{}}
|
|
onSubmit={formData => {
|
|
onPluginAddUpdate?.(false, constructPayloadForYAMLInsertion(formData, plugin))
|
|
}}>
|
|
<FormikForm height="100%" flex={{ justifyContent: 'space-between', alignItems: 'baseline' }}>
|
|
<Layout.Vertical flex={{ alignItems: 'flex-start' }} height="inherit" spacing="medium">
|
|
<Layout.Vertical
|
|
width="100%"
|
|
className={css.formFields}
|
|
spacing="xsmall"
|
|
flex={{ justifyContent: 'space-between' }}>
|
|
{category === PluginCategory.Harness ? (
|
|
<Layout.Vertical width="inherit">
|
|
<FormInput.TextArea
|
|
name={'script'}
|
|
label={getString('pluginsPanel.run.script')}
|
|
style={{ width: '100%' }}
|
|
key={'script'}
|
|
/>
|
|
<FormInput.Select
|
|
name={'shell'}
|
|
label={getString('pluginsPanel.run.shell')}
|
|
style={{ width: '100%' }}
|
|
key={'shell'}
|
|
items={[
|
|
{ label: getString('pluginsPanel.run.sh'), value: 'sh' },
|
|
{ label: getString('pluginsPanel.run.bash'), value: 'bash' },
|
|
{ label: getString('pluginsPanel.run.powershell'), value: 'powershell' },
|
|
{ label: getString('pluginsPanel.run.pwsh'), value: 'pwsh' }
|
|
]}
|
|
/>
|
|
<Accordion activeId="container">
|
|
<Accordion.Panel
|
|
id="container"
|
|
summary="Container"
|
|
details={
|
|
<Layout.Vertical className={css.indent}>
|
|
<FormInput.Text
|
|
name={'container.image'}
|
|
label={getString('pluginsPanel.run.image')}
|
|
style={{ width: '100%' }}
|
|
key={'container.image'}
|
|
/>
|
|
<Accordion activeId="container.credentials">
|
|
<Accordion.Panel
|
|
id="container.credentials"
|
|
summary={getString('pluginsPanel.run.credentials')}
|
|
details={
|
|
<Layout.Vertical className={css.indent}>
|
|
<FormInput.Text
|
|
name={'container.credentials.username'}
|
|
label={getString('pluginsPanel.run.username')}
|
|
style={{ width: '100%' }}
|
|
key={'container.credentials.username'}
|
|
/>
|
|
<FormInput.Text
|
|
name={'container.credentials.password'}
|
|
label={getString('pluginsPanel.run.password')}
|
|
style={{ width: '100%' }}
|
|
key={'container.credentials.password'}
|
|
/>
|
|
</Layout.Vertical>
|
|
}
|
|
/>
|
|
</Accordion>
|
|
<FormInput.Text
|
|
name={'container.pull'}
|
|
label={getString('pluginsPanel.run.pull')}
|
|
style={{ width: '100%' }}
|
|
key={'container.pull'}
|
|
/>
|
|
<FormInput.Text
|
|
name={'container.entrypoint'}
|
|
label={getString('pluginsPanel.run.entrypoint')}
|
|
style={{ width: '100%' }}
|
|
key={'container.entrypoint'}
|
|
/>
|
|
<FormInput.Text
|
|
name={'container.network'}
|
|
label={getString('pluginsPanel.run.network')}
|
|
style={{ width: '100%' }}
|
|
key={'container.network'}
|
|
/>
|
|
<FormInput.Text
|
|
name={'container.networkMode'}
|
|
label={getString('pluginsPanel.run.networkMode')}
|
|
style={{ width: '100%' }}
|
|
key={'container.networkMode'}
|
|
/>
|
|
<FormInput.RadioGroup
|
|
name={'container.privileged'}
|
|
label={getString('pluginsPanel.run.privileged')}
|
|
style={{ width: '100%' }}
|
|
key={'container.privileged'}
|
|
items={[
|
|
{ label: 'Yes', value: 'true' },
|
|
{ label: 'No', value: 'false' }
|
|
]}
|
|
/>
|
|
<FormInput.Toggle
|
|
name={'container.privileged'}
|
|
label={getString('pluginsPanel.run.privileged')}
|
|
style={{ width: '100%' }}
|
|
key={'container.privileged'}
|
|
/>
|
|
<FormInput.Text
|
|
name={'container.user'}
|
|
label={getString('user')}
|
|
style={{ width: '100%' }}
|
|
key={'container.user'}
|
|
/>
|
|
</Layout.Vertical>
|
|
}
|
|
/>
|
|
<Accordion.Panel
|
|
id="mount"
|
|
summary="Mount"
|
|
details={
|
|
<Layout.Vertical className={css.indent}>
|
|
<FormInput.Text
|
|
name={'mount.name'}
|
|
label={getString('name')}
|
|
style={{ width: '100%' }}
|
|
key={'mount.name'}
|
|
/>
|
|
<FormInput.Text
|
|
name={'mount.path'}
|
|
label={getString('pluginsPanel.run.path')}
|
|
style={{ width: '100%' }}
|
|
key={'mount.path'}
|
|
/>
|
|
</Layout.Vertical>
|
|
}
|
|
/>
|
|
</Accordion>
|
|
</Layout.Vertical>
|
|
) : (
|
|
<Layout.Vertical width="inherit">
|
|
{Object.keys(allPluginInputs).map((field: string) => {
|
|
return renderPluginFormField({ name: field, properties: get(allPluginInputs, field) })
|
|
})}
|
|
</Layout.Vertical>
|
|
)}
|
|
</Layout.Vertical>
|
|
<Container margin={{ top: 'small', bottom: 'small' }}>
|
|
<Button variation={ButtonVariation.PRIMARY} text={getString('addLabel')} type="submit" />
|
|
</Container>
|
|
</Layout.Vertical>
|
|
</FormikForm>
|
|
</Formik>
|
|
</Container>
|
|
</Layout.Vertical>
|
|
)
|
|
}, [plugin, category])
|
|
|
|
const renderPluginsPanel = useCallback((): JSX.Element => {
|
|
switch (panelView) {
|
|
case PluginPanelView.Category:
|
|
return renderPluginCategories()
|
|
case PluginPanelView.Listing:
|
|
return renderPlugins()
|
|
case PluginPanelView.Configuration:
|
|
return renderPluginConfigForm()
|
|
default:
|
|
return <></>
|
|
}
|
|
}, [loading, plugins, panelView, category])
|
|
|
|
return (
|
|
<Layout.Vertical height="100%">
|
|
{panelView === PluginPanelView.Category ? (
|
|
<Container padding={{ top: 'medium', bottom: 'medium', left: 'medium' }}>
|
|
<Text font={{ variation: FontVariation.H5 }}>{getString('step.select')}</Text>
|
|
</Container>
|
|
) : (
|
|
<></>
|
|
)}
|
|
{renderPluginsPanel()}
|
|
</Layout.Vertical>
|
|
)
|
|
}
|