Skip to content

Commit

Permalink
Merge pull request #2210 from flowforge/2156-ha-replicas-ui
Browse files Browse the repository at this point in the history
Initial UI support for HA mode
  • Loading branch information
knolleary committed Jun 2, 2023
2 parents d26baf8 + e5f39b7 commit 029e507
Show file tree
Hide file tree
Showing 14 changed files with 238 additions and 37 deletions.
6 changes: 3 additions & 3 deletions forge/db/views/Project.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ module.exports = {
const settingsHostnameRow = proj.ProjectSettings?.find((projectSettingsRow) => projectSettingsRow.key === KEY_HOSTNAME)
result.hostname = settingsHostnameRow?.value || ''
}
const settingsHARow = proj.ProjectSettings?.find(row => row.key === KEY_HA)
if (settingsHARow) {
result.ha = settingsHARow.value
if (app.config.features.enabled('ha')) {
const settingsHARow = proj.ProjectSettings?.find(row => row.key === KEY_HA)
result.ha = settingsHARow?.value || { disabled: true }
}

if (proj.Application) {
Expand Down
13 changes: 12 additions & 1 deletion frontend/src/api/instances.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,15 @@ const rollbackInstance = async (instanceId, snapshotId) => {
}
return client.post(`/api/v1/projects/${instanceId}/actions/rollback`, data).then(res => res.data)
}

const enableHAMode = async (instanceId) => {
const haConfig = { replicas: 2 }
return client.put(`/api/v1/projects/${instanceId}/ha`, haConfig)
}
const disableHAMode = async (instanceId) => {
return client.delete(`/api/v1/projects/${instanceId}/ha`)
}

export default {
create,
getInstance,
Expand All @@ -151,5 +160,7 @@ export default {
getInstanceDevices,
getInstanceDeviceSettings,
updateInstanceDeviceSettings,
rollbackInstance
rollbackInstance,
enableHAMode,
disableHAMode
}
2 changes: 1 addition & 1 deletion frontend/src/components/StatusBadge.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import {
} from '@heroicons/vue/outline'
export default {
name: 'InstanceStatusBadge',
name: 'StatusBadge',
components: {
CloudDownloadIcon,
CloudUploadIcon,
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/pages/application/Overview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,8 @@ export default {
return this.instances.map((instance) => {
instance.running = instance.meta?.state === 'running'
instance.notSuspended = instance.meta?.state !== 'suspended'
instance.disabled = !instance.running || this.isVisitingAdmin
instance.isHA = instance.ha?.replicas !== undefined
instance.disabled = !instance.running || this.isVisitingAdmin || instance.isHA
return instance
})
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/pages/application/createInstance.vue
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,10 @@ export default {
options: { ...copyParts }
}
}
if (this.features.ha && createPayload.isHA) {
createPayload.ha = { replicas: 2 }
}
delete createPayload.isHA
return instanceApi.create(createPayload)
}
Expand Down
16 changes: 14 additions & 2 deletions frontend/src/pages/instance/Overview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,14 @@
<ExternalLinkIcon class="w-4 ml-3" />
</a>
</div>
<div v-else class="my-2">Unavailable</div>
<div v-else class="my-2">
<router-link v-if="isHA" :to="{name: 'InstanceSettingsHA', params: { id: instance.id }}" @click.stop>
<StatusBadge class="text-gray-400 hover:text-blue-600" status="high-availability" />
</router-link>
<template v-else>
Unavailable
</template>
</div>
</td>
</tr>
<tr class="border-b">
Expand Down Expand Up @@ -84,6 +91,7 @@ import { mapState } from 'vuex'

import InstanceApi from '../../api/instances.js'
import FormHeading from '../../components/FormHeading.vue'
import StatusBadge from '../../components/StatusBadge.vue'
import AuditLog from '../../components/audit-log/AuditLog.vue'
import permissionsMixin from '../../mixins/Permissions.js'

Expand All @@ -96,6 +104,7 @@ export default {
ExternalLinkIcon,
FormHeading,
InstanceStatusBadge,
StatusBadge,
TemplateIcon,
TrendingUpIcon
},
Expand All @@ -122,7 +131,10 @@ export default {
return this.instance?.meta?.state === 'running'
},
editorAvailable () {
return this.instanceRunning
return !this.isHA && this.instanceRunning
},
isHA () {
return this.instance?.ha?.replicas !== undefined
}
},
watch: {
Expand Down
44 changes: 33 additions & 11 deletions frontend/src/pages/instance/Settings/General.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,31 @@
Instance Type
</FormRow>

<FormRow v-if="features.ha && input.haConfig" v-model="input.haConfig" type="uneditable">
<template #default>High Availability</template>
<template #input>
<div class="w-full uneditable undefined text-gray-800">
{{ input.haConfig.replicas }} x instances
</div>
</template>
</FormRow>
<FormRow v-model="input.stackDescription" type="uneditable">
Stack
</FormRow>
<FormRow v-model="input.templateName" type="uneditable">
Template
</FormRow>
<DangerSettings
:instance="project"
:instance="instance"
@instance-confirm-delete="$emit('instance-confirm-delete')"
@instance-confirm-suspend="$emit('instance-confirm-suspend')"
/>
</div>
</template>

<script>
import { mapState } from 'vuex'
import FormHeading from '../../../components/FormHeading.vue'
import FormRow from '../../../components/FormRow.vue'
Expand All @@ -42,7 +52,7 @@ export default {
},
inheritAttrs: false,
props: {
project: {
instance: {
type: Object,
required: true
}
Expand All @@ -58,13 +68,20 @@ export default {
projectName: '',
projectTypeName: '',
stackDescription: '',
templateName: ''
templateName: '',
haConfig: {}
},
original: {
projectName: ''
}
}
},
computed: {
...mapState('account', ['features']),
isHA () {
return !!this.instance?.ha
}
},
watch: {
project: 'fetchData'
},
Expand All @@ -73,25 +90,30 @@ export default {
},
methods: {
fetchData () {
this.input.projectId = this.project.id
if (this.project.stack) {
this.input.stackDescription = this.project.stack.label || this.project.stack.name
this.input.projectId = this.instance.id
if (this.instance.stack) {
this.input.stackDescription = this.instance.stack.label || this.instance.stack.name
} else {
this.input.stackDescription = 'none'
}
if (this.project.projectType) {
this.input.projectTypeName = this.project.projectType.name
if (this.instance.projectType) {
this.input.projectTypeName = this.instance.projectType.name
} else {
this.input.projectTypeName = 'none'
}
if (this.project.template) {
this.input.templateName = this.project.template.name
if (this.instance.template) {
this.input.templateName = this.instance.template.name
} else {
this.input.templateName = 'none'
}
this.input.projectName = this.project.name
this.input.projectName = this.instance.name
if (this.instance.ha?.replicas !== undefined) {
this.input.haConfig = this.instance.ha
} else {
this.input.haConfig = undefined
}
}
}
}
Expand Down
113 changes: 113 additions & 0 deletions frontend/src/pages/instance/Settings/HighAvailability.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<template>
<ff-loading v-if="updating" message="Updating Instance..." />
<template v-else>
<FormHeading>High Availability (preview)</FormHeading>
<FormRow>
<template #description>
<p class="mb-3">
High Availability mode allows you to run multiple copies
of your Node-RED instance, with incoming work distributed
between them.
</p>
<p>
This Preview Feature is currently free to use, but
will become a chargable feature in a future release.
</p>
<p>
When HA mode is enabled the following restrictions currently apply:
</p>
<ul class="list-disc pl-6">
<li>Enabling or disabling HA mode requires a restart of the Instance.</li>
<li>Flows cannot be directly modified in an HA Instance; the editor is disabled.</li>
<li>A DevOps Pipeline should be created to deploy new flows to the instance.</li>
<li>Any internal state of the flows is not shared between the HA copies.</li>
</ul>
<p>
Check the documentation for more information about the <a class="underline" href="#">High Availability preview feature</a>.
</p>
</template>
<template #input>&nbsp;</template>
</FormRow>
<template v-if="!isHA">
<ff-button kind="secondary" data-nav="enable-ha" @click="enableHA()">Enable HA mode</ff-button>
</template>
<template v-else>
<ff-button kind="secondary" data-nav="disable-ha" @click="disableHA()">Disable HA mode</ff-button>
</template>
</template>
</template>

<script>
import InstanceApi from '../../../api/instances.js'
import FormHeading from '../../../components/FormHeading.vue'
import FormRow from '../../../components/FormRow.vue'
import Alerts from '../../../services/alerts.js'
import Dialog from '../../../services/dialog.js'
export default {
name: 'InstanceSettingsStages',
components: {
FormHeading,
FormRow
},
inheritAttrs: false,
props: {
instance: {
type: Object,
required: true
}
},
emits: ['instance-updated'],
data: function () {
return {
updating: false
}
},
computed: {
isHA () {
return this.instance?.ha?.replicas !== undefined
}
},
methods: {
async enableHA () {
const msg = {
header: 'Enable High Availability mode',
html: `<p>Enabling HA mode will require a restart of the instance.</p>
<p>Once enabled, the editor will be disabled. The flows can only
be updated by using a DevOps Pipeline to deploy to this instance
from another one, or by disabling HA mode first.</p>`
}
Dialog.show(msg, async () => {
this.updating = true
await InstanceApi.enableHAMode(this.instance.id)
this.updating = false
if (this.instance.meta?.state === 'suspended') {
Alerts.emit('High Availability mode enabled', 'confirmation')
} else {
Alerts.emit('High Availability mode enabled. The Instance will now be restarted', 'confirmation')
}
this.$emit('instance-updated')
})
},
async disableHA () {
const msg = {
header: 'Disable High Availability mode',
html: '<p>Disabling HA mode will require a restart of the instance.</p>'
}
Dialog.show(msg, async () => {
this.updating = true
await InstanceApi.disableHAMode(this.instance.id)
this.updating = false
if (this.instance.meta?.state === 'suspended') {
Alerts.emit('High Availability mode disabled', 'confirmation')
} else {
Alerts.emit('High Availability mode disabled. The Instance will now be restarted', 'confirmation')
}
this.$emit('instance-updated')
})
}
}
}
</script>
5 changes: 4 additions & 1 deletion frontend/src/pages/instance/Settings/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export default {
}
},
computed: {
...mapState('account', ['team', 'teamMembership'])
...mapState('account', ['team', 'teamMembership', 'features'])
},
watch: {
teamMembership: 'checkAccess'
Expand All @@ -60,6 +60,9 @@ export default {
]
if (this.hasPermission('project:edit')) {
this.sideNavigation.push({ name: 'DevOps', path: './devops' })
if (this.features.ha) {
this.sideNavigation.push({ name: 'High Availability', path: './ha' })
}
this.sideNavigation.push({ name: 'Editor', path: './editor' })
this.sideNavigation.push({ name: 'Security', path: './security' })
this.sideNavigation.push({ name: 'Palette', path: './palette' })
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/pages/instance/Settings/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import InstanceSettingsDevOps from './DevOps.vue'
import InstanceSettingsEditor from './Editor.vue'
import InstanceSettingsEnvVar from './Environment.vue'
import InstanceSettingsGeneral from './General.vue'
import InstanceSettingsHA from './HighAvailability.vue'
import InstanceSettingsPalette from './Palette.vue'
import InstanceSettingsSecurity from './Security.vue'

Expand All @@ -15,6 +16,7 @@ export default [
{ path: 'security', component: InstanceSettingsSecurity },
{ path: 'palette', component: InstanceSettingsPalette },
{ path: 'danger', component: InstanceSettingsDanger },
{ path: 'ha', name: 'InstanceSettingsHA', component: InstanceSettingsHA },
{
name: 'ChangeInstanceType',
path: 'change-type',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<template>
<ff-button
v-if="!isVisitingAdmin"
v-ff-tooltip:left="disabledReason"
kind="secondary"
data-action="open-editor"
:disabled="editorDisabled || disabled || !url"
Expand All @@ -11,7 +12,7 @@
</template>
{{ editorDisabled ? 'Editor Disabled' : 'Open Editor' }}
</ff-button>
<button v-else title="Unable to open editor when visiting as an admin" class="ff-btn ff-btn--secondary" disabled>
<button v-else v-ff-tooltip:left="'Unable to open editor when visiting as an admin'" class="ff-btn ff-btn--secondary" disabled>
{{ editorDisabled ? 'Editor Disabled' : 'Open Editor' }}
<span class="ff-btn--icon ff-btn--icon-right">
<ExternalLinkIcon />
Expand Down Expand Up @@ -39,6 +40,10 @@ export default {
default: false,
type: Boolean
},
disabledReason: {
default: null,
type: String
},
url: {
default: '',
type: String
Expand Down
Loading

0 comments on commit 029e507

Please sign in to comment.