Repository navigation
Expand file tree
/
Copy pathUpdatePage.tsx
More file actions
325 lines (304 loc) · 11.3 KB
/
Copy pathUpdatePage.tsx
File metadata and controls
325 lines (304 loc) · 11.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import { differenceInMinutes } from 'date-fns'
import { useMemo } from 'react'
import { Link } from 'react-router'
import * as R from 'remeda'
import { lt as semverLt } from 'semver'
import {
Images24Icon,
OpenLink12Icon,
SoftwareUpdate16Icon,
SoftwareUpdate24Icon,
} from '@oxide/design-system/icons/react'
import { Badge } from '@oxide/design-system/ui'
import {
api,
q,
queryClient,
useApiMutation,
usePrefetchedQuery,
type UpdateStatus,
} from '~/api'
import { DocsPopover } from '~/components/DocsPopover'
import { HL } from '~/components/HL'
import { MoreActionsMenu } from '~/components/MoreActionsMenu'
import { RefreshButton } from '~/components/RefreshButton'
import { makeCrumb } from '~/hooks/use-crumbs'
import { confirmAction } from '~/stores/confirm-action'
import { addToast } from '~/stores/toast'
import { EmptyCell } from '~/table/cells/EmptyCell'
import { CardBlock } from '~/ui/lib/CardBlock'
import { DateTime } from '~/ui/lib/DateTime'
import { Divider } from '~/ui/lib/Divider'
import * as DropdownMenu from '~/ui/lib/DropdownMenu'
import { Message } from '~/ui/lib/Message'
import { PageHeader, PageTitle } from '~/ui/lib/PageHeader'
import { PropertiesTable } from '~/ui/lib/PropertiesTable'
import { TipIcon } from '~/ui/lib/TipIcon'
import { ALL_ISH } from '~/util/consts'
import { docLinks } from '~/util/links'
import { percentage, round } from '~/util/math'
export const handle = makeCrumb('System Update')
const SEC = 1000 // ms, obviously
const POLL_FAST = 20 * SEC
const POLL_SLOW = 120 * SEC
const statusQuery = q(
api.systemUpdateStatus,
{},
{
refetchInterval({ state: { data: status } }) {
if (!status) return false // should be impossible due to prefetch
const now = new Date()
const minSinceTargetSet = status.targetRelease
? differenceInMinutes(now, status.targetRelease.timeRequested)
: null
const minSinceLastStepPlanned = differenceInMinutes(now, status.timeLastStepPlanned)
return minSinceLastStepPlanned < 30 ||
(minSinceTargetSet !== null && minSinceTargetSet < 30)
? POLL_FAST
: POLL_SLOW
},
}
)
const reposQuery = q(api.systemUpdateRepositoryList, {
query: { limit: ALL_ISH },
})
const refreshData = () =>
Promise.all([
queryClient.invalidateEndpoint('systemUpdateStatus'),
queryClient.invalidateEndpoint('systemUpdateRepositoryList'),
])
export async function clientLoader() {
await Promise.all([
queryClient.prefetchQuery(statusQuery),
queryClient.prefetchQuery(reposQuery),
])
return null
}
function calcProgress(status: UpdateStatus) {
const targetVersion = status.targetRelease?.version
if (!targetVersion) return null
const total = R.sum(Object.values(status.componentsByReleaseVersion))
const current = status.componentsByReleaseVersion[targetVersion] || 0
if (!total) return null // avoid dividing by zero
return {
current,
total,
// trunc prevents, e.g., 999/1000 being reported as 100%
percentage: round(percentage(current, total), 0, 'trunc'),
}
}
// wrapper div keeps the link out of the Message content's [&>a]:tint-underline
const ContactSupportCta = () => (
<div>
<Link
className="text-mono-xs text-accent group mt-3 inline-flex items-center gap-1.5"
to="https://support.oxide.computer"
target="_blank"
rel="noreferrer"
>
Contact Support
<OpenLink12Icon />
</Link>
</div>
)
export default function UpdatePage() {
const { data: status } = usePrefetchedQuery(statusQuery)
const { data: repos } = usePrefetchedQuery(reposQuery)
const { mutateAsync: setTargetRelease } = useApiMutation(api.targetReleaseUpdate, {
onSuccess() {
refreshData()
addToast({ content: 'Target release updated' })
},
// error handled by confirm modal
})
const componentProgress = useMemo(() => calcProgress(status), [status])
return (
<>
<PageHeader>
<PageTitle icon={<SoftwareUpdate24Icon />}>System Update</PageTitle>
<div className="flex items-center gap-2">
<RefreshButton onClick={refreshData} />
<DocsPopover
heading="system update"
icon={<SoftwareUpdate16Icon />}
summary="The update system automatically updates components to the target release."
links={[docLinks.systemUpdate]}
/>
</div>
</PageHeader>
<PropertiesTable className="-mt-8">
{/* targetRelease will never be null on a customer system after the
first time it is set. */}
<PropertiesTable.Row label="Target release">
{status.targetRelease?.version ?? <EmptyCell />}
</PropertiesTable.Row>
<PropertiesTable.Row label="Target set">
{status.targetRelease?.timeRequested ? (
<DateTime date={status.targetRelease.timeRequested} />
) : (
<EmptyCell />
)}
</PropertiesTable.Row>
<PropertiesTable.Row
label={
<>
Progress
<TipIcon className="ml-1.5">
Number of components updated to the target release
</TipIcon>
</>
}
>
{componentProgress ? (
<>
<div className="mr-1.5">{componentProgress.percentage}%</div>
<div className="text-tertiary">
({componentProgress.current} of {componentProgress.total})
</div>
</>
) : (
<EmptyCell />
)}
</PropertiesTable.Row>
<PropertiesTable.Row
label={
<>
Last step planned{' '}
<TipIcon className="ml-1.5">
A rough indicator of the last time the update planner did something
</TipIcon>
</>
}
>
<DateTime date={status.timeLastStepPlanned} />
</PropertiesTable.Row>
<PropertiesTable.Row
label={
<>
Suspended{' '}
<TipIcon className="ml-1.5">
Whether automatic update is suspended due to manual update activity
</TipIcon>
</>
}
>
{status.suspended ? 'Yes' : 'No'}
</PropertiesTable.Row>
</PropertiesTable>
{status.contactSupport && (
<Message
className="mt-4"
variant="notice"
title="Support required"
content={
<>
The system has detected one or more known conditions that require Oxide
support to resolve.
<ContactSupportCta />
</>
}
/>
)}
<Divider className="my-8" />
<CardBlock>
<CardBlock.Header title="Releases" />
<CardBlock.Body>
<ul className="space-y-3">
{repos.items.map((repo) => {
const targetVersion = status.targetRelease?.version
const isTarget = repo.systemVersion === targetVersion
// semverLt looks at prerelease meta but not build meta. In prod
// it doesn't matter either way because there will be neither. On
// dogfood it shouldn't matter because the versions will usually
// be the same and with the same prelease meta and only differing
// build meta, so semverLt will return false and we don't disable
// any. Very important this is
const olderThanTarget = targetVersion
? semverLt(repo.systemVersion, targetVersion)
: false
return (
<li
key={repo.hash}
className="border-default @container flex items-center gap-2 rounded-md border pl-4"
>
<Images24Icon className="text-quaternary shrink-0" aria-hidden />
<div className="flex min-w-0 flex-1 flex-col flex-wrap items-start gap-x-4 gap-y-1 py-3 @md:flex-row @md:items-center">
<div className="flex-1">
<div className="flex items-center gap-1.5">
<span className="text-sans-semi-lg text-raise">
{repo.systemVersion}
</span>
{isTarget && <Badge color="default">Target</Badge>}
</div>
</div>
<div className="flex flex-col items-start gap-0.5 pr-2 @md:items-end">
<DateTime date={repo.timeCreated} />
<div>{repo.fileName}</div>
</div>
</div>
<div className="border-secondary flex items-center justify-center self-stretch border-l">
<MoreActionsMenu
label={`${repo.systemVersion} actions`}
variant="filled"
>
<DropdownMenu.Item
label="Set as target release"
onSelect={() => {
confirmAction({
actionType: 'primary',
doAction: () =>
setTargetRelease({
body: { systemVersion: repo.systemVersion },
}),
modalTitle: 'Set target release',
modalContent: (
<div className="space-y-4">
{status.contactSupport && (
<Message
variant="notice"
// title="Support required"
content={
<>
The system has detected known conditions that
require Oxide support to resolve. Starting an update
before talking to support is{' '}
<HL>strongly discouraged</HL>.
</>
}
/>
)}
<p>
Are you sure you want to set <HL>{repo.systemVersion}</HL>{' '}
as the target release?
</p>
</div>
),
errorTitle: `Error setting target release to ${repo.systemVersion}`,
})
}}
disabled={
isTarget
? 'Already set as target'
: olderThanTarget
? 'Cannot set older release as target'
: false
}
/>
</MoreActionsMenu>
</div>
</li>
)
})}
</ul>
</CardBlock.Body>
</CardBlock>
</>
)
}