Skip to content

Commit

Permalink
Merge pull request #3643 from FlowFuse/device-npmrc-catalogue
Browse files Browse the repository at this point in the history
Allow .npmrc and calalogue urls to be set for Application bound devices
  • Loading branch information
Steve-Mcl authored Apr 10, 2024
2 parents d89ac63 + 1197d80 commit b5326bd
Show file tree
Hide file tree
Showing 9 changed files with 255 additions and 6 deletions.
11 changes: 11 additions & 0 deletions docs/device-agent/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,17 @@ For devices that are assigned to an application, the platform will automatically
when it detects flows modified. This snapshot will be created with the name "Auto Snapshot - yyyy-mm-dd hh:mm-ss".
Only the last 10 auto snapshots are kept, others are deleted on a first in first out basis.

**Custom Node Catalogues**

For devices that want to make use of custom node catalogues, these can be configured
under the device settings page on the Palette tab

**.npmrc file**

Likewise for devices that need to be provided with a custom `.npmrc` file to allow access
to a custom npm registry or to provide an access token this can also be set on the device
settings Palette tab


### Important Notes

Expand Down
3 changes: 2 additions & 1 deletion forge/db/models/Device.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ const { buildPaginationSearchClause } = require('../utils')

const ALLOWED_SETTINGS = {
env: 1,
autoSnapshot: 1
autoSnapshot: 1,
palette: 1
}

const DEFAULT_SETTINGS = {
Expand Down
9 changes: 7 additions & 2 deletions forge/routes/api/device.js
Original file line number Diff line number Diff line change
Expand Up @@ -621,7 +621,11 @@ module.exports = async function (app) {
type: 'object',
properties: {
env: { type: 'array', items: { type: 'object', additionalProperties: true } },
autoSnapshot: { type: 'boolean' }
autoSnapshot: { type: 'boolean' },
palette: {
type: 'object',
additionalProperties: true
}
}
},
response: {
Expand Down Expand Up @@ -698,7 +702,8 @@ module.exports = async function (app) {
type: 'object',
properties: {
env: { type: 'array', items: { type: 'object', additionalProperties: true } },
autoSnapshot: { type: 'boolean' }
autoSnapshot: { type: 'boolean' },
palette: { type: 'object', additionalProperties: true }
}
},
'4xx': {
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/pages/device/Settings/Environment.vue
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,12 @@ export default {
templateEnvValues: {}
}
},
mounted () {
this.getSettings()
},
computed: {
...mapState('account', ['teamMembership'])
},
mounted () {
this.getSettings()
},
methods: {
getSettings: async function () {
if (this.device) {
Expand Down
183 changes: 183 additions & 0 deletions frontend/src/pages/device/Settings/Palette.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
<template>
<div v-if="device.ownerType == 'application'">
<form class="space-y-6 max-w-2xl" @submit.prevent>
<FormHeading>
<template #default>
Node Catalogues
</template>
</FormHeading>
<div class="flex flex-col sm:flex-row" />
<div class="w-full flex flex-col sm:flex-row">
<div class="w-full sm:mr-8 space-y-2">
<div class="w-full flex items-center">
<div class="flex-grow" :class="{'opacity-20': !defaultEnabled}">{{ defaultCatalogue }}</div>
<!-- Default is enabled, allow for removal -->
<ff-button v-if="!defaultEnabled" v-ff-tooltip:left="'Restore Default Catalogue'" kind="tertiary" size="small" @click="addDefault()">
<template #icon><UndoIcon /></template>
</ff-button>
<!-- Default is disabled, allow for restoration -->
<ff-button v-else kind="tertiary" size="small" :disabled="readOnly" @click="removeURL(defaultCatalogue)">
<template #icon><XIcon /></template>
</ff-button>
</div>
<div v-for="(u, index) in thirdPartyUrls" :key="index" class="w-full flex items-center">
<div class="flex-grow">{{ u }}</div>
<ff-button kind="tertiary" size="small" :disabled="readOnly" @click="removeURL(u)">
<template #icon><XIcon /></template>
</ff-button>
</div>
<FormRow v-model="url" class="w-full sm:mr-8" :error="error" containerClass="none" appendClass="ml-2 relative">
<template #append>
<ff-button kind="secondary" size="small" @click="addURL()">
<template #icon>
<PlusSmIcon />
</template>
</ff-button>
</template>
</FormRow>
</div>
</div>
<FormHeading>
<template #default>
NPM configuration file
</template>
</FormHeading>
<div class="flex flex-col sm:flex-row">
<div class="space-y-4 w-full sm:mr-8">
<FormRow containerClass="none">
<template #input><textarea v-model="npmrc" class="font-mono w-full" placeholder=".npmrc" rows="8" /></template>
</FormRow>
</div>
</div>
<ff-button size="small" :disabled="!changed" @click="save">Save Settings</ff-button>
</form>
</div>
<div v-else>
Only available to Application bound instances, Instance bound Devices will inherit from the Instance.
</div>
</template>

<script>
import { PlusSmIcon, XIcon } from '@heroicons/vue/outline'
import { mapState } from 'vuex'
import deviceApi from '../../../api/devices.js'
import FormHeading from '../../../components/FormHeading.vue'
import FormRow from '../../../components/FormRow.vue'
import UndoIcon from '../../../components/icons/Undo.js'
import permissionsMixin from '../../../mixins/Permissions.js'
import alerts from '../../../services/alerts.js'
export default {
name: 'DeviceSettingsPalette',
components: {
FormHeading,
FormRow,
PlusSmIcon,
UndoIcon,
XIcon
},
mixins: [permissionsMixin],
props: {
device: {
type: Object,
required: true
}
},
emits: ['device-updated'],
data () {
return {
readOnly: false,
defaultCatalogue: 'https://catalogue.nodered.org/catalogue.json',
urls: [],
url: '',
npmrc: '',
error: '',
initial: {
urls: [],
npmrc: ''
}
}
},
computed: {
...mapState('account', ['teamMembership']),
defaultEnabled () {
return this.urls.includes(this.defaultCatalogue)
},
thirdPartyUrls () {
// whether or not this Template has any third party catalogues enabled
return this.urls.filter(url => url !== this.defaultCatalogue)
},
changed () {
const changed = this.npmrc !== this.initial.npmrc || (
this.initial.urls.length !== this.urls.length ||
this.initial.urls.every((v, i) => v !== this.urls[i])
)
return changed
}
},
mounted () {
this.getSettings()
},
methods: {
getSettings: async function () {
if (this.device) {
const settings = await deviceApi.getSettings(this.device.id)
if (settings.palette?.catalogues) {
this.urls = settings.palette.catalogues
this.initial.urls.push(...settings.palette.catalogues)
} else {
this.urls = [this.defaultCatalogue]
this.initial.urls = [this.defaultCatalogue]
}
if (settings.palette?.npmrc) {
this.npmrc = settings.palette.npmrc
this.initial.npmrc = `${settings.palette.npmrc}`
}
}
},
save: async function () {
const settings = await deviceApi.getSettings(this.device.id)
settings.palette = {
catalogues: this.urls,
npmrc: this.npmrc ? this.npmrc : undefined
}
deviceApi.updateSettings(this.device.id, settings)
this.$emit('device-updated')
alerts.emit('Device settings successfully updated.', 'confirmation', 6000)
this.initial.urls = []
this.initial.urls.push(...this.urls)
this.initial.npmrc = `${this.npmrc}`
},
addURL () {
const newURL = this.url.trim()
if (newURL) {
try {
// eslint-disable-next-line no-new
new URL(newURL)
} catch (err) {
this.error = 'Invalid URL'
return
}
if (!this.urls.includes(newURL)) {
this.urls.push(newURL)
this.url = ''
this.error = ''
} else {
this.error = 'Catalogue already present'
}
}
},
removeURL (url) {
const index = this.urls.indexOf(url)
this.urls.splice(index, 1)
},
addDefault () {
if (this.urls.indexOf(this.defaultCatalogue)) {
this.urls.unshift(this.defaultCatalogue)
}
}
}
}
</script>
8 changes: 8 additions & 0 deletions frontend/src/pages/device/Settings/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,20 @@ export default {
{ name: 'General', path: './general' },
{ name: 'Environment', path: './environment' }
]
if (this.device.ownerType === 'application' && this.hasPermission('device:edit')) {
this.sideNavigation.push({ name: 'Palette', path: './palette' })
}
if (this.hasPermission('device:edit')) {
this.sideNavigation.push({ name: 'Danger', path: './danger' })
}
return true
}
},
watch: {
device: function (newVal, oldVal) {
this.checkAccess()
}
},
components: {
SectionSideMenu
}
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/pages/device/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import DeviceOverview from './Overview.vue'
import DeviceSettingsDanger from './Settings/Danger.vue'
import DeviceSettingsEnvironment from './Settings/Environment.vue'
import DeviceSettingsGeneral from './Settings/General.vue'
import DeviceSettingsPalette from './Settings/Palette.vue'
import DeviceSettings from './Settings/index.vue'
import DeviceSnapshots from './Snapshots/index.vue'

Expand Down Expand Up @@ -35,6 +36,7 @@ export default [
children: [
{ path: 'general', component: DeviceSettingsGeneral },
{ path: 'environment', component: DeviceSettingsEnvironment },
{ path: 'palette', component: DeviceSettingsPalette },
{ path: 'danger', component: DeviceSettingsDanger }
]
},
Expand Down
10 changes: 10 additions & 0 deletions test/e2e/frontend/cypress/tests/devices/assignment.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,16 @@ describe('FlowForge - Application - Devices - Create', () => {
cy.get('[data-el="devices-browser"] tbody tr td').contains(deviceName)
})
})

it('application assigned Device has palette settings', () => {
navigateToApplicationDevices('BTeam', 'application-2')
cy.wait('@getApplicationDevices').then(() => {
cy.get('[data-el="devices-browser"] tbody tr:last-child td a').click()
cy.get('[data-nav="device-settings"]').click()
cy.get('[data-el="section-side-menu"] li [data-nav="palette"]').should('exist')
cy.get('[data-nav="palette"]').click()
})
})
})

describe('FlowForge - Devices - Assign', () => {
Expand Down
29 changes: 29 additions & 0 deletions test/unit/forge/routes/api/device_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,35 @@ describe('Device API', async function () {
nonPlatformVars[0].should.have.property('value', 'foo')
settings.should.not.have.property('invalid')
})
it('owner set .npmrc', async function () {
const device = await createDevice({ name: 'Ad2', type: '', team: TestObjects.ATeam.hashid, as: TestObjects.tokens.alice })
const response = await app.inject({
method: 'PUT',
url: `/api/v1/devices/${device.id}/settings`,
body: {
palette: {
npmrc: '; testing',
catalogues: ['http://example.com/catalog.json']
}
},
cookies: { sid: TestObjects.tokens.alice }
})
response.statusCode.should.equal(200)
response.json().should.have.property('status', 'okay')

const settingsResponse = await app.inject({
method: 'GET',
url: `/api/v1/devices/${device.id}/settings`,
cookies: { sid: TestObjects.tokens.alice }
})

const settings = settingsResponse.json()
settings.should.have.property('palette')
settings.palette.should.have.property('npmrc', '; testing')
settings.palette.should.have.property('catalogues')
settings.palette.catalogues.should.have.length(1)
settings.palette.catalogues[0].should.equal('http://example.com/catalog.json')
})
})

describe('device remote editor (unlicensed)', function () {
Expand Down

0 comments on commit b5326bd

Please sign in to comment.