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

Add streaming logs from device agents #1900

Merged
merged 18 commits into from
Apr 4, 2023
Merged
Show file tree
Hide file tree
Changes from 3 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
8 changes: 6 additions & 2 deletions forge/comms/aclManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ module.exports = function (app) {
{ topic: /^ff\/v1\/[^/]+\/l\/[^/]+\/status$/ },
// Receive status events from devices
// - ff/v1/+/d/+/status
{ topic: /^ff\/v1\/[^/]+\/d\/[^/]+\/status$/ }
{ topic: /^ff\/v1\/[^/]+\/d\/[^/]+\/status$/ },
// - ff/v1/+/d/+/logs
{ topic: /^ff\/v1\/[^/]+\/d\/[^/]+\/logs$/ }
],
pub: [
// Send commands to project launchers
Expand Down Expand Up @@ -112,7 +114,9 @@ module.exports = function (app) {
pub: [
// Send status to the platform
// - ff/v1/<team>/d/<device>/status
{ topic: /^ff\/v1\/([^/]+)\/d\/([^/]+)\/status$/, verify: 'checkTeamAndObjectIds' }
{ topic: /^ff\/v1\/([^/]+)\/d\/([^/]+)\/status$/, verify: 'checkTeamAndObjectIds' },
// - ff/v1/<team>/d/<device/logs
{ topic: /^ff\/v1\/([^/]+)\/d\/([^/]+)\/logs$/, verify: 'checkTeamAndObjectIds' }
]
}
}
Expand Down
20 changes: 15 additions & 5 deletions forge/comms/commsClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,23 +35,33 @@ class CommsClient extends EventEmitter {
const topicParts = topic.split('/')
const ownerType = topicParts[3]
const ownerId = topicParts[4]
const messageType = topicParts[5]
if (ownerType === 'p') {
this.emit('status/project', {
id: ownerId,
status: message.toString()
})
} else if (ownerType === 'd') {
this.emit('status/device', {
id: ownerId,
status: message.toString()
})
if (messageType === 'status') {
this.emit('status/device', {
id: ownerId,
status: message.toString()
})
} else if (messageType === 'logs') {
this.emit('logs/device', {
id: ownerId,
logs: message.toString()
})
}
}
})
this.client.subscribe([
// Launcher status
'ff/v1/+/l/+/status',
// Device status
'ff/v1/+/d/+/status'
'ff/v1/+/d/+/status',
// Device logs
'ff/v1/+/d/+/logs'
])
}
}
Expand Down
39 changes: 39 additions & 0 deletions forge/comms/devices.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ class DeviceCommsHandler {
this.app = app
this.client = client

this.deviceLogClients = {}

// Listen for any incoming device status events
client.on('status/device', (status) => { this.handleStatus(status) })
client.on('logs/device', (log) => { this.forwardLog(log) })
}

async handleStatus (status) {
Expand Down Expand Up @@ -82,6 +85,42 @@ class DeviceCommsHandler {
...payload
}))
}

/**
* Steam logs to web from devices
* @param {String} teamId
* @param {String} deviceId
* @param {WebSocket} socket
*/
streamLogs (teamId, deviceId, socket) {
if (this.deviceLogClients[deviceId]) {
this.deviceLogClients[deviceId].counter++
} else {
this.deviceLogClients[deviceId] = {
counter: 1,
socket
}
this.sendCommand(teamId, deviceId, 'startLog', '')
this.app.log.info(`Enable device logging ${deviceId}`)
}

socket.on('close', () => {
if (this.deviceLogClients[deviceId]?.counter === 1) {
delete this.deviceLogClients[deviceId]
this.sendCommand(teamId, deviceId, 'stopLog', '')
this.app.log.info(`Disable device logging ${deviceId}`)
}
})
}

forwardLog (log) {
const dev = this.deviceLogClients[log.id]
if (dev) {
dev.socket.send(log.logs)
} else {
// socket not found
}
}
}

module.exports = {
Expand Down
8 changes: 8 additions & 0 deletions forge/routes/api/device.js
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,14 @@ module.exports = async function (app) {
})
}
})

app.get('/:deviceId/logs', {
websocket: true,
preHandler: app.needsPermission('device:read')
}, async (connection, request) => {
const team = await app.db.models.Team.byId(request.device.TeamId)
app.comms.devices.streamLogs(team.hashid, request.device.hashid, connection.socket)
})
}
async function assignDeviceToProject (device, project) {
await device.setProject(project)
Expand Down
1 change: 1 addition & 0 deletions forge/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
*/
const fp = require('fastify-plugin')
module.exports = fp(async function (app, opts, done) {
await app.register(require('@fastify/websocket'))
await app.register(require('./auth'), { logLevel: app.config.logging.http })
await app.register(require('./api'), { prefix: '/api/v1', logLevel: app.config.logging.http })
await app.register(require('./ui'), { logLevel: app.config.logging.http })
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/api/devices.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ const updateSettings = async (deviceId, settings) => {
})
}

const startLogs = async (deviceId) => {}

const stopLogs = async (deviceId) => {}

Steve-Mcl marked this conversation as resolved.
Show resolved Hide resolved
export default {
create,
getDevice,
Expand All @@ -60,5 +64,7 @@ export default {
updateDevice,
generateCredentials,
getSettings,
updateSettings
updateSettings,
startLogs,
stopLogs
Steve-Mcl marked this conversation as resolved.
Show resolved Hide resolved
}
27 changes: 27 additions & 0 deletions frontend/src/pages/device/Logs.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<template>
<SectionTopMenu hero="Device Logs" help-header="FlowForge - Device Logs" info="Live logs from your FlowForge instances of Node-RED">
<template>
</template>
<template #tools>
</template>
</SectionTopMenu>
<LogsShared :device="device" :instance="instance"/>
</template>

<script>
import SectionTopMenu from '../../components/SectionTopMenu'
import LogsShared from './components/DeviceLog'

export default {
name: 'DeviceLogs',
components: {
SectionTopMenu,
LogsShared
},
props: [
'device',
'instance'
],
inheritAttrs: false
}
</script>
103 changes: 103 additions & 0 deletions frontend/src/pages/device/components/DeviceLog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<template>
<ff-loading v-if="loading" message="Loading Logs..." />
<div v-if="device?.status && device?.status !== 'stopped'" class="mx-auto text-xs border bg-gray-800 text-gray-200 rounded p-2 font-mono">
<div v-for="(item, itemIdx) in logEntries" :key="itemIdx" class="flex" :class="'forge-log-entry-level-' + item.level">
<div class="w-40 flex-shrink-0">{{ item.date }}</div>
<div class="w-20 flex-shrink-0 align-right">[{{ item.level }}]</div>
<div class="flex-grow break-all whitespace-pre-wrap">{{ item.msg.replace(/^[\n]*/, '') }}</div>
</div>
</div>
<div v-else >
Logs unavailable
</div>
</template>

<script>
import DeviceApi from '@/api/devices'

export default {
name: 'DeviceLogView',
inheritAttrs: false,
props: {
device: {
type: Object,
required: true
},
instance: {
type: Object,
required: true
}
},
data () {
return {
loading: true,
logEntries: [],
prevCursor: null,
nextCursor: null,
keepAliveInterval: null,
connection: null
}
},
mounted () {
// need to subscribe to log stream
this.connect()
},
unmounted () {
// need to unsubscribe here
this.disconnect()
clearInterval(this.keepAliveInterval)
},
methods: {
connect: async function () {
await DeviceApi.startLogs(this.device.id)
Steve-Mcl marked this conversation as resolved.
Show resolved Hide resolved
console.log('connect')
hardillb marked this conversation as resolved.
Show resolved Hide resolved
// this.keepAliveInterval = setInterval(() => {
// this.connect(this.device.id)
// }, 10000)
hardillb marked this conversation as resolved.
Show resolved Hide resolved
if (this.connection === null) {
try {
const protocol = location.protocol === 'http:' ? 'ws:' : 'wss:'
console.log(`connecting to ${protocol}//${location.host}/api/v1/devices/${this.device.id}/logs`)
hardillb marked this conversation as resolved.
Show resolved Hide resolved
this.connection = new WebSocket(`${protocol}//${location.host}/api/v1/devices/${this.device.id}/logs`)
this.connection.onopen = () => {
this.loading = false
}
this.connection.onmessage = message => {
const m = JSON.parse(message.data)
if (!Array.isArray(m)) {
if (!isNaN(m.ts)) {
m.ts = `${m.ts}`
}
console.log(m)
hardillb marked this conversation as resolved.
Show resolved Hide resolved
const d = new Date(parseInt(m.ts.substring(0, m.ts.length - 4)))
m.date = `${d.toLocaleDateString()} ${d.toLocaleTimeString()}`
this.logEntries.push(m)
} else {
console.log('array')
hardillb marked this conversation as resolved.
Show resolved Hide resolved
m.forEach(row => {
if (!isNaN(row.ts)) {
row.ts = `${row.ts}`
}
const d = new Date(parseInt(row.ts.substring(0, row.ts.length - 4)))
row.date = `${d.toLocaleDateString()} ${d.toLocaleTimeString()}`
this.logEntries.push(row)
})
}
}
} catch (e) {
console.log(e)
}
}
},
disconnect: async function () {
DeviceApi.stopLogs(this.device.id)
Steve-Mcl marked this conversation as resolved.
Show resolved Hide resolved
console.log('disconnect')
hardillb marked this conversation as resolved.
Show resolved Hide resolved
if (this.connection) {
this.connection.close()
}
this.connection = null
}
}
}

</script>
19 changes: 16 additions & 3 deletions frontend/src/pages/device/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
<div class="ff-banner" data-el="banner-device-as-admin">You are viewing this device as an Administrator</div>
</Teleport>
<div class="px-3 pb-3 md:px-6 md:pb-6">
<router-view :device="device" @device-updated="loadDevice()"></router-view>
<router-view :instance="device.project" :device="device" @device-updated="loadDevice()"></router-view>
</div>
</div>
</main>
Expand All @@ -64,7 +64,7 @@ import SubscriptionExpiredBanner from '@/components/banners/SubscriptionExpired.
import TeamTrialBanner from '@/components/banners/TeamTrial.vue'

// icons
import { ChipIcon, CogIcon } from '@heroicons/vue/solid'
import { ChipIcon, CogIcon, TerminalIcon } from '@heroicons/vue/solid'

export default {
name: 'DevicePage',
Expand All @@ -80,6 +80,7 @@ export default {
data: function () {
const navigation = [
{ label: 'Overview', path: `/device/${this.$route.params.id}/overview`, tag: 'device-overview', icon: ChipIcon },
// { label: 'Device Logs', path: `/device/${this.$route.params.id}/logs`, tag: 'device-logs', icon: TerminalIcon },
{ label: 'Settings', path: `/device/${this.$route.params.id}/settings`, tag: 'device-settings', icon: CogIcon }
]

Expand All @@ -90,12 +91,13 @@ export default {
}
},
computed: {
...mapState('account', ['teamMembership', 'team']),
...mapState('account', ['teamMembership', 'team', 'features']),
isVisitingAdmin: function () {
return this.teamMembership.role === Roles.Admin
}
},
mounted () {
this.checkFeatures()
this.mounted = true
this.loadDevice()
},
Expand All @@ -104,6 +106,17 @@ export default {
const device = await deviceApi.getDevice(this.$route.params.id)
this.device = device
this.$store.dispatch('account/setTeam', this.device.team.slug)
},
checkFeatures: async function () {
console.log(this.features)
hardillb marked this conversation as resolved.
Show resolved Hide resolved
if (this.features.projectComms) {
this.navigation.splice(1, 0, {
label: 'Device Logs',
path: `/device/${this.$route.params.id}/logs`,
tag: 'device-logs',
icon: TerminalIcon
})
}
}
}
}
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/pages/device/routes.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Device from '@/pages/device/index.vue'
import DeviceOverview from '@/pages/device/Overview.vue'
import DeviceSettings from '@/pages/device/Settings/index.vue'
import DeviceLogs from '@/pages/device/Logs.vue'

import DeviceSettingsGeneral from '@/pages/device/Settings/General.vue'
import DeviceSettingsEnvironment from '@/pages/device/Settings/Environment.vue'
Expand Down Expand Up @@ -33,6 +34,13 @@ export default [
{ path: 'environment', component: DeviceSettingsEnvironment },
{ path: 'danger', component: DeviceSettingsDanger }
]
},
{
path: 'logs',
component: DeviceLogs,
meta: {
title: 'Device - Logs'
}
}
]
}
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"@fastify/helmet": "^10.0.2",
"@fastify/passport": "^2.2.0",
"@fastify/static": "^6.5.0",
"@fastify/websocket": "^7.2.0",
"@flowforge/forge-ui-components": "^0.5.5",
"@flowforge/localfs": "^1.5.0",
"@headlessui/vue": "1.7.12",
Expand Down