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

Allow .npmrc and calalogue urls to be set for Application bound devices #3643

Merged
merged 13 commits into from
Apr 10, 2024
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
Loading