Skip to content

Commit 74c2060

Browse files
authored
System Update (#2915)
* system update UI * delete upload modal until we can make it work * e2e tests * mock api rejects setting target to older version * explicitly sort releases by semver desc in mock API * You're absolutely right! Yes/No is better than True/False * gpt-5 review suggestions * Available releases -> Releases * test: fleet viewer can't set target release * fix stuff
1 parent 96939d3 commit 74c2060

25 files changed

+629
-31
lines changed

app/api/client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ class QueryClient extends QueryClientOrig {
8383
* The params argument can be added in if we ever have a use case for it.
8484
*/
8585
invalidateEndpoint(method: keyof typeof api.methods) {
86-
this.invalidateQueries({ queryKey: [method] })
86+
return this.invalidateQueries({ queryKey: [method] })
8787
}
8888
}
8989

app/layouts/SystemLayout.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
IpGlobal16Icon,
1515
Metrics16Icon,
1616
Servers16Icon,
17+
SoftwareUpdate16Icon,
1718
} from '@oxide/design-system/icons/react'
1819

1920
import { trigger404 } from '~/components/ErrorBoundary'
@@ -53,6 +54,7 @@ export default function SystemLayout() {
5354
{ value: 'Utilization', path: pb.systemUtilization() },
5455
{ value: 'Inventory', path: pb.sledInventory() },
5556
{ value: 'IP Pools', path: pb.ipPools() },
57+
{ value: 'System Update', path: pb.systemUpdate() },
5658
]
5759
// filter out the entry for the path we're currently on
5860
.filter((i) => i.path !== pathname)
@@ -96,6 +98,9 @@ export default function SystemLayout() {
9698
<NavLinkItem to={pb.ipPools()}>
9799
<IpGlobal16Icon /> IP Pools
98100
</NavLinkItem>
101+
<NavLinkItem to={pb.systemUpdate()}>
102+
<SoftwareUpdate16Icon /> System Update
103+
</NavLinkItem>
99104
</Sidebar.Nav>
100105
</Sidebar>
101106
<ContentPane />

app/pages/system/UpdatePage.tsx

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
9+
import { useMemo } from 'react'
10+
import * as R from 'remeda'
11+
12+
import {
13+
Images24Icon,
14+
SoftwareUpdate16Icon,
15+
SoftwareUpdate24Icon,
16+
Time16Icon,
17+
} from '@oxide/design-system/icons/react'
18+
import { Badge } from '@oxide/design-system/ui'
19+
20+
import {
21+
apiq,
22+
queryClient,
23+
useApiMutation,
24+
usePrefetchedQuery,
25+
type UpdateStatus,
26+
} from '~/api'
27+
import { DocsPopover } from '~/components/DocsPopover'
28+
import { HL } from '~/components/HL'
29+
import { MoreActionsMenu } from '~/components/MoreActionsMenu'
30+
import { RefreshButton } from '~/components/RefreshButton'
31+
import { makeCrumb } from '~/hooks/use-crumbs'
32+
import { confirmAction } from '~/stores/confirm-action'
33+
import { addToast } from '~/stores/toast'
34+
import { EmptyCell } from '~/table/cells/EmptyCell'
35+
import { CardBlock } from '~/ui/lib/CardBlock'
36+
import { DateTime } from '~/ui/lib/DateTime'
37+
import * as DropdownMenu from '~/ui/lib/DropdownMenu'
38+
import { PageHeader, PageTitle } from '~/ui/lib/PageHeader'
39+
import { PropertiesTable } from '~/ui/lib/PropertiesTable'
40+
import { TipIcon } from '~/ui/lib/TipIcon'
41+
import { ALL_ISH } from '~/util/consts'
42+
import { docLinks } from '~/util/links'
43+
import { percentage, round } from '~/util/math'
44+
45+
export const handle = makeCrumb('System Update')
46+
47+
const statusQuery = apiq('systemUpdateStatus', {})
48+
const reposQuery = apiq('systemUpdateRepositoryList', { query: { limit: ALL_ISH } })
49+
50+
const refreshData = () =>
51+
Promise.all([
52+
queryClient.invalidateEndpoint('systemUpdateStatus'),
53+
queryClient.invalidateEndpoint('systemUpdateRepositoryList'),
54+
])
55+
56+
export async function clientLoader() {
57+
await Promise.all([
58+
queryClient.prefetchQuery(statusQuery),
59+
queryClient.prefetchQuery(reposQuery),
60+
])
61+
return null
62+
}
63+
64+
function calcProgress(status: UpdateStatus) {
65+
const targetVersion = status.targetRelease?.version
66+
if (!targetVersion) return null
67+
68+
const total = R.sum(Object.values(status.componentsByReleaseVersion))
69+
const current = status.componentsByReleaseVersion[targetVersion] || 0
70+
71+
if (!total) return null // avoid dividing by zero
72+
73+
return {
74+
current,
75+
total,
76+
// trunc prevents, e.g., 999/1000 being reported as 100%
77+
percentage: round(percentage(current, total), 0, 'trunc'),
78+
}
79+
}
80+
81+
export default function UpdatePage() {
82+
const { data: status } = usePrefetchedQuery(statusQuery)
83+
const { data: repos } = usePrefetchedQuery(reposQuery)
84+
85+
const { mutateAsync: setTargetRelease } = useApiMutation('targetReleaseUpdate', {
86+
onSuccess() {
87+
refreshData()
88+
addToast({ content: 'Target release updated' })
89+
},
90+
// error handled by confirm modal
91+
})
92+
93+
const componentProgress = useMemo(() => calcProgress(status), [status])
94+
95+
return (
96+
<>
97+
<PageHeader>
98+
<PageTitle icon={<SoftwareUpdate24Icon />}>System Update</PageTitle>
99+
<div className="flex items-center gap-2">
100+
<RefreshButton onClick={refreshData} />
101+
<DocsPopover
102+
heading="system update"
103+
icon={<SoftwareUpdate16Icon />}
104+
summary="The update system automatically updates components to the target release."
105+
links={[docLinks.systemUpdate]}
106+
/>
107+
</div>
108+
</PageHeader>
109+
<PropertiesTable className="-mt-8 mb-8">
110+
{/* targetRelease will never be null on a customer system after the
111+
first time it is set. */}
112+
<PropertiesTable.Row label="Target release">
113+
{status.targetRelease?.version ?? <EmptyCell />}
114+
</PropertiesTable.Row>
115+
<PropertiesTable.Row label="Target set">
116+
{status.targetRelease?.timeRequested ? (
117+
<DateTime date={status.targetRelease.timeRequested} />
118+
) : (
119+
<EmptyCell />
120+
)}
121+
</PropertiesTable.Row>
122+
<PropertiesTable.Row
123+
label={
124+
<>
125+
Progress
126+
<TipIcon className="ml-1.5">
127+
Number of components updated to the target release
128+
</TipIcon>
129+
</>
130+
}
131+
>
132+
{componentProgress ? (
133+
<>
134+
<div className="mr-1.5">{componentProgress.percentage}%</div>
135+
<div className="text-secondary">
136+
({componentProgress.current} of {componentProgress.total})
137+
</div>
138+
</>
139+
) : (
140+
<EmptyCell />
141+
)}
142+
</PropertiesTable.Row>
143+
<PropertiesTable.Row
144+
label={
145+
<>
146+
Last step planned{' '}
147+
<TipIcon className="ml-1.5">
148+
A rough indicator of the last time the update planner did something
149+
</TipIcon>
150+
</>
151+
}
152+
>
153+
<DateTime date={status.timeLastStepPlanned} />
154+
</PropertiesTable.Row>
155+
<PropertiesTable.Row
156+
label={
157+
<>
158+
Suspended{' '}
159+
<TipIcon className="ml-1.5">
160+
Whether automatic update is suspended due to manual update activity
161+
</TipIcon>
162+
</>
163+
}
164+
>
165+
{status.suspended ? 'Yes' : 'No'}
166+
</PropertiesTable.Row>
167+
</PropertiesTable>
168+
169+
<CardBlock>
170+
<CardBlock.Header title="Releases" />
171+
<CardBlock.Body>
172+
<ul className="space-y-3">
173+
{repos.items.map((repo) => {
174+
const isTarget = repo.systemVersion === status.targetRelease?.version
175+
return (
176+
<li
177+
key={repo.hash}
178+
className="border-secondary flex items-center gap-4 rounded border p-4"
179+
>
180+
<Images24Icon className="text-secondary shrink-0" aria-hidden />
181+
<div className="flex-1">
182+
<div className="flex items-center gap-2">
183+
<span className="text-sans-semi-lg text-raise">
184+
{repo.systemVersion}
185+
</span>
186+
{isTarget && <Badge color="default">Target</Badge>}
187+
</div>
188+
<div className="text-secondary">{repo.fileName}</div>
189+
</div>
190+
<div className="flex items-center gap-4">
191+
<div className="flex items-center gap-1">
192+
<Time16Icon aria-hidden />
193+
<DateTime date={repo.timeCreated} />
194+
</div>
195+
</div>
196+
<MoreActionsMenu label={`${repo.systemVersion} actions`} isSmall>
197+
<DropdownMenu.Item
198+
label="Set as target release"
199+
onSelect={() => {
200+
confirmAction({
201+
actionType: 'primary',
202+
doAction: () =>
203+
setTargetRelease({
204+
body: { systemVersion: repo.systemVersion },
205+
}),
206+
modalTitle: 'Confirm set target release',
207+
modalContent: (
208+
<p>
209+
Are you sure you want to set <HL>{repo.systemVersion}</HL> as
210+
the target release?
211+
</p>
212+
),
213+
errorTitle: `Error setting target release to ${repo.systemVersion}`,
214+
})
215+
}}
216+
// TODO: follow API logic, disabling for older releases.
217+
// Or maybe just have the API tell us by adding a field to
218+
// the TufRepo response type.
219+
disabled={isTarget && 'Already set as target'}
220+
/>
221+
</MoreActionsMenu>
222+
</li>
223+
)
224+
})}
225+
</ul>
226+
</CardBlock.Body>
227+
</CardBlock>
228+
</>
229+
)
230+
}

app/routes.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,10 @@ export const routes = createRoutesFromElements(
226226
/>
227227
</Route>
228228
</Route>
229+
<Route
230+
path="update"
231+
lazy={() => import('./pages/system/UpdatePage').then(convert)}
232+
/>
229233
</Route>
230234

231235
<Route index loader={() => redirect(pb.projects())} element={null} />

app/ui/lib/PropertiesTable.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export function PropertiesTable({
3737
)
3838
return (
3939
<div
40+
aria-label="Properties table"
4041
className={cn(
4142
className,
4243
'properties-table border-default min-w-min basis-6/12 rounded-lg border',

app/util/__snapshots__/path-builder.spec.ts.snap

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -733,6 +733,12 @@ exports[`breadcrumbs 2`] = `
733733
"path": "/settings/ssh-keys",
734734
},
735735
],
736+
"systemUpdate (/system/update)": [
737+
{
738+
"label": "System Update",
739+
"path": "/system/update",
740+
},
741+
],
736742
"systemUtilization (/system/utilization)": [
737743
{
738744
"label": "Utilization",

app/util/links.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export const links = {
5757
systemIpPoolsDocs: 'https://docs.oxide.computer/guides/operator/ip-pool-management',
5858
systemMetricsDocs: 'https://docs.oxide.computer/guides/operator/system-metrics',
5959
systemSiloDocs: 'https://docs.oxide.computer/guides/operator/silo-management',
60+
systemUpdateDocs: 'https://docs.oxide.computer/guides/operator/system-update',
6061
transitIpsDocs:
6162
'https://docs.oxide.computer/guides/configuring-guest-networking#_example_4_software_routing_tunnels',
6263
troubleshootingAccess:
@@ -155,6 +156,10 @@ export const docLinks = {
155156
href: links.systemSiloDocs,
156157
linkText: 'Silos',
157158
},
159+
systemUpdate: {
160+
href: links.systemUpdateDocs,
161+
linkText: 'System Update',
162+
},
158163
instances: {
159164
href: links.instancesDocs,
160165
linkText: 'Instances',

app/util/math.spec.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { describe, expect, it } from 'vitest'
1010
import { diskSizeNearest10, displayBigNum, percentage, round, splitDecimal } from './math'
1111
import { GiB } from './units'
1212

13-
function roundTest() {
13+
it('round', () => {
1414
expect(round(1, 2)).toEqual(1)
1515
expect(round(100, 2)).toEqual(100)
1616
expect(round(999, 2)).toEqual(999)
@@ -30,9 +30,22 @@ function roundTest() {
3030
expect(round(4.997, 2)).toEqual(5)
3131
expect(round(5 / 2, 2)).toEqual(2.5) // math expressions are resolved
3232
expect(round(1879048192 / GiB, 2)).toEqual(1.75) // constants can be evaluated
33-
}
33+
})
3434

35-
it('round', roundTest)
35+
it('round trunc', () => {
36+
expect(round(0.456, 2, 'trunc')).toEqual(0.45)
37+
expect(round(-0.456, 2, 'trunc')).toEqual(-0.45)
38+
expect(round(123.456, 2, 'trunc')).toEqual(123.45)
39+
expect(round(1.9, 0, 'trunc')).toEqual(1)
40+
expect(round(4.997, 2, 'trunc')).toEqual(4.99)
41+
expect(round(1438972340398.648, 2, 'trunc')).toEqual(1438972340398.64)
42+
expect(round(123.0001, 3, 'trunc')).toEqual(123)
43+
expect(round(5 / 2, 2, 'trunc')).toEqual(2.5)
44+
expect(round(1879048192 / GiB, 2, 'trunc')).toEqual(1.75)
45+
expect(round(99.999, 2, 'trunc')).toEqual(99.99)
46+
expect(round(0.999, 0, 'trunc')).toEqual(0)
47+
expect(round(-3.14159, 3, 'trunc')).toEqual(-3.141)
48+
})
3649

3750
it.each([
3851
[2, 5, 40],

app/util/math.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,19 @@ export function percentage<T extends number | bigint>(top: T, bottom: T): number
5050
return Number(((top as bigint) * 10_000n) / (bottom as bigint)) / 100
5151
}
5252

53-
export function round(num: number, digits: number) {
53+
// there are a lot more options, but let's only include the ones we need.
54+
// halfExpand is the default when nothing/undefined is passed in. trunc is like floor
55+
// except it always rounds toward zero, so, e.g., -0.99 rounds to -0.9 instead
56+
// of -1.0
57+
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#roundingmode
58+
type RoundingMode = 'trunc'
59+
60+
export function round(num: number, digits: number, roundingMode?: RoundingMode) {
5461
// unlike with splitDecimal, we hard-code en-US to ensure that Number() will
5562
// be able to parse the result
5663
const nf = Intl.NumberFormat('en-US', {
5764
maximumFractionDigits: digits,
65+
roundingMode,
5866
// very important, otherwise turning back into number will fail on n >= 1000
5967
// due to commas
6068
useGrouping: false,

app/util/path-builder.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ test('path builder', () => {
9999
"sshKeyEdit": "/settings/ssh-keys/ss/edit",
100100
"sshKeys": "/settings/ssh-keys",
101101
"sshKeysNew": "/settings/ssh-keys-new",
102+
"systemUpdate": "/system/update",
102103
"systemUtilization": "/system/utilization",
103104
"vpc": "/projects/p/vpcs/v/firewall-rules",
104105
"vpcEdit": "/projects/p/vpcs/v/edit",

0 commit comments

Comments
 (0)