Skip to content

Commit

Permalink
Merge pull request #3716 from FlowFuse/3715-health-check-interval
Browse files Browse the repository at this point in the history
Implement health check interval user setting
  • Loading branch information
knolleary authored May 8, 2024
2 parents 1aa0b27 + 629776b commit fe2e18f
Show file tree
Hide file tree
Showing 10 changed files with 330 additions and 6 deletions.
5 changes: 3 additions & 2 deletions forge/db/models/Project.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const { DataTypes, Op } = require('sequelize')

const Controllers = require('../controllers')

const { KEY_HOSTNAME, KEY_SETTINGS, KEY_HA, KEY_PROTECTED } = require('./ProjectSettings')
const { KEY_HOSTNAME, KEY_SETTINGS, KEY_HA, KEY_PROTECTED, KEY_HEALTH_CHECK_INTERVAL } = require('./ProjectSettings')

const BANNED_NAME_LIST = [
'www',
Expand Down Expand Up @@ -358,7 +358,8 @@ module.exports = {
{ key: KEY_SETTINGS },
{ key: KEY_HOSTNAME },
{ key: KEY_HA },
{ key: KEY_PROTECTED }
{ key: KEY_PROTECTED },
{ key: KEY_HEALTH_CHECK_INTERVAL }
]
},
required: false
Expand Down
2 changes: 2 additions & 0 deletions forge/db/models/ProjectSettings.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ const KEY_SETTINGS = 'settings'
const KEY_HOSTNAME = 'hostname'
const KEY_HA = 'ha'
const KEY_PROTECTED = 'protected'
const KEY_HEALTH_CHECK_INTERVAL = 'healthCheckInterval'

module.exports = {
KEY_SETTINGS,
KEY_HOSTNAME,
KEY_HA,
KEY_PROTECTED,
KEY_HEALTH_CHECK_INTERVAL,
name: 'ProjectSettings',
schema: {
ProjectId: { type: DataTypes.UUID, unique: 'pk_settings' },
Expand Down
16 changes: 15 additions & 1 deletion forge/db/views/Project.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const { KEY_HOSTNAME, KEY_SETTINGS, KEY_HA, KEY_PROTECTED } = require('../models/ProjectSettings')
const { KEY_HOSTNAME, KEY_SETTINGS, KEY_HA, KEY_PROTECTED, KEY_HEALTH_CHECK_INTERVAL } = require('../models/ProjectSettings')

module.exports = function (app) {
app.addSchema({
Expand Down Expand Up @@ -32,6 +32,13 @@ module.exports = function (app) {
protected: {
type: 'object',
additionalProperties: true
},
launcherSettings: {
type: 'object',
properties: {
healthCheckInterval: { type: 'number' }
},
additionalProperties: false
}
}
})
Expand Down Expand Up @@ -61,6 +68,13 @@ module.exports = function (app) {
} else {
result.settings = {}
}
// Launcher Settings
const heathCheckIntervalRow = proj.ProjectSettings?.find((projectSettingsRow) => projectSettingsRow.key === KEY_HEALTH_CHECK_INTERVAL)
if (heathCheckIntervalRow) {
result.launcherSettings = {}
result.launcherSettings.healthCheckInterval = heathCheckIntervalRow?.value
}
// Environment
result.settings.env = app.db.controllers.Project.insertPlatformSpecificEnvVars(proj, result.settings.env)
if (!result.settings.palette?.modules) {
// If there are no modules listed in settings, check the StorageSettings
Expand Down
24 changes: 23 additions & 1 deletion forge/routes/api/project.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const { KEY_HOSTNAME, KEY_SETTINGS } = require('../../db/models/ProjectSettings')
const { KEY_HOSTNAME, KEY_SETTINGS, KEY_HEALTH_CHECK_INTERVAL } = require('../../db/models/ProjectSettings')
const { Roles } = require('../../lib/roles')

const { isFQDN } = require('../../lib/validate')
Expand Down Expand Up @@ -312,6 +312,7 @@ module.exports = async function (app) {
name: { type: 'string' },
hostname: { type: 'string' },
settings: { type: 'object' },
launcherSettings: { type: 'object' },
projectType: { type: 'string' },
stack: { type: 'string' },
sourceProject: {
Expand Down Expand Up @@ -478,6 +479,19 @@ module.exports = async function (app) {
changesToPersist.stack = { from: request.project.stack, to: stack }
}

// Launcher settings
if (request.body?.launcherSettings?.healthCheckInterval) {
const oldInterval = await request.project.getSetting(KEY_HEALTH_CHECK_INTERVAL)
const newInterval = parseInt(request.body.launcherSettings.healthCheckInterval, 10)
if (isNaN(newInterval) || newInterval < 5000) {
reply.code(400).send({ code: 'invalid_heathCheckInterval', error: 'Invalid heath check interval' })
return
}
if (oldInterval !== newInterval) {
changesToPersist.healthCheckInterval = { from: oldInterval, to: newInterval }
}
}

/// Persist the changes
const updates = new app.auditLog.formatters.UpdatesCollection()
const transaction = await app.db.sequelize.transaction() // start a transaction
Expand Down Expand Up @@ -541,6 +555,11 @@ module.exports = async function (app) {
}
}

if (changesToPersist.healthCheckInterval) {
await request.project.updateSetting(KEY_HEALTH_CHECK_INTERVAL, changesToPersist.healthCheckInterval.to, { transaction })
updates.pushDifferences({ healthCheckInterval: changesToPersist.healthCheckInterval.from }, { healthCheckInterval: changesToPersist.healthCheckInterval.to })
}

await transaction.commit() // all good, commit the transaction

// Log the updates
Expand Down Expand Up @@ -795,7 +814,9 @@ module.exports = async function (app) {
reply.code(400).send({ code: 'project_suspended', error: 'Project suspended' })
return
}
// get settings from the driver
const settings = await app.containers.settings(request.project)
// add instance settings
settings.env = settings.env || {}
settings.baseURL = request.project.url
settings.forgeURL = app.config.base_url
Expand All @@ -805,6 +826,7 @@ module.exports = async function (app) {
settings.auditURL = request.project.auditURL
settings.state = request.project.state
settings.stack = request.project.ProjectStack?.properties || {}
settings.healthCheckInterval = await request.project.getSetting(KEY_HEALTH_CHECK_INTERVAL)
settings.settings = await app.db.controllers.Project.getRuntimeSettings(request.project)
if (settings.settings.env) {
settings.env = Object.assign({}, settings.settings.env, settings.env)
Expand Down
116 changes: 116 additions & 0 deletions frontend/src/pages/instance/Settings/LauncherSettings.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<template>
<FormHeading class="mb-6">Launcher Settings</FormHeading>
<form class="space-y-6" data-el="launcher-settings-form">
<FormRow v-model="input.healthCheckInterval" type="number" :error="errors.healthCheckInterval">
Health check interval (ms)
<template #description>
The interval at which the launcher will check the health of Node-RED.
Flows that perform CPU intensive work may need to increase this from the default of 7500ms.
</template>
</FormRow>

<div class="space-x-4 whitespace-nowrap">
<ff-button size="small" :disabled="!unsavedChanges || !validateFormInputs()" data-action="save-settings" @click="saveSettings()">Save settings</ff-button>
</div>
</form>
</template>

<script>
import { useRouter } from 'vue-router'
import { mapState } from 'vuex'
import InstanceApi from '../../../api/instances.js'
import FormHeading from '../../../components/FormHeading.vue'
import FormRow from '../../../components/FormRow.vue'
import permissionsMixin from '../../../mixins/Permissions.js'
import alerts from '../../../services/alerts.js'
export default {
name: 'LauncherSettings',
components: {
FormRow,
FormHeading
},
mixins: [permissionsMixin],
inheritAttrs: false,
props: {
project: {
type: Object,
required: true
}
},
emits: ['instance-updated'],
data () {
return {
mounted: false,
original: {
healthCheckInterval: null
},
input: {
healthCheckInterval: null
},
errors: {
healthCheckInterval: ''
}
}
},
computed: {
...mapState('account', ['team', 'teamMembership']),
unsavedChanges: function () {
return this.original.healthCheckInterval !== this.input.healthCheckInterval
}
},
watch: {
project: 'getSettings',
'input.healthCheckInterval': function (value) {
if (this.mounted) {
this.validateFormInputs()
}
}
},
mounted () {
this.checkAccess()
this.getSettings()
this.mounted = true
},
methods: {
checkAccess: function () {
if (!this.hasPermission('project:edit')) {
useRouter().push({ replace: true, path: 'general' })
}
},
validateFormInputs () {
if (!this.unsavedChanges) {
this.errors.healthCheckInterval = ''
} else {
const hci = parseInt(this.input.healthCheckInterval)
if (isNaN(hci) || hci < 5000) {
this.errors.healthCheckInterval = 'Health check interval must be 5000 or greater'
} else {
this.errors.healthCheckInterval = ''
}
}
return !this.errors.healthCheckInterval
},
getSettings: function () {
this.original.healthCheckInterval = this.project?.launcherSettings?.healthCheckInterval
this.input.healthCheckInterval = this.project?.launcherSettings.healthCheckInterval
},
async saveSettings () {
const launcherSettings = {
healthCheckInterval: this.input.healthCheckInterval
}
if (!this.validateFormInputs()) {
alerts.emit('Please correct the errors before saving.', 'error')
return
}
await InstanceApi.updateInstance(this.project.id, { launcherSettings })
this.$emit('instance-updated')
alerts.emit('Instance successfully updated. Restart the instance to apply the changes.', 'confirmation')
}
}
}
</script>
1 change: 1 addition & 0 deletions frontend/src/pages/instance/Settings/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export default {
this.sideNavigation.push({ name: 'Editor', path: './editor' })
this.sideNavigation.push({ name: 'Security', path: './security' })
this.sideNavigation.push({ name: 'Palette', path: './palette' })
this.sideNavigation.push({ name: 'Launcher', path: './launcher' })
if (this.features.emailAlerts && this.team.type.properties.features?.emailAlerts) {
this.sideNavigation.push({ name: 'Alerts', path: './alerts' })
}
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 @@ -5,6 +5,7 @@ import InstanceSettingsEditor from './Editor.vue'
import InstanceSettingsEnvVar from './Environment.vue'
import InstanceSettingsGeneral from './General.vue'
import InstanceSettingsHA from './HighAvailability.vue'
import InstanceSettingsLauncher from './LauncherSettings.vue'
import InstanceSettingsPalette from './Palette.vue'
import InstanceSettingsProtect from './ProtectInstance.vue'
import InstanceSettingsSecurity from './Security.vue'
Expand All @@ -26,5 +27,6 @@ export default [
title: 'Instance - Change Type'
}
},
{ path: 'launcher', name: 'instance-settings-launcher', component: InstanceSettingsLauncher },
{ path: 'alerts', name: 'instance-settings-alerts', component: InstanceSettingsAlerts }
]
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ describe('FlowFuse EE - Instance - Alerts', () => {
navigateToInstanceSettings('BTeam', 'instance-2-1')

// check Alerts in list and click
cy.get('[data-el="section-side-menu"] li').should('have.length', 8)
cy.get('[data-el="section-side-menu"] li:last a').contains('Alerts')
cy.get('[data-el="section-side-menu"] li:last').click()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/// <reference types="Cypress" />
describe('FlowFuse - Instance - Settings - Launcher', () => {
function navigateToInstanceSettings (teamName, projectName) {
cy.request('GET', '/api/v1/user/teams')
.then((response) => {
const team = response.body.teams.find(
(team) => team.name === teamName
)
return cy.request('GET', `/api/v1/teams/${team.id}/projects`)
})
.then((response) => {
const project = response.body.projects.find(
(project) => project.name === projectName
)
cy.visit(`/instance/${project.id}/settings/general`)
cy.wait('@getInstance')
})
}

function getForm () {
return cy.get('[data-el="launcher-settings-form"]')
}

beforeEach(() => {
cy.intercept('GET', '/api/*/projects/').as('getProjects')
cy.intercept('GET', '/api/*/projects/*').as('getInstance')
cy.login('bob', 'bbPassword')
cy.home()
})

it('Validates health check interval user input', () => {
cy.intercept('PUT', '/api/*/projects/*').as('updateInstance')
// navigate to instance settings -> launcher tab
cy.login('bob', 'bbPassword')
cy.home()
navigateToInstanceSettings('BTeam', 'instance-2-1')

cy.get('[data-el="section-side-menu"] li').contains('Launcher').click()

// wait for url /instance/***/settings/launcher
cy.url().should('include', 'settings/launcher')

// Change value to < 5000
getForm().first('div').get('.ff-input > input[type=number]').clear()
getForm().first('div').get('.ff-input > input[type=number]').type(4999)
cy.get('[data-action="save-settings"]').should('be.disabled')
getForm().first('div').get('[data-el="form-row-error"').contains('Health check interval must be 5000 or greater').should('exist')

// Change value to > 5000
getForm().first('div').get('.ff-input > input[type=number]').clear()
getForm().first('div').get('.ff-input > input[type=number]').type(5001)
cy.get('[data-action="save-settings"]').should('not.be.disabled')
getForm().first('div').get('[data-el="form-row-error"').should('not.exist')
})

it('Can set health check interval value', () => {
cy.intercept('PUT', '/api/*/projects/*').as('updateInstance')

navigateToInstanceSettings('BTeam', 'instance-2-1')

// locate and click on the launcher tab
cy.get('[data-el="section-side-menu"] li').contains('Launcher').click()

// wait for url /instance/***/settings/launcher
cy.url().should('include', 'settings/launcher')

// // ensure the first child's title is correct
getForm().should('exist')
getForm().first('div').should('exist')
getForm().first('div').get('[data-el="form-row-title"]').contains('Health check interval (ms)').should('exist')
// ensure the first child's numeric input exists
getForm().first('div').get('.ff-input > input[type=number]').should('exist')

// Change value & save
const randomBetween6789and9876 = Math.floor(Math.random() * (9876 - 6789 + 1)) + 6789
getForm().first('div').get('.ff-input > input[type=number]').clear()
getForm().first('div').get('.ff-input > input[type=number]').type(randomBetween6789and9876)
cy.get('[data-action="save-settings"]').click()
cy.wait('@updateInstance')

// refresh page
navigateToInstanceSettings('BTeam', 'instance-2-1')
cy.get('[data-el="section-side-menu"] li').contains('Launcher').click()

// check value is restored
cy.get('[data-el="launcher-settings-form"]').first().get('.ff-input > input[type=number]').should('have.value', randomBetween6789and9876)
})
})
Loading

0 comments on commit fe2e18f

Please sign in to comment.