Skip to content
Merged
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
36 changes: 36 additions & 0 deletions nuxt/components/DeviceAgentInstallCommands.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<script setup lang="ts">
// Ported from src/_includes/components/device-agent-install-commands.njk, for /node-red/.
// /platform/device-agent/ used to share it and has its own <DeviceAgentInstallTabs> now.
//
// Both commands are byte-identical to docs/device-agent/quickstart.md: get.sh and
// get.ps1 only download the installer, so the run step is part of the command. Keep them
// that way, they drifted apart once already.
//
// The .njk carried its own delegated copy-button script, guarded against double-binding
// because two includes on one page would otherwise bind twice. <FfCommand> owns copying
// now, per instance, so that whole script is gone.
withDefaults(defineProps<{
// Phrases the heading for the page. /node-red/ uses it to present this as the
// alternative to signing up.
heading?: string
}>(), {
heading: 'Install on edge, straight from your terminal',
})

const LINUX_COMMAND = '/bin/bash -c "$(curl -fsSL https://flowfuse.github.io/device-agent/get.sh)" && ./flowfuse-device-agent-installer'
const WINDOWS_COMMAND = 'Set-Location $env:USERPROFILE; powershell -c "irm https://flowfuse.github.io/device-agent/get.ps1 | iex"; .\\flowfuse-device-agent-installer.exe'
</script>

<template>
<div class="ff-blue-card max-md:max-w-md max-md:mx-auto">
<h2 class="font-medium w-full text-center md:text-left mb-6">{{ heading }}</h2>
<p class="font-medium mb-2">Linux and macOS</p>
<FfCommand :command="LINUX_COMMAND" class="mb-6" />
<p class="font-medium mb-2">Windows (run elevated)</p>
<FfCommand :command="WINDOWS_COMMAND" />
<p class="font-light mt-6">
Installing with npm or Docker instead? The
<NuxtLink to="/docs/device-agent/install/overview/">installation docs</NuxtLink> cover every route.
</p>
</div>
</template>
51 changes: 51 additions & 0 deletions nuxt/lib/certified-nodes.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Turns the two FlowFuse Certified catalogue feeds into the cards /node-red/ renders.
// Kept free of Nuxt imports so it can be unit tested with `node --test`;
// server/api/certified-nodes.get.ts is the only caller and does the fetching.

// The catalogues carry no display `name`, and the ids are lowercase slugs, so
// title-casing alone gives "Opcua" and "Rtsp". Acronyms cannot be inferred.
const NAME_OVERRIDES = {
'@flowfuse-certified-nodes/opcua': 'OPC UA',
'@flowfuse-certified-nodes/rtsp': 'RTSP',
'@flowfuse-certified-nodes/cip-suite': 'CIP Suite',
}

// Last resort when there is no override: "@flowfuse-certified-nodes/google-sheets"
// becomes "Google Sheets", which reads correctly for names that are ordinary words
// ("Redis", "Modbus", "Kafka").
function displayNameFromId (id) {
return (id.split('/').pop() ?? id)
.split('-')
.map(word => word.replace(/^\w/, char => char.toUpperCase()))
.join(' ')
}

// Not entry.name: normalizeCatalogueModule falls back to the id's last segment when the
// feed carries no name, which every one of these does, so trusting it would print
// "modbus" and "rtsp" rather than "Modbus" and "RTSP".
export function certifiedNodeName (id) {
return NAME_OVERRIDES[id] ?? displayNameFromId(id)
}

// One card per (node, collection) pair, because the badge names the collection and a node
// can be certified in both. That is what the .njk rendered. `feeds` is one entry list per
// catalogue, as fetchCertifiedCatalogues() returns them.
export function certifiedNodeCards (feeds) {
const cards = feeds.flat().flatMap(entry => (entry.collections ?? []).map(collection => ({
id: entry._id,
name: certifiedNodeName(entry._id),
description: entry.description,
version: entry.version,
collection,
// docsUrl is already normalised (flowfuse.com paths only, trailing slash added,
// per-node overrides applied); the collection index is the fallback, as it was in
// the .njk. Some publisher URLs still point at /node-red/flowfuse/… paths that now
// 301 into /docs/ - that is true of production too, and it is the catalogue
// entries that need updating, not this.
docsPath: entry.docsUrl ?? `/docs/flowfuse-nodes/${collection}/`,
})))

// Stable, so a node certified in both collections keeps feed order: Hub, then Edge.
cards.sort((a, b) => a.name.localeCompare(b.name))
return cards
}
72 changes: 72 additions & 0 deletions nuxt/lib/certified-nodes.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'

import { certifiedNodeCards, certifiedNodeName } from './certified-nodes.mjs'

const SCOPE = '@flowfuse-certified-nodes'

function entry (slug, collections, extra = {}) {
return {
_id: `${SCOPE}/${slug}`,
name: slug,
description: `${slug} description`,
version: '1.0.0',
collections,
...extra,
}
}

test('acronyms come from the override map', () => {
assert.equal(certifiedNodeName(`${SCOPE}/opcua`), 'OPC UA')
assert.equal(certifiedNodeName(`${SCOPE}/rtsp`), 'RTSP')
assert.equal(certifiedNodeName(`${SCOPE}/cip-suite`), 'CIP Suite')
})

test('other slugs are title-cased word by word', () => {
assert.equal(certifiedNodeName(`${SCOPE}/modbus`), 'Modbus')
assert.equal(certifiedNodeName(`${SCOPE}/google-sheets`), 'Google Sheets')
assert.equal(certifiedNodeName('redis'), 'Redis')
})

test('the catalogue name is ignored, since it is only the lowercase slug', () => {
const [card] = certifiedNodeCards([[entry('rtsp', ['edge'])]])
assert.equal(card.name, 'RTSP')
})

test('a node certified in two collections gets one card per collection', () => {
const cards = certifiedNodeCards([[entry('modbus', ['hub', 'edge'])]])
assert.deepEqual(cards.map(card => card.collection), ['hub', 'edge'])
assert.ok(cards.every(card => card.id === `${SCOPE}/modbus`))
})

test('an entry with no collections gets no card', () => {
assert.deepEqual(certifiedNodeCards([[entry('kafka', undefined), entry('redis', [])]]), [])
})

test('docsPath keeps the normalised docsUrl and falls back to the collection index', () => {
const [withDocs, withoutDocs] = certifiedNodeCards([[
entry('kafka', ['hub'], { docsUrl: '/docs/flowfuse-nodes/hub/kafka/' }),
entry('modbus', ['edge']),
]])
assert.equal(withDocs.docsPath, '/docs/flowfuse-nodes/hub/kafka/')
assert.equal(withoutDocs.docsPath, '/docs/flowfuse-nodes/edge/')
})

test('cards from both feeds are sorted together by display name', () => {
const hub = [entry('redis', ['hub']), entry('opcua', ['hub'])]
const edge = [entry('modbus', ['edge']), entry('cip-suite', ['edge'])]
assert.deepEqual(
certifiedNodeCards([hub, edge]).map(card => card.name),
['CIP Suite', 'Modbus', 'OPC UA', 'Redis'],
)
})

test('the same node in both feeds stays in feed order after sorting', () => {
const cards = certifiedNodeCards([[entry('kafka', ['hub'])], [entry('kafka', ['edge'])]])
assert.deepEqual(cards.map(card => card.collection), ['hub', 'edge'])
})

test('no feeds, or empty feeds after a fetch failure, give no cards', () => {
assert.deepEqual(certifiedNodeCards([]), [])
assert.deepEqual(certifiedNodeCards([[], []]), [])
})
2 changes: 2 additions & 0 deletions nuxt/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,8 @@ export default defineNuxtConfig({

...collectSlugRoutes(join(__dirname, 'content/vs'), '/vs'),

'/node-red/',

'/about/',

...collectSlugRoutes(join(__dirname, 'content/landing'), '/landing'),
Expand Down
Loading
Loading