Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support mutlipe backup targets on UI #755

Draft
wants to merge 4 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/assets/images/read-only.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
92 changes: 45 additions & 47 deletions src/components/BackupLabelInput/BackupLabelInput.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,64 +37,62 @@ class BackupLabelInput extends React.Component {

render() {
const { getFieldDecorator, getFieldValue } = this.props.form
const formItemLayoutWithOutLabel = {
wrapperCol: {
xs: { span: 24, offset: 0 },
sm: { span: 16, offset: 4 },
},
const formItemLayout = {
labelCol: { span: 6 },
wrapperCol: { span: 18 },
}
getFieldDecorator('keys', { initialValue: [] })
const keys = getFieldValue('keys')
const formItems = keys.map((k, index) => (
<div style={{ display: 'flex', justifyContent: 'space-around', alignItems: 'start', height: '60px' }} key={index}>
<Form.Item
required={false}
key={`key${k}`}
style={{ marginBottom: 0 }}
>
{getFieldDecorator(`key[${k}]`, {
validateTrigger: ['onChange', 'onBlur'],
rules: [
{
required: true,
whitespace: true,
message: 'key is required',
},
],
})(<Input placeholder="Labels Key" style={{ marginRight: 8 }} />)}
</Form.Item>
<Form.Item
required={false}
key={`value${k}`}
style={{ marginBottom: 0 }}
>
{getFieldDecorator(`value[${k}]`, {
validateTrigger: ['onChange', 'onBlur'],
rules: [
{
required: true,
whitespace: true,
message: 'value is required',
},
],
})(<Input placeholder="Labels Value" style={{ marginRight: 8 }} />)}
</Form.Item>{keys.length > 0 ? (
<Icon
style={{ marginTop: '12px' }}
className="dynamic-delete-button"
type="minus-circle-o"
onClick={() => this.remove(k)}
/>) : null}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', height: 60, marginLeft: 60 }} key={index}>
<Form.Item
required={false}
key={`key${k}`}
style={{ marginBottom: 0 }}
>
{getFieldDecorator(`key[${k}]`, {
validateTrigger: ['onChange', 'onBlur'],
rules: [
{
required: true,
whitespace: true,
message: 'key is required',
},
],
})(<Input placeholder="Labels Key" style={{ marginRight: 8 }} />)}
</Form.Item>
<Form.Item
required={false}
key={`value${k}`}
style={{ marginBottom: 0 }}
>
{getFieldDecorator(`value[${k}]`, {
validateTrigger: ['onChange', 'onBlur'],
rules: [
{
required: true,
whitespace: true,
message: 'value is required',
},
],
})(<Input placeholder="Labels Value" style={{ marginRight: 8 }} />)}
</Form.Item>{keys.length > 0 ? (
<Icon
style={{ marginTop: '12px' }}
className="dynamic-delete-button"
type="minus-circle-o"
onClick={() => this.remove(k)}
/>) : null}
</div>
))
return (
<Form onSubmit={this.handleSubmit}>
{formItems}
<Form.Item {...formItemLayoutWithOutLabel}>
<Form.Item label="Labels" style={{ display: 'flex' }} {...formItemLayout}>
<Button type="dashed" onClick={this.add} style={{ width: '100%' }}>
<Icon type="plus" /> Add Labels for Backup
<Icon type="plus" /> Add Labels
</Button>
</Form.Item>
{formItems}
</Form>
)
}
Expand Down
87 changes: 66 additions & 21 deletions src/components/Filter/Filter.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,24 @@ import styles from './Filter.less'

const Option = Select.Option

const BOOLEAN_OPTIONS = { True: true, False: false }

class Filter extends React.Component {
constructor(props) {
super(props)
const { field = props.defaultField || 'name', value = '', stateValue = '', nodeRedundancyValue = '', engineImageUpgradableValue = '', scheduleValue = '', pvStatusValue = '', revisionCounterValue = '', isGroupValue = '', createdFromValue = '' } = queryString.parse(props.location.search)
const {
field = props.defaultField || 'name',
value = '', stateValue = '',
nodeRedundancyValue = '',
engineImageUpgradableValue = '',
scheduleValue = '',
pvStatusValue = '',
revisionCounterValue = '',
isGroupValue = '',
createdFromValue = '',
booleanValue = BOOLEAN_OPTIONS.True,
} = queryString.parse(props.location.search)

this.state = {
field,
stateValue,
Expand All @@ -22,6 +36,7 @@ class Filter extends React.Component {
createdFromValue,
isGroupValue,
keyword: value,
booleanValue,
}
}

Expand Down Expand Up @@ -64,6 +79,10 @@ class Filter extends React.Component {
this.setState({ ...this.state, isGroupValue })
}

handleBooleanValueChange = (booleanValue) => {
this.setState({ ...this.state, booleanValue })
}

handleCreatedFromValueChange = (createdFromValue) => {
this.setState({ ...this.state, createdFromValue })
}
Expand All @@ -73,8 +92,19 @@ class Filter extends React.Component {
}

render() {
const { field = this.props.defaultField || 'name', value = '', stateValue = '', nodeRedundancyValue = '', engineImageUpgradableValue = '', scheduleValue = '', pvStatusValue = '', revisionCounterValue = '', isGroup = '', createdFromValue = '' } = queryString.parse(this.props.location.search)

const { booleanFields = [] } = this.props
const {
field = this.props.defaultField || 'name',
value = '',
stateValue = '',
nodeRedundancyValue = '',
engineImageUpgradableValue = '',
scheduleValue = '',
pvStatusValue = '',
revisionCounterValue = '',
isGroup = '',
createdFromValue = '',
} = queryString.parse(this.props.location.search)
let defaultContent = {
field,
stateValue,
Expand Down Expand Up @@ -160,15 +190,29 @@ class Filter extends React.Component {
{this.props.revisionCounterOption.map(item => (<Option key={item.value} value={item.value}>{item.name}</Option>))}
</Select>)
} else if (this.state.field === 'sourceType' && this.props.createdFromOption) {
valueForm = (<Select key="sourceType"
style={{ width: '100%' }}
size="large"
allowClear
defaultValue={this.state.createdFromValue}
onChange={this.handleCreatedFromValueChange}
>
{this.props.createdFromOption.map(item => (<Option key={item.value} value={item.value}>{item.name}</Option>))}
</Select>)
valueForm = (
<Select key="sourceType"
style={{ width: '100%' }}
size="large"
allowClear
defaultValue={this.state.createdFromValue}
onChange={this.handleCreatedFromValueChange}
>
{this.props.createdFromOption.map(item => (<Option key={item.value} value={item.value}>{item.name}</Option>))}
</Select>
)
} else if (booleanFields.includes(this.state.field)) {
valueForm = (
<Select key="boolean"
style={{ width: '100%' }}
size="large"
allowClear
defaultValue={BOOLEAN_OPTIONS.True}
onChange={this.handleBooleanValueChange}
>
{Object.keys(BOOLEAN_OPTIONS).map(key => (<Option key={key} value={BOOLEAN_OPTIONS[key]}>{key}</Option>))}
</Select>
)
}

let content = ''
Expand All @@ -183,15 +227,15 @@ class Filter extends React.Component {

return (
<Form>
<Input.Group compact className={styles.filter}>
<Popover placement="topLeft" content={content} visible={popoverVisible}>
<Select size="large" defaultValue={this.state.field} className={styles.filterSelect} onChange={this.handleFieldChange}>
{this.props.fieldOption.map(item => (<Option key={item.value} value={item.value}>{item.name}</Option>))}
</Select>
</Popover>
{ valueForm }
<Button size="large" style={{ height: '36px' }} htmlType="submit" type="primary" onClick={this.handleSubmit}>Go</Button>
</Input.Group>
<Input.Group compact className={styles.filter}>
<Popover placement="topLeft" content={content} visible={popoverVisible}>
<Select size="large" defaultValue={this.state.field} className={styles.filterSelect} onChange={this.handleFieldChange}>
{this.props.fieldOption.map(item => (<Option key={item.value} value={item.value}>{item.name}</Option>))}
</Select>
</Popover>
{ valueForm }
<Button size="large" style={{ height: '36px' }} htmlType="submit" type="primary" onClick={this.handleSubmit}>Go</Button>
</Input.Group>
</Form>
)
}
Expand All @@ -204,6 +248,7 @@ Filter.propTypes = {
stateOption: PropTypes.array,
fieldOption: PropTypes.array,
defaultField: PropTypes.string,
booleanFields: PropTypes.array,
scheduleOption: PropTypes.array,
replicaNodeRedundancyOption: PropTypes.array,
engineImageUpgradableOption: PropTypes.array,
Expand Down
6 changes: 4 additions & 2 deletions src/components/Layout/Footer.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import semver from 'semver'
import BundlesModel from './BundlesModel'
import StableLonghornVersions from './StableLonghornVersions'

function Footer({ app, host, volume, setting, engineimage, eventlog, backingImage, recurringJob, backup, systemBackups, dispatch }) {
function Footer({ app, host, volume, setting, engineimage, eventlog, backingImage, backupTarget, recurringJob, backup, systemBackups, dispatch }) {
const { bundlesropsVisible, bundlesropsKey, stableLonghornVersionslVisible, stableLonghornVersionsKey, okText, modalButtonDisabled, progressPercentage } = app
const currentVersion = config.version === '${VERSION}' ? 'dev' : config.version // eslint-disable-line no-template-curly-in-string
const issueHref = 'https://github.com/longhorn/longhorn/issues/new/choose'
Expand Down Expand Up @@ -127,6 +127,7 @@ function Footer({ app, host, volume, setting, engineimage, eventlog, backingImag
{getStatusIcon(engineimage)}
{getStatusIcon(eventlog)}
{getStatusIcon(backingImage)}
{getStatusIcon(backupTarget)}
{getStatusIcon(recurringJob)}
{getBackupStatusIcon(backup, 'backupVolumes')}
{getBackupStatusIcon(backup, 'backups')}
Expand All @@ -145,6 +146,7 @@ Footer.propTypes = {
volume: PropTypes.object,
setting: PropTypes.object,
engineimage: PropTypes.object,
backupTarget: PropTypes.object,
eventlog: PropTypes.object,
backingImage: PropTypes.object,
recurringJob: PropTypes.object,
Expand All @@ -154,4 +156,4 @@ Footer.propTypes = {
dispatch: PropTypes.func,
}

export default connect(({ app, host, volume, setting, engineimage, eventlog, backingImage, recurringJob, backup, systemBackups }) => ({ app, host, volume, setting, engineimage, eventlog, backingImage, recurringJob, backup, systemBackups }))(Footer)
export default connect(({ app, host, volume, setting, engineimage, eventlog, backupTarget, backingImage, recurringJob, backup, systemBackups }) => ({ app, host, volume, setting, engineimage, eventlog, backingImage, backupTarget, recurringJob, backup, systemBackups }))(Footer)
2 changes: 2 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import setting from './models/setting'
import eventlog from './models/eventlog'
import engineimage from './models/engineimage'
import backingImage from './models/backingImage'
import backupTarget from './models/backupTarget'
import backup from './models/backup'
import snapshot from './models/snapshot'
import recurringJob from './models/recurringJob'
Expand Down Expand Up @@ -34,6 +35,7 @@ app.model(setting)
app.model(eventlog)
app.model(engineimage)
app.model(backingImage)
app.model(backupTarget)
app.model(backup)
app.model(volume)
app.model(recurringJob)
Expand Down
12 changes: 11 additions & 1 deletion src/models/backingImage.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { create, deleteBackingImage, query, deleteDisksOnBackingImage, uploadChunk, download, bulkDownload } from '../services/backingImage'
import { create, deleteBackingImage, query, execAction, deleteDisksOnBackingImage, uploadChunk, download, bulkDownload } from '../services/backingImage'
import { message, notification } from 'antd'
import { delay } from 'dva/saga'
import { wsChanges, updateState } from '../utils/websocket'
Expand Down Expand Up @@ -89,6 +89,16 @@ export default {
payload.sourceType === 'upload' && notification.destroy()
}
},
*createBackingImageBackup({
url,
payload,
}, { call, put }) {
const resp = yield call(execAction, url, payload)
if (resp && resp.status === 200) {
message.success(`Successfully backup backing image ${payload.backingImageName}`, 5)
}
yield put({ type: 'query' })
},
*delete({
payload,
}, { call, put }) {
Expand Down
32 changes: 19 additions & 13 deletions src/models/backup.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { query, queryBackupList, execAction, restore, deleteBackup, syncBackupVolume, syncAllBackupVolumes, createVolume, deleteAllBackups, getNodeTags, getDiskTags, queryTarget } from '../services/backup'
import { query, queryBackupList, execAction, restore, deleteBackup, syncBackupVolume, syncAllBackupVolumes, createVolume, deleteAllBackups, getNodeTags, getDiskTags } from '../services/backup'
import { queryBackupTarget } from '../services/backupTarget'
import { message } from 'antd'
import { wsChanges } from '../utils/websocket'
import queryString from 'query-string'
Expand All @@ -22,6 +23,7 @@ export default {
lastBackupUrl: '',
volumeName: '',
backupTargetMessage: '',
backupTargetAvailable: false,
previousChecked: false,
tagsLoading: true,
size: '',
Expand All @@ -31,7 +33,6 @@ export default {
backupVolumesForBulkCreate: [],
search: {},
restoreBackupModalVisible: false,
backupTargetAvailable: false,
workloadDetailModalVisible: false,
createVolumeStandModalVisible: false,
bulkCreateVolumeStandModalVisible: false,
Expand Down Expand Up @@ -99,18 +100,19 @@ export default {
}
},
*queryBackupTarget({
// eslint-disable-next-line no-unused-vars
payload,
}, { call, put }) {
let resp = yield call(queryTarget)
if (resp && resp.data && resp.data[0]) {
let isbackupVolumePage = true
let path = ['/node', '/dashboard', '/volume', '/engineimage', '/setting', '/backingImage', '/recurringJob']

isbackupVolumePage = payload.history && payload.history.location && payload.history.location.pathname && payload.history.location.pathname !== '/' && path.every(ele => !payload.history.location.pathname.startsWith(ele))
if (isbackupVolumePage) {
!resp.data[0].available ? message.error(resp.data[0].message) : message.destroy()
const resp = yield call(queryBackupTarget)
if (resp && resp.status === 200) {
const backupTargetAvailable = resp?.data?.some(d => d.available === true) || false
const backupTargetMessage = backupTargetAvailable ? '' : 'No backup target is available, please go to Setting -> Backup Target page to create one'
if (payload.history.location.pathname === '/backup' && !backupTargetAvailable) {
message.error(backupTargetMessage, 5)
} else {
message.destroy()
}
yield put({ type: 'setBackupTargetAvailable', payload: { backupTargetAvailable: resp.data[0].available, backupTargetMessage: resp.data[0].message } })
yield put({ type: 'setBackupTargetAvailable', payload: { backupTargetAvailable, backupTargetMessage } })
}
},
*queryBackupStatus({
Expand Down Expand Up @@ -311,7 +313,6 @@ export default {
wsChanges(payload.dispatch, payload.type, '1s', payload.ns)
}
}

if (payload.type === 'backups' && getBackupVolumeName(payload.search)) {
let wsBackup = yield select(state => state.backup.wsBackup)
if (wsBackup) {
Expand Down Expand Up @@ -353,7 +354,12 @@ export default {
let volumeName = getBackupVolumeName(state.search)
if (volumeName && action.payload && action.payload.data) {
let backupData = action.payload.data.filter((item) => {
return item.volumeName === volumeName
if (item.backupTargetName) {
// after implement multiple backup targets feature, backup volume name in backup page is composed by ${volumeName}-${backupTargetName}
return volumeName === `${item.volumeName}-${item.backupTargetName}`
} else {
return item.volumeName === volumeName
}
})
return {
...state,
Expand Down
Loading