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
6 changes: 5 additions & 1 deletion frontend/src/components/ResizeBar.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<div class="resize-bar" :class="{horizontal: isHorizontal}" />
<div class="resize-bar" :class="{horizontal: isHorizontal, resizing: isResizing}" />
</template>

<script>
Expand All @@ -9,6 +9,10 @@ export default {
direction: {
type: String,
default: 'vertical' // vertical || horizontal
},
isResizing: {
type: Boolean,
default: false
}
},
computed: {
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/components/expert/ExpertChatInput.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<template>
<div ref="resizeTarget" class="ff-expert-input" :style="{height: heightStyle}">
<resize-bar
:is-resizing="isInputResizing"
direction="horizontal"
@mousedown="startResize"
/>
Expand Down Expand Up @@ -105,12 +106,13 @@ export default {
},
emits: ['send', 'stop', 'start-over'],
setup () {
const { startResize, heightStyle, bindResizer } = useResizingHelper()
const { startResize, heightStyle, bindResizer, isResizing: isInputResizing } = useResizingHelper()

return {
startResize,
bindResizer,
heightStyle
heightStyle,
isInputResizing
}
},
data () {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,14 @@ export default {
isDeviceRunning () {
return this.computedStatus === 'running'
},
isEditorAvailable () {
return Object.prototype.hasOwnProperty.call(this.device, 'editor') &&
Object.prototype.hasOwnProperty.call(this.device.editor, 'connected') &&
this.device.editor.connected
},
computedStatus () {
if (!this.device || !Object.prototype.hasOwnProperty.call(this.device, 'editor')) {
if (!this.device || !this.isEditorAvailable) {
// forces the loading animation while loading
return 'loading'
}

Expand Down
79 changes: 55 additions & 24 deletions frontend/src/pages/device/Editor/index.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
<template>
<div ref="resizeTarget" class="ff--immersive-editor-wrapper" :class="{resizing: isEditorResizing}">
<EditorWrapper
:url="device?.editor?.url"
:disable-events="isEditorResizing"
:device="device"
/>
Expand All @@ -22,7 +21,10 @@

<div class="header">
<div class="logo">
<router-link title="Back to remote instance overview" :to="{ name: 'device-overview', params: {id: device.id} }">
<router-link
title="Back to remote instance overview"
:to="{ name: 'device-overview', params: {id: device.id} }"
>
<ArrowLeftIcon class="ff-btn--icon" />
</router-link>
</div>
Expand Down Expand Up @@ -123,7 +125,7 @@ export default {
agentSupportsActions: null,
device: null,
openingTunnel: false,
openTunnelTimeout: null
ws: null
}
},
computed: {
Expand All @@ -135,11 +137,20 @@ export default {
isDevModeAvailable: function () {
return !!this.features.deviceEditor
},
isEditorAvailable () {
return this.device &&
Object.prototype.hasOwnProperty.call(this.device, 'editor') &&
Object.prototype.hasOwnProperty.call(this.device.editor, 'connected') &&
this.device.editor.connected
},
navigation () {
return [
{
label: 'Expert',
to: { name: 'device-editor-expert', params: { id: this.device.id } },
to: {
name: 'device-editor-expert',
params: { id: this.device.id }
},
tag: 'device-expert',
icon: ExpertTabIcon,
hidden: !this.featuresCheck.isExpertAssistantFeatureEnabled
Expand Down Expand Up @@ -176,24 +187,27 @@ export default {
label: 'Settings',
to: { name: 'device-editor-settings' },
tag: 'device-settings'
},
{
label: 'Developer Mode',
to: { name: 'device-editor-developer-mode' },
tag: 'device-devmode',
hidden: !(this.isDevModeAvailable && this.device.mode === 'developer')
}
// {
// label: 'Developer Mode',
// to: { name: 'device-editor-developer-mode' },
// tag: 'device-devmode',
// hidden: !(this.isDevModeAvailable && this.device.mode === 'developer')
// }
]
}
},
watch: {
device (device) {
if (device && Object.prototype.hasOwnProperty.call(device, 'editor')) {
if (device && this.isEditorAvailable) {
this.setContextDevice(device)
this.pollDeviceComms()
this.runInitialTease()
} else {
Alerts.emit('Unable to connect to the Remote Instance', 'warning')

setTimeout(() => this.$router.push({ name: 'device-overview' }), 2000)
this.closeComms()
this.$router.push({ name: 'device-overview' })
.then(() => Alerts.emit('Unable to connect to the Remote Instance', 'warning'))
.catch(e => e)
}
}
},
Expand All @@ -217,9 +231,9 @@ export default {
})
})
.catch(err => err)
.finally(() => {
this.runInitialTease()
})
},
beforeUnmount () {
this.closeComms()
},
methods: {
...mapActions('context', { setContextDevice: 'setDevice' }),
Expand All @@ -228,22 +242,39 @@ export default {
this.device = await deviceApi.getDevice(this.$route.params.id)
} catch (err) {
if (err.status === 403) {
return this.$router.push({ name: 'Home' })
return this.$router.push({ name: 'device-overview' })
}
} finally {
this.loading = false
}

this.agentSupportsDeviceAccess = this.device.agentVersion && semver.gte(this.device.agentVersion, '0.8.0')
this.agentSupportsActions = this.device.agentVersion && semver.gte(this.device.agentVersion, '2.3.0')

// todo we first need to get the device and set the team afterwards
await this.$store.dispatch('account/setTeam', this.device.team.slug)
},
pollDeviceComms () {
if (!this.isEditorAvailable || this.ws) return

const uri = `/api/v1/devices/${this.device.id}/editor/proxy/comms`

this.ws = new WebSocket(uri)

this.ws.addEventListener('error', this.handleCommsDisconnect)
this.ws.addEventListener('close', this.handleCommsDisconnect)
},
handleCommsDisconnect () {
this.$router.push({ name: 'device-overview' })
.then(() => Alerts.emit('Disconnected from remote instance.', 'warning'))
.catch(e => e)
},
closeComms () {
if (this.ws) {
this.ws.removeEventListener('error', this.handleCommsDisconnect)
this.ws.removeEventListener('close', this.handleCommsDisconnect)
this.ws.close()
this.ws = null
}
}
}
}
</script>

<style scoped lang="scss">

</style>
6 changes: 3 additions & 3 deletions frontend/src/pages/device/Overview.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>
<div class="ff-device-overview grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="flex flex-col gap-4">
<div class="ff-device-overview flex gap-4 flex-wrap">
<div class="flex flex-1 flex-col gap-4">
<InfoCard header="Connection:">
<template #icon>
<WifiIcon />
Expand Down Expand Up @@ -154,7 +154,7 @@
</template>
</InfoCard>
</div>
<div>
<div class="flex-1">
<FormHeading>
<div class="flex gap-2 items-center text-xl">
<TrendingUpIcon class="ff-icon" />Recent Activity
Expand Down
76 changes: 67 additions & 9 deletions frontend/src/pages/device/VersionHistory/index.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
<template>
<SectionTopMenu>
<template #hero>
<toggle-button-group :buttons="pageToggle" data-nav="page-toggle" title="View" />
<template v-if="!isInImmersiveMode" #hero>
<toggle-button-group :buttons="pageToggle" data-nav="page-toggle" title="View" :visually-hide-title="true" />
</template>
<template #pictogram>
<template v-if="!isInImmersiveMode" #pictogram>
<img v-if="$route.name.includes('timeline')" alt="info" src="../../../images/pictograms/timeline_red.png">
<img v-else-if="$route.name.includes('snapshots')" alt="info" src="../../../images/pictograms/snapshot_red.png">
</template>
<template #helptext>
<template v-if="!isInImmersiveMode" #helptext>
<template v-if="$route.name.includes('timeline')">
<p>The <b>Timeline</b> provides a concise, chronological view of key activities within your Node-RED instance.</p>
<p>It tracks various events such as pipeline stage deployments, snapshot restorations, flow deployments, snapshot creations, and updates to instance settings.</p>
Expand All @@ -20,7 +20,7 @@
</template>
</template>
<template #tools>
<section class="flex gap-2 items-center self-center">
<section class="flex gap-2 items-center self-center flex-wrap">
<ff-checkbox
v-model="showDeviceSnapshotsOnly"
v-ff-tooltip:left="'Untick this to show snapshots from other Instances within this application'"
Expand All @@ -35,7 +35,8 @@
:disabled="busy || isOwnedByAnInstance || isUnassigned"
@click="showImportSnapshotDialog"
>
<template #icon-left><UploadIcon /></template>Upload Snapshot
<template #icon-left><UploadIcon /></template>
<span class="hidden sm:inline upload-snapshot-text">Upload Snapshot</span>
</ff-button>
<ff-button
v-if="hasPermission('device:snapshot:create', { application: device.application })"
Expand All @@ -46,7 +47,8 @@
:disabled="!canCreateSnapshot"
@click="showCreateSnapshotDialog"
>
<template #icon-left><PlusSmIcon /></template>Create Snapshot
<template #icon-left><PlusSmIcon /></template>
<span class="hidden sm:inline create-snapshot-text">Create Snapshot</span>
</ff-button>
</section>
</template>
Expand Down Expand Up @@ -128,8 +130,24 @@ export default {
return {
reloadHooks: [],
pageToggle: [
{ title: 'Snapshots', to: { name: 'device-snapshots', params: this.$route.params } },
{ title: 'Timeline', to: { name: 'device-version-history-timeline', params: this.$route.params } }
{
title: 'Snapshots',
to: {
name: (() => (this.$route.name.startsWith('device-editor')
? 'device-editor-snapshots'
: 'device-snapshots'))(),
params: this.$route.params
}
},
{
title: 'Timeline',
to: {
name: (() => (this.$route.name.startsWith('device-editor')
? 'device-editor-version-history-timeline'
: 'device-version-history-timeline'))(),
params: this.$route.params
}
}
],
showDeviceSnapshotsOnly: true,
busyMakingSnapshot: false,
Expand Down Expand Up @@ -163,6 +181,9 @@ export default {
return 'Instance must be owned by an Application to create a Snapshot'
}
return !this.canCreateSnapshot ? 'Instance must be in \'Developer Mode\' to create a Snapshot' : 'Capture a Snapshot of this Instance.'
},
isInImmersiveMode () {
return this.$route.name.startsWith('device-editor-')
}
},
methods: {
Expand Down Expand Up @@ -211,4 +232,41 @@ export default {
.page-fade-enter, .page-fade-leave-to {
opacity: 0;
}

// Viewport-based responsive behavior (matches Tailwind sm: breakpoint)
// Hide button text on narrow viewports (< 640px)
@media (max-width: 639px) {
.upload-snapshot-text,
.create-snapshot-text {
display: none;
}
}

// Show button text on wider viewports (>= 640px)
@media (min-width: 640px) {
.upload-snapshot-text,
.create-snapshot-text {
display: inline;
}
}

// Container query for drawer context - responsive button behavior
// Breakpoint matches DRAWER_MOBILE_BREAKPOINT constant in Editor/index.vue
// These override viewport-based rules when inside the drawer
@container drawer (max-width: 639px) {
// Hide text when drawer is narrow - icon-only mode
.upload-snapshot-text,
.create-snapshot-text {
display: none;
}
}

@container drawer (min-width: 640px) {
// Show text when drawer is wide enough
.upload-snapshot-text,
.create-snapshot-text {
display: inline;
}
}

</style>
2 changes: 1 addition & 1 deletion frontend/src/pages/device/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,7 @@ export default {
},
openEditor () {
this.$store.dispatch('ux/validateUserAction', 'hasOpenedDeviceEditor')
window.open(this.deviceEditorURL, `device-editor-${this.device.id}`)
this.$router.push({ name: 'device-editor' })
},
async openTunnel (launchEditor = false) {
try {
Expand Down
1 change: 1 addition & 0 deletions frontend/src/pages/instance/Editor/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
@mouseleave="handleDrawerMouseLeave"
>
<resize-bar
:is-resizing="isEditorResizing"
@mousedown="startEditorResize"
/>

Expand Down
20 changes: 18 additions & 2 deletions frontend/src/pages/instance/VersionHistory/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,24 @@ export default {
pageToggle () {
if (this.$route.name.includes('editor')) {
return [
{ title: 'Snapshots', to: { path: './snapshots', params: this.$route.params } },
{ title: 'Timeline', to: { path: './timeline', params: this.$route.params } }
{
title: 'Snapshots',
to: {
name: (() => (this.$route.name.startsWith('instance-editor')
? 'instance-editor-snapshots'
: 'instance-snapshots'))(),
params: this.$route.params
}
},
{
title: 'Timeline',
to: {
name: (() => (this.$route.name.startsWith('instance-editor')
? 'instance-editor-version-history-timeline'
: 'instance-version-history-timeline'))(),
params: this.$route.params
}
}
]
}

Expand Down
Loading
Loading