Skip to content

Commit e788779

Browse files
authored
Merge pull request #6647 from FlowFuse/6645_handle-redirect-when-accessing-unreachable-remote-instances
Enhance remote instance editor with connection polling, communication…
2 parents 49957b6 + 8bbfd58 commit e788779

10 files changed

Lines changed: 165 additions & 47 deletions

File tree

frontend/src/components/ResizeBar.vue

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<template>
2-
<div class="resize-bar" :class="{horizontal: isHorizontal}" />
2+
<div class="resize-bar" :class="{horizontal: isHorizontal, resizing: isResizing}" />
33
</template>
44

55
<script>
@@ -9,6 +9,10 @@ export default {
99
direction: {
1010
type: String,
1111
default: 'vertical' // vertical || horizontal
12+
},
13+
isResizing: {
14+
type: Boolean,
15+
default: false
1216
}
1317
},
1418
computed: {

frontend/src/components/expert/ExpertChatInput.vue

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<template>
22
<div ref="resizeTarget" class="ff-expert-input" :style="{height: heightStyle}">
33
<resize-bar
4+
:is-resizing="isInputResizing"
45
direction="horizontal"
56
@mousedown="startResize"
67
/>
@@ -105,12 +106,13 @@ export default {
105106
},
106107
emits: ['send', 'stop', 'start-over'],
107108
setup () {
108-
const { startResize, heightStyle, bindResizer } = useResizingHelper()
109+
const { startResize, heightStyle, bindResizer, isResizing: isInputResizing } = useResizingHelper()
109110
110111
return {
111112
startResize,
112113
bindResizer,
113-
heightStyle
114+
heightStyle,
115+
isInputResizing
114116
}
115117
},
116118
data () {

frontend/src/components/immersive-editor/RemoteInstanceEditorWrapper.vue

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,14 @@ export default {
4141
isDeviceRunning () {
4242
return this.computedStatus === 'running'
4343
},
44+
isEditorAvailable () {
45+
return Object.prototype.hasOwnProperty.call(this.device, 'editor') &&
46+
Object.prototype.hasOwnProperty.call(this.device.editor, 'connected') &&
47+
this.device.editor.connected
48+
},
4449
computedStatus () {
45-
if (!this.device || !Object.prototype.hasOwnProperty.call(this.device, 'editor')) {
50+
if (!this.device || !this.isEditorAvailable) {
51+
// forces the loading animation while loading
4652
return 'loading'
4753
}
4854

frontend/src/pages/device/Editor/index.vue

Lines changed: 55 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
<template>
22
<div ref="resizeTarget" class="ff--immersive-editor-wrapper" :class="{resizing: isEditorResizing}">
33
<EditorWrapper
4-
:url="device?.editor?.url"
54
:disable-events="isEditorResizing"
65
:device="device"
76
/>
@@ -22,7 +21,10 @@
2221

2322
<div class="header">
2423
<div class="logo">
25-
<router-link title="Back to remote instance overview" :to="{ name: 'device-overview', params: {id: device.id} }">
24+
<router-link
25+
title="Back to remote instance overview"
26+
:to="{ name: 'device-overview', params: {id: device.id} }"
27+
>
2628
<ArrowLeftIcon class="ff-btn--icon" />
2729
</router-link>
2830
</div>
@@ -123,7 +125,7 @@ export default {
123125
agentSupportsActions: null,
124126
device: null,
125127
openingTunnel: false,
126-
openTunnelTimeout: null
128+
ws: null
127129
}
128130
},
129131
computed: {
@@ -135,11 +137,20 @@ export default {
135137
isDevModeAvailable: function () {
136138
return !!this.features.deviceEditor
137139
},
140+
isEditorAvailable () {
141+
return this.device &&
142+
Object.prototype.hasOwnProperty.call(this.device, 'editor') &&
143+
Object.prototype.hasOwnProperty.call(this.device.editor, 'connected') &&
144+
this.device.editor.connected
145+
},
138146
navigation () {
139147
return [
140148
{
141149
label: 'Expert',
142-
to: { name: 'device-editor-expert', params: { id: this.device.id } },
150+
to: {
151+
name: 'device-editor-expert',
152+
params: { id: this.device.id }
153+
},
143154
tag: 'device-expert',
144155
icon: ExpertTabIcon,
145156
hidden: !this.featuresCheck.isExpertAssistantFeatureEnabled
@@ -176,24 +187,27 @@ export default {
176187
label: 'Settings',
177188
to: { name: 'device-editor-settings' },
178189
tag: 'device-settings'
179-
},
180-
{
181-
label: 'Developer Mode',
182-
to: { name: 'device-editor-developer-mode' },
183-
tag: 'device-devmode',
184-
hidden: !(this.isDevModeAvailable && this.device.mode === 'developer')
185190
}
191+
// {
192+
// label: 'Developer Mode',
193+
// to: { name: 'device-editor-developer-mode' },
194+
// tag: 'device-devmode',
195+
// hidden: !(this.isDevModeAvailable && this.device.mode === 'developer')
196+
// }
186197
]
187198
}
188199
},
189200
watch: {
190201
device (device) {
191-
if (device && Object.prototype.hasOwnProperty.call(device, 'editor')) {
202+
if (device && this.isEditorAvailable) {
192203
this.setContextDevice(device)
204+
this.pollDeviceComms()
205+
this.runInitialTease()
193206
} else {
194-
Alerts.emit('Unable to connect to the Remote Instance', 'warning')
195-
196-
setTimeout(() => this.$router.push({ name: 'device-overview' }), 2000)
207+
this.closeComms()
208+
this.$router.push({ name: 'device-overview' })
209+
.then(() => Alerts.emit('Unable to connect to the Remote Instance', 'warning'))
210+
.catch(e => e)
197211
}
198212
}
199213
},
@@ -217,9 +231,9 @@ export default {
217231
})
218232
})
219233
.catch(err => err)
220-
.finally(() => {
221-
this.runInitialTease()
222-
})
234+
},
235+
beforeUnmount () {
236+
this.closeComms()
223237
},
224238
methods: {
225239
...mapActions('context', { setContextDevice: 'setDevice' }),
@@ -228,22 +242,39 @@ export default {
228242
this.device = await deviceApi.getDevice(this.$route.params.id)
229243
} catch (err) {
230244
if (err.status === 403) {
231-
return this.$router.push({ name: 'Home' })
245+
return this.$router.push({ name: 'device-overview' })
232246
}
233-
} finally {
234-
this.loading = false
235247
}
236248
237249
this.agentSupportsDeviceAccess = this.device.agentVersion && semver.gte(this.device.agentVersion, '0.8.0')
238250
this.agentSupportsActions = this.device.agentVersion && semver.gte(this.device.agentVersion, '2.3.0')
239251
240252
// todo we first need to get the device and set the team afterwards
241253
await this.$store.dispatch('account/setTeam', this.device.team.slug)
254+
},
255+
pollDeviceComms () {
256+
if (!this.isEditorAvailable || this.ws) return
257+
258+
const uri = `/api/v1/devices/${this.device.id}/editor/proxy/comms`
259+
260+
this.ws = new WebSocket(uri)
261+
262+
this.ws.addEventListener('error', this.handleCommsDisconnect)
263+
this.ws.addEventListener('close', this.handleCommsDisconnect)
264+
},
265+
handleCommsDisconnect () {
266+
this.$router.push({ name: 'device-overview' })
267+
.then(() => Alerts.emit('Disconnected from remote instance.', 'warning'))
268+
.catch(e => e)
269+
},
270+
closeComms () {
271+
if (this.ws) {
272+
this.ws.removeEventListener('error', this.handleCommsDisconnect)
273+
this.ws.removeEventListener('close', this.handleCommsDisconnect)
274+
this.ws.close()
275+
this.ws = null
276+
}
242277
}
243278
}
244279
}
245280
</script>
246-
247-
<style scoped lang="scss">
248-
249-
</style>

frontend/src/pages/device/Overview.vue

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<template>
2-
<div class="ff-device-overview grid grid-cols-1 sm:grid-cols-2 gap-4">
3-
<div class="flex flex-col gap-4">
2+
<div class="ff-device-overview flex gap-4 flex-wrap">
3+
<div class="flex flex-1 flex-col gap-4">
44
<InfoCard header="Connection:">
55
<template #icon>
66
<WifiIcon />
@@ -154,7 +154,7 @@
154154
</template>
155155
</InfoCard>
156156
</div>
157-
<div>
157+
<div class="flex-1">
158158
<FormHeading>
159159
<div class="flex gap-2 items-center text-xl">
160160
<TrendingUpIcon class="ff-icon" />Recent Activity

frontend/src/pages/device/VersionHistory/index.vue

Lines changed: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
<template>
22
<SectionTopMenu>
3-
<template #hero>
4-
<toggle-button-group :buttons="pageToggle" data-nav="page-toggle" title="View" />
3+
<template v-if="!isInImmersiveMode" #hero>
4+
<toggle-button-group :buttons="pageToggle" data-nav="page-toggle" title="View" :visually-hide-title="true" />
55
</template>
6-
<template #pictogram>
6+
<template v-if="!isInImmersiveMode" #pictogram>
77
<img v-if="$route.name.includes('timeline')" alt="info" src="../../../images/pictograms/timeline_red.png">
88
<img v-else-if="$route.name.includes('snapshots')" alt="info" src="../../../images/pictograms/snapshot_red.png">
99
</template>
10-
<template #helptext>
10+
<template v-if="!isInImmersiveMode" #helptext>
1111
<template v-if="$route.name.includes('timeline')">
1212
<p>The <b>Timeline</b> provides a concise, chronological view of key activities within your Node-RED instance.</p>
1313
<p>It tracks various events such as pipeline stage deployments, snapshot restorations, flow deployments, snapshot creations, and updates to instance settings.</p>
@@ -20,7 +20,7 @@
2020
</template>
2121
</template>
2222
<template #tools>
23-
<section class="flex gap-2 items-center self-center">
23+
<section class="flex gap-2 items-center self-center flex-wrap">
2424
<ff-checkbox
2525
v-model="showDeviceSnapshotsOnly"
2626
v-ff-tooltip:left="'Untick this to show snapshots from other Instances within this application'"
@@ -35,7 +35,8 @@
3535
:disabled="busy || isOwnedByAnInstance || isUnassigned"
3636
@click="showImportSnapshotDialog"
3737
>
38-
<template #icon-left><UploadIcon /></template>Upload Snapshot
38+
<template #icon-left><UploadIcon /></template>
39+
<span class="hidden sm:inline upload-snapshot-text">Upload Snapshot</span>
3940
</ff-button>
4041
<ff-button
4142
v-if="hasPermission('device:snapshot:create', { application: device.application })"
@@ -46,7 +47,8 @@
4647
:disabled="!canCreateSnapshot"
4748
@click="showCreateSnapshotDialog"
4849
>
49-
<template #icon-left><PlusSmIcon /></template>Create Snapshot
50+
<template #icon-left><PlusSmIcon /></template>
51+
<span class="hidden sm:inline create-snapshot-text">Create Snapshot</span>
5052
</ff-button>
5153
</section>
5254
</template>
@@ -128,8 +130,24 @@ export default {
128130
return {
129131
reloadHooks: [],
130132
pageToggle: [
131-
{ title: 'Snapshots', to: { name: 'device-snapshots', params: this.$route.params } },
132-
{ title: 'Timeline', to: { name: 'device-version-history-timeline', params: this.$route.params } }
133+
{
134+
title: 'Snapshots',
135+
to: {
136+
name: (() => (this.$route.name.startsWith('device-editor')
137+
? 'device-editor-snapshots'
138+
: 'device-snapshots'))(),
139+
params: this.$route.params
140+
}
141+
},
142+
{
143+
title: 'Timeline',
144+
to: {
145+
name: (() => (this.$route.name.startsWith('device-editor')
146+
? 'device-editor-version-history-timeline'
147+
: 'device-version-history-timeline'))(),
148+
params: this.$route.params
149+
}
150+
}
133151
],
134152
showDeviceSnapshotsOnly: true,
135153
busyMakingSnapshot: false,
@@ -163,6 +181,9 @@ export default {
163181
return 'Instance must be owned by an Application to create a Snapshot'
164182
}
165183
return !this.canCreateSnapshot ? 'Instance must be in \'Developer Mode\' to create a Snapshot' : 'Capture a Snapshot of this Instance.'
184+
},
185+
isInImmersiveMode () {
186+
return this.$route.name.startsWith('device-editor-')
166187
}
167188
},
168189
methods: {
@@ -211,4 +232,41 @@ export default {
211232
.page-fade-enter, .page-fade-leave-to {
212233
opacity: 0;
213234
}
235+
236+
// Viewport-based responsive behavior (matches Tailwind sm: breakpoint)
237+
// Hide button text on narrow viewports (< 640px)
238+
@media (max-width: 639px) {
239+
.upload-snapshot-text,
240+
.create-snapshot-text {
241+
display: none;
242+
}
243+
}
244+
245+
// Show button text on wider viewports (>= 640px)
246+
@media (min-width: 640px) {
247+
.upload-snapshot-text,
248+
.create-snapshot-text {
249+
display: inline;
250+
}
251+
}
252+
253+
// Container query for drawer context - responsive button behavior
254+
// Breakpoint matches DRAWER_MOBILE_BREAKPOINT constant in Editor/index.vue
255+
// These override viewport-based rules when inside the drawer
256+
@container drawer (max-width: 639px) {
257+
// Hide text when drawer is narrow - icon-only mode
258+
.upload-snapshot-text,
259+
.create-snapshot-text {
260+
display: none;
261+
}
262+
}
263+
264+
@container drawer (min-width: 640px) {
265+
// Show text when drawer is wide enough
266+
.upload-snapshot-text,
267+
.create-snapshot-text {
268+
display: inline;
269+
}
270+
}
271+
214272
</style>

frontend/src/pages/device/index.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,7 @@ export default {
427427
},
428428
openEditor () {
429429
this.$store.dispatch('ux/validateUserAction', 'hasOpenedDeviceEditor')
430-
window.open(this.deviceEditorURL, `device-editor-${this.device.id}`)
430+
this.$router.push({ name: 'device-editor' })
431431
},
432432
async openTunnel (launchEditor = false) {
433433
try {

frontend/src/pages/instance/Editor/index.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
@mouseleave="handleDrawerMouseLeave"
2020
>
2121
<resize-bar
22+
:is-resizing="isEditorResizing"
2223
@mousedown="startEditorResize"
2324
/>
2425

frontend/src/pages/instance/VersionHistory/index.vue

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,24 @@ export default {
121121
pageToggle () {
122122
if (this.$route.name.includes('editor')) {
123123
return [
124-
{ title: 'Snapshots', to: { path: './snapshots', params: this.$route.params } },
125-
{ title: 'Timeline', to: { path: './timeline', params: this.$route.params } }
124+
{
125+
title: 'Snapshots',
126+
to: {
127+
name: (() => (this.$route.name.startsWith('instance-editor')
128+
? 'instance-editor-snapshots'
129+
: 'instance-snapshots'))(),
130+
params: this.$route.params
131+
}
132+
},
133+
{
134+
title: 'Timeline',
135+
to: {
136+
name: (() => (this.$route.name.startsWith('instance-editor')
137+
? 'instance-editor-version-history-timeline'
138+
: 'instance-version-history-timeline'))(),
139+
params: this.$route.params
140+
}
141+
}
126142
]
127143
}
128144

0 commit comments

Comments
 (0)