7
7
*/
8
8
import { createColumnHelper } from '@tanstack/react-table'
9
9
import { filesize } from 'filesize'
10
- import { useMemo } from 'react'
10
+ import { useMemo , useRef } from 'react'
11
11
import { useNavigate , type LoaderFunctionArgs } from 'react-router-dom'
12
12
13
13
import { apiQueryClient , usePrefetchedApiQuery , type Instance } from '@oxide/api'
14
14
import { Instances24Icon } from '@oxide/design-system/icons/react'
15
15
16
+ import { instanceTransitioning } from '~/api/util'
16
17
import { InstanceDocsPopover } from '~/components/InstanceDocsPopover'
17
18
import { RefreshButton } from '~/components/RefreshButton'
18
19
import { getProjectSelector , useProjectSelector , useQuickActions } from '~/hooks'
@@ -25,6 +26,9 @@ import { CreateLink } from '~/ui/lib/CreateButton'
25
26
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
26
27
import { PageHeader , PageTitle } from '~/ui/lib/PageHeader'
27
28
import { TableActions } from '~/ui/lib/Table'
29
+ import { Tooltip } from '~/ui/lib/Tooltip'
30
+ import { setDiff } from '~/util/array'
31
+ import { toLocaleTimeString } from '~/util/date'
28
32
import { pb } from '~/util/path-builder'
29
33
30
34
import { useMakeInstanceActions } from './actions'
@@ -51,6 +55,12 @@ InstancesPage.loader = async ({ params }: LoaderFunctionArgs) => {
51
55
52
56
const refetchInstances = ( ) => apiQueryClient . invalidateQueries ( 'instanceList' )
53
57
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
+
54
64
export function InstancesPage ( ) {
55
65
const { project } = useProjectSelector ( )
56
66
@@ -59,9 +69,61 @@ export function InstancesPage() {
59
69
{ onSuccess : refetchInstances , onDelete : refetchInstances }
60
70
)
61
71
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
+ )
65
127
66
128
const navigate = useNavigate ( )
67
129
useQuickActions (
@@ -132,8 +194,20 @@ export function InstancesPage() {
132
194
< PageTitle icon = { < Instances24Icon /> } > Instances</ PageTitle >
133
195
< InstanceDocsPopover />
134
196
</ 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 >
137
211
< CreateLink to = { pb . instancesNew ( { project } ) } > New Instance</ CreateLink >
138
212
</ TableActions >
139
213
< Table columns = { columns } emptyState = { < EmptyState /> } />
0 commit comments