5
5
*
6
6
* Copyright Oxide Computer Company
7
7
*/
8
+ import cn from 'classnames'
8
9
import { lazy , Suspense , useEffect , useRef , useState } from 'react'
9
- import { Link } from 'react-router-dom'
10
-
11
- import { api } from '@oxide/api'
10
+ import { Link , type LoaderFunctionArgs } from 'react-router-dom'
11
+
12
+ import {
13
+ api ,
14
+ apiQueryClient ,
15
+ instanceCan ,
16
+ usePrefetchedApiQuery ,
17
+ type InstanceState ,
18
+ } from '@oxide/api'
12
19
import { PrevArrow12Icon } from '@oxide/design-system/icons/react'
13
20
14
21
import { EquivalentCliCommand } from '~/components/EquivalentCliCommand'
15
- import { useInstanceSelector } from '~/hooks'
22
+ import { InstanceStatusBadge } from '~/components/StatusBadge'
23
+ import { getInstanceSelector , useInstanceSelector } from '~/hooks/use-params'
16
24
import { Badge , type BadgeColor } from '~/ui/lib/Badge'
17
25
import { Spinner } from '~/ui/lib/Spinner'
18
26
import { cliCmd } from '~/util/cli-cmd'
@@ -36,13 +44,29 @@ const statusMessage: Record<WsState, string> = {
36
44
error : 'error' ,
37
45
}
38
46
47
+ SerialConsolePage . loader = async ( { params } : LoaderFunctionArgs ) => {
48
+ const { project, instance } = getInstanceSelector ( params )
49
+ await apiQueryClient . prefetchQuery ( 'instanceView' , {
50
+ path : { instance } ,
51
+ query : { project } ,
52
+ } )
53
+ return null
54
+ }
55
+
39
56
export function SerialConsolePage ( ) {
40
57
const instanceSelector = useInstanceSelector ( )
41
58
const { project, instance } = instanceSelector
42
59
60
+ const { data : instanceData } = usePrefetchedApiQuery ( 'instanceView' , {
61
+ query : { project } ,
62
+ path : { instance } ,
63
+ } )
64
+
43
65
const ws = useRef < WebSocket | null > ( null )
44
66
45
- const [ connectionStatus , setConnectionStatus ] = useState < WsState > ( 'connecting' )
67
+ const canConnect = instanceCan . serialConsole ( instanceData )
68
+ const initialState = canConnect ? 'connecting' : 'closed'
69
+ const [ connectionStatus , setConnectionStatus ] = useState < WsState > ( initialState )
46
70
47
71
// In dev, React 18 strict mode fires all effects twice for lulz, even ones
48
72
// with no dependencies. In order to prevent the websocket from being killed
@@ -54,6 +78,8 @@ export function SerialConsolePage() {
54
78
// 1a. cleanup runs, nothing happens because socket was not open yet
55
79
// 2. effect runs, but `ws.current` is truthy, so nothing happens
56
80
useEffect ( ( ) => {
81
+ if ( ! canConnect ) return
82
+
57
83
// TODO: error handling if this connection fails
58
84
if ( ! ws . current ) {
59
85
const { project, instance } = instanceSelector
@@ -70,7 +96,7 @@ export function SerialConsolePage() {
70
96
ws . current . close ( )
71
97
}
72
98
}
73
- } , [ instanceSelector ] )
99
+ } , [ instanceSelector , canConnect ] )
74
100
75
101
// Because this one does not look at ready state, just whether the thing is
76
102
// defined, it will remove the event listeners before the spurious second
@@ -81,20 +107,22 @@ export function SerialConsolePage() {
81
107
// 1a. cleanup runs, event listeners removed
82
108
// 2. effect runs again, event listeners attached again
83
109
useEffect ( ( ) => {
110
+ if ( ! canConnect ) return // don't bother if instance is not running
111
+
84
112
const setOpen = ( ) => setConnectionStatus ( 'open' )
85
113
const setClosed = ( ) => setConnectionStatus ( 'closed' )
86
114
const setError = ( ) => setConnectionStatus ( 'error' )
87
115
88
116
ws . current ?. addEventListener ( 'open' , setOpen )
89
- ws . current ?. addEventListener ( 'closed ' , setClosed )
117
+ ws . current ?. addEventListener ( 'close ' , setClosed )
90
118
ws . current ?. addEventListener ( 'error' , setError )
91
119
92
120
return ( ) => {
93
121
ws . current ?. removeEventListener ( 'open' , setOpen )
94
- ws . current ?. removeEventListener ( 'closed ' , setClosed )
122
+ ws . current ?. removeEventListener ( 'close ' , setClosed )
95
123
ws . current ?. removeEventListener ( 'error' , setError )
96
124
}
97
- } , [ ] )
125
+ } , [ canConnect ] )
98
126
99
127
return (
100
128
< div className = "!mx-0 flex h-full max-h-[calc(100vh-60px)] !w-full flex-col" >
@@ -109,7 +137,13 @@ export function SerialConsolePage() {
109
137
</ Link >
110
138
111
139
< div className = "gutter relative w-full shrink grow overflow-hidden" >
112
- { connectionStatus !== 'open' && < SerialSkeleton /> }
140
+ { connectionStatus === 'connecting' && < ConnectingSkeleton /> }
141
+ { connectionStatus === 'error' && < ErrorSkeleton /> }
142
+ { connectionStatus === 'closed' && ! canConnect && (
143
+ < CannotConnect instanceState = { instanceData . runState } />
144
+ ) }
145
+ { /* closed && canConnect shouldn't be possible because there's no way to
146
+ * close an open connection other than leaving the page */ }
113
147
< Suspense fallback = { null } > { ws . current && < Terminal ws = { ws . current } /> } </ Suspense >
114
148
</ div >
115
149
< div className = "shrink-0 justify-between overflow-hidden border-t bg-default border-secondary empty:border-t-0" >
@@ -127,16 +161,22 @@ export function SerialConsolePage() {
127
161
)
128
162
}
129
163
130
- function SerialSkeleton ( ) {
131
- const instanceSelector = useInstanceSelector ( )
132
-
164
+ function SerialSkeleton ( {
165
+ children,
166
+ connecting,
167
+ } : {
168
+ children : React . ReactNode
169
+ connecting ?: boolean
170
+ } ) {
133
171
return (
134
172
< div className = "relative h-full shrink grow overflow-hidden" >
135
173
< div className = "h-full space-y-2 overflow-hidden" >
136
174
{ [ ...Array ( 200 ) ] . map ( ( _e , i ) => (
137
175
< div
138
176
key = { i }
139
- className = "h-4 rounded bg-tertiary motion-safe:animate-pulse"
177
+ className = { cn ( 'h-4 rounded bg-tertiary' , {
178
+ 'motion-safe:animate-pulse' : connecting ,
179
+ } ) }
140
180
style = { {
141
181
width : `${ Math . sin ( Math . sin ( i ) ) * 20 + 40 } %` ,
142
182
} } /* this is silly deterministic way to get random looking lengths */
@@ -150,18 +190,56 @@ function SerialSkeleton() {
150
190
background : 'linear-gradient(180deg, rgba(8, 15, 17, 0) 0%, #080F11 100%)' ,
151
191
} }
152
192
/>
153
- < div className = "absolute left-1/2 top-1/2 flex w-96 -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center space-y-4 rounded-lg border p-12 !bg-raise border-secondary elevation-3" >
154
- < Spinner size = "lg" />
155
-
156
- < div className = "space-y-2" >
157
- < p className = "text-center text-sans-xl text-default" >
158
- Connecting to{ ' ' }
159
- < Link to = { pb . instance ( instanceSelector ) } className = "text-accent-secondary" >
160
- { instanceSelector . instance }
161
- </ Link >
162
- </ p >
163
- </ div >
193
+ < div className = "absolute left-1/2 top-1/2 flex w-96 -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center rounded-lg border p-12 !bg-raise border-secondary elevation-3" >
194
+ { children }
164
195
</ div >
165
196
</ div >
166
197
)
167
198
}
199
+
200
+ function InstanceLink ( ) {
201
+ const { instance, project } = useInstanceSelector ( )
202
+ return (
203
+ < Link
204
+ className = "text-sans-xl text-accent-secondary hover:text-accent"
205
+ to = { pb . instance ( { project, instance } ) }
206
+ >
207
+ { instance }
208
+ </ Link >
209
+ )
210
+ }
211
+
212
+ const ConnectingSkeleton = ( ) => (
213
+ < SerialSkeleton connecting >
214
+ < Spinner size = "lg" />
215
+ < div className = "mt-4 text-center" >
216
+ < p className = "text-sans-xl" > Connecting to</ p >
217
+ < InstanceLink />
218
+ </ div >
219
+ </ SerialSkeleton >
220
+ )
221
+
222
+ const CannotConnect = ( { instanceState } : { instanceState : InstanceState } ) => (
223
+ < SerialSkeleton >
224
+ < p className = "flex items-center justify-center text-sans-xl" >
225
+ < span >
226
+ Instance < InstanceLink /> is
227
+ </ span >
228
+ < InstanceStatusBadge className = "ml-1" status = { instanceState } />
229
+ </ p >
230
+ < p className = "mt-2 text-center text-secondary" >
231
+ You can only connect to the serial console on a running instance.
232
+ </ p >
233
+ </ SerialSkeleton >
234
+ )
235
+
236
+ // TODO: sure would be nice to say something useful about the error, but
237
+ // we don't know what kind of thing we might pull off the error event
238
+ const ErrorSkeleton = ( ) => (
239
+ < SerialSkeleton >
240
+ < p className = "flex items-center justify-center text-center text-sans-xl" >
241
+ Serial console connection failed
242
+ </ p >
243
+ < p className = "mt-2 text-center text-secondary" > Please try again.</ p >
244
+ </ SerialSkeleton >
245
+ )
0 commit comments