55 *
66 * Copyright Oxide Computer Company
77 */
8+ import cn from 'classnames'
89import { 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'
1219import { PrevArrow12Icon } from '@oxide/design-system/icons/react'
1320
1421import { EquivalentCliCommand } from '~/components/EquivalentCliCommand'
15- import { useInstanceSelector } from '~/hooks'
22+ import { InstanceStatusBadge } from '~/components/StatusBadge'
23+ import { getInstanceSelector , useInstanceSelector } from '~/hooks/use-params'
1624import { Badge , type BadgeColor } from '~/ui/lib/Badge'
1725import { Spinner } from '~/ui/lib/Spinner'
1826import { cliCmd } from '~/util/cli-cmd'
@@ -36,13 +44,29 @@ const statusMessage: Record<WsState, string> = {
3644 error : 'error' ,
3745}
3846
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+
3956export function SerialConsolePage ( ) {
4057 const instanceSelector = useInstanceSelector ( )
4158 const { project, instance } = instanceSelector
4259
60+ const { data : instanceData } = usePrefetchedApiQuery ( 'instanceView' , {
61+ query : { project } ,
62+ path : { instance } ,
63+ } )
64+
4365 const ws = useRef < WebSocket | null > ( null )
4466
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 )
4670
4771 // In dev, React 18 strict mode fires all effects twice for lulz, even ones
4872 // with no dependencies. In order to prevent the websocket from being killed
@@ -54,6 +78,8 @@ export function SerialConsolePage() {
5478 // 1a. cleanup runs, nothing happens because socket was not open yet
5579 // 2. effect runs, but `ws.current` is truthy, so nothing happens
5680 useEffect ( ( ) => {
81+ if ( ! canConnect ) return
82+
5783 // TODO: error handling if this connection fails
5884 if ( ! ws . current ) {
5985 const { project, instance } = instanceSelector
@@ -70,7 +96,7 @@ export function SerialConsolePage() {
7096 ws . current . close ( )
7197 }
7298 }
73- } , [ instanceSelector ] )
99+ } , [ instanceSelector , canConnect ] )
74100
75101 // Because this one does not look at ready state, just whether the thing is
76102 // defined, it will remove the event listeners before the spurious second
@@ -81,20 +107,22 @@ export function SerialConsolePage() {
81107 // 1a. cleanup runs, event listeners removed
82108 // 2. effect runs again, event listeners attached again
83109 useEffect ( ( ) => {
110+ if ( ! canConnect ) return // don't bother if instance is not running
111+
84112 const setOpen = ( ) => setConnectionStatus ( 'open' )
85113 const setClosed = ( ) => setConnectionStatus ( 'closed' )
86114 const setError = ( ) => setConnectionStatus ( 'error' )
87115
88116 ws . current ?. addEventListener ( 'open' , setOpen )
89- ws . current ?. addEventListener ( 'closed ' , setClosed )
117+ ws . current ?. addEventListener ( 'close ' , setClosed )
90118 ws . current ?. addEventListener ( 'error' , setError )
91119
92120 return ( ) => {
93121 ws . current ?. removeEventListener ( 'open' , setOpen )
94- ws . current ?. removeEventListener ( 'closed ' , setClosed )
122+ ws . current ?. removeEventListener ( 'close ' , setClosed )
95123 ws . current ?. removeEventListener ( 'error' , setError )
96124 }
97- } , [ ] )
125+ } , [ canConnect ] )
98126
99127 return (
100128 < div className = "!mx-0 flex h-full max-h-[calc(100vh-60px)] !w-full flex-col" >
@@ -109,7 +137,13 @@ export function SerialConsolePage() {
109137 </ Link >
110138
111139 < 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 */ }
113147 < Suspense fallback = { null } > { ws . current && < Terminal ws = { ws . current } /> } </ Suspense >
114148 </ div >
115149 < 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() {
127161 )
128162}
129163
130- function SerialSkeleton ( ) {
131- const instanceSelector = useInstanceSelector ( )
132-
164+ function SerialSkeleton ( {
165+ children,
166+ connecting,
167+ } : {
168+ children : React . ReactNode
169+ connecting ?: boolean
170+ } ) {
133171 return (
134172 < div className = "relative h-full shrink grow overflow-hidden" >
135173 < div className = "h-full space-y-2 overflow-hidden" >
136174 { [ ...Array ( 200 ) ] . map ( ( _e , i ) => (
137175 < div
138176 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+ } ) }
140180 style = { {
141181 width : `${ Math . sin ( Math . sin ( i ) ) * 20 + 40 } %` ,
142182 } } /* this is silly deterministic way to get random looking lengths */
@@ -150,18 +190,56 @@ function SerialSkeleton() {
150190 background : 'linear-gradient(180deg, rgba(8, 15, 17, 0) 0%, #080F11 100%)' ,
151191 } }
152192 />
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 }
164195 </ div >
165196 </ div >
166197 )
167198}
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