77 */
88import { createColumnHelper } from '@tanstack/react-table'
99import { filesize } from 'filesize'
10- import { useMemo } from 'react'
10+ import { useMemo , useRef } from 'react'
1111import { useNavigate , type LoaderFunctionArgs } from 'react-router-dom'
1212
1313import { apiQueryClient , usePrefetchedApiQuery , type Instance } from '@oxide/api'
1414import { Instances24Icon } from '@oxide/design-system/icons/react'
1515
16+ import { instanceTransitioning } from '~/api/util'
1617import { InstanceDocsPopover } from '~/components/InstanceDocsPopover'
1718import { RefreshButton } from '~/components/RefreshButton'
1819import { getProjectSelector , useProjectSelector , useQuickActions } from '~/hooks'
@@ -25,6 +26,9 @@ import { CreateLink } from '~/ui/lib/CreateButton'
2526import { EmptyMessage } from '~/ui/lib/EmptyMessage'
2627import { PageHeader , PageTitle } from '~/ui/lib/PageHeader'
2728import { TableActions } from '~/ui/lib/Table'
29+ import { Tooltip } from '~/ui/lib/Tooltip'
30+ import { setDiff } from '~/util/array'
31+ import { toLocaleTimeString } from '~/util/date'
2832import { pb } from '~/util/path-builder'
2933
3034import { useMakeInstanceActions } from './actions'
@@ -51,6 +55,12 @@ InstancesPage.loader = async ({ params }: LoaderFunctionArgs) => {
5155
5256const refetchInstances = ( ) => apiQueryClient . invalidateQueries ( 'instanceList' )
5357
58+ const sec = 1000 // ms, obviously
59+ const POLL_FAST_TIMEOUT = 30 * sec
60+ // a little slower than instance detail because this is a bigger response
61+ const POLL_INTERVAL_FAST = 3 * sec
62+ const POLL_INTERVAL_SLOW = 60 * sec
63+
5464export function InstancesPage ( ) {
5565 const { project } = useProjectSelector ( )
5666
@@ -59,9 +69,61 @@ export function InstancesPage() {
5969 { onSuccess : refetchInstances , onDelete : refetchInstances }
6070 )
6171
62- const { data : instances } = usePrefetchedApiQuery ( 'instanceList' , {
63- query : { project, limit : PAGE_SIZE } ,
64- } )
72+ // this is a whole thing. sit down.
73+
74+ // We initialize this set as empty because we don't have the instances on hand
75+ // yet. This is fine because the first fetch will recognize the presence of
76+ // any transitioning instances as a change in state and initiate polling
77+ const transitioningInstances = useRef < Set < string > > ( new Set ( ) )
78+ const pollingStartTime = useRef < number > ( Date . now ( ) )
79+
80+ const { data : instances , dataUpdatedAt } = usePrefetchedApiQuery (
81+ 'instanceList' ,
82+ { query : { project, limit : PAGE_SIZE } } ,
83+ {
84+ // The point of all this is to poll quickly for a certain amount of time
85+ // after some instance in the current page enters a transitional state
86+ // like starting or stopping. After that, it will keep polling, but more
87+ // slowly. For example, if you stop an instance, its state will change to
88+ // `stopping`, which will cause this logic to start polling the list until
89+ // it lands in `stopped`, at which point it will poll only slowly because
90+ // `stopped` is not considered transitional.
91+ refetchInterval ( { state : { data } } ) {
92+ const prevTransitioning = transitioningInstances . current
93+ const nextTransitioning = new Set (
94+ // Data will never actually be undefined because of the prefetch but whatever
95+ ( data ?. items || [ ] )
96+ . filter ( instanceTransitioning )
97+ // These are strings of instance ID + current state. This is done because
98+ // of the case where an instance is stuck in starting (for example), polling
99+ // times out, and then you manually stop it. Without putting the state in the
100+ // the key, that stop action would not be registered as a change in the set
101+ // of transitioning instances.
102+ . map ( ( i ) => i . id + '|' + i . runState )
103+ )
104+
105+ // always update the ledger to the current state
106+ transitioningInstances . current = nextTransitioning
107+
108+ // We use this set difference logic instead of set equality because if
109+ // you have two transitioning instances and one stops transitioning,
110+ // then that's a change in the set, but you shouldn't start polling
111+ // fast because of it! What you want to look for is *new* transitioning
112+ // instances.
113+ const anyTransitioning = nextTransitioning . size > 0
114+ const anyNewTransitioning = setDiff ( nextTransitioning , prevTransitioning ) . size > 0
115+
116+ // if there are new instances in transitioning, restart the timeout window
117+ if ( anyNewTransitioning ) pollingStartTime . current = Date . now ( )
118+
119+ // important that elapsed is calculated *after* potentially bumping start time
120+ const elapsed = Date . now ( ) - pollingStartTime . current
121+ return anyTransitioning && elapsed < POLL_FAST_TIMEOUT
122+ ? POLL_INTERVAL_FAST
123+ : POLL_INTERVAL_SLOW
124+ } ,
125+ }
126+ )
65127
66128 const navigate = useNavigate ( )
67129 useQuickActions (
@@ -132,8 +194,20 @@ export function InstancesPage() {
132194 < PageTitle icon = { < Instances24Icon /> } > Instances</ PageTitle >
133195 < InstanceDocsPopover />
134196 </ PageHeader >
135- < TableActions >
136- < RefreshButton onClick = { refetchInstances } />
197+ { /* Avoid changing justify-end on TableActions for this one case. We can
198+ * fix this properly when we add refresh and filtering for all tables. */ }
199+ < TableActions className = "!-mt-6 !justify-between" >
200+ < div className = "flex items-center gap-2" >
201+ < RefreshButton onClick = { refetchInstances } />
202+ < Tooltip
203+ content = "Auto-refresh is more frequent after instance actions"
204+ delay = { 150 }
205+ >
206+ < span className = "text-sans-sm text-tertiary" >
207+ Updated { toLocaleTimeString ( new Date ( dataUpdatedAt ) ) }
208+ </ span >
209+ </ Tooltip >
210+ </ div >
137211 < CreateLink to = { pb . instancesNew ( { project } ) } > New Instance</ CreateLink >
138212 </ TableActions >
139213 < Table columns = { columns } emptyState = { < EmptyState /> } />
0 commit comments