Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ class QueryClient extends QueryClientOrig {
* The params argument can be added in if we ever have a use case for it.
*/
invalidateEndpoint(method: keyof typeof api.methods) {
this.invalidateQueries({ queryKey: [method] })
return this.invalidateQueries({ queryKey: [method] })
}
}

Expand Down
5 changes: 5 additions & 0 deletions app/layouts/SystemLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
IpGlobal16Icon,
Metrics16Icon,
Servers16Icon,
SoftwareUpdate16Icon,
} from '@oxide/design-system/icons/react'

import { trigger404 } from '~/components/ErrorBoundary'
Expand Down Expand Up @@ -53,6 +54,7 @@ export default function SystemLayout() {
{ value: 'Utilization', path: pb.systemUtilization() },
{ value: 'Inventory', path: pb.sledInventory() },
{ value: 'IP Pools', path: pb.ipPools() },
{ value: 'System Update', path: pb.systemUpdate() },
]
// filter out the entry for the path we're currently on
.filter((i) => i.path !== pathname)
Expand Down Expand Up @@ -96,6 +98,9 @@ export default function SystemLayout() {
<NavLinkItem to={pb.ipPools()}>
<IpGlobal16Icon /> IP Pools
</NavLinkItem>
<NavLinkItem to={pb.systemUpdate()}>
<SoftwareUpdate16Icon /> System Update
</NavLinkItem>
</Sidebar.Nav>
</Sidebar>
<ContentPane />
Expand Down
230 changes: 230 additions & 0 deletions app/pages/system/UpdatePage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
/*
* 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 { useMemo } from 'react'
import * as R from 'remeda'

import {
Images24Icon,
SoftwareUpdate16Icon,
SoftwareUpdate24Icon,
Time16Icon,
} from '@oxide/design-system/icons/react'
import { Badge } from '@oxide/design-system/ui'

import {
apiq,
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 * as DropdownMenu from '~/ui/lib/DropdownMenu'
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 statusQuery = apiq('systemUpdateStatus', {})
const reposQuery = apiq('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'),
}
}

export default function UpdatePage() {
const { data: status } = usePrefetchedQuery(statusQuery)
const { data: repos } = usePrefetchedQuery(reposQuery)

const { mutateAsync: setTargetRelease } = useApiMutation('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 mb-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-secondary">
({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>

<CardBlock>
<CardBlock.Header title="Releases" />
<CardBlock.Body>
<ul className="space-y-3">
{repos.items.map((repo) => {
const isTarget = repo.systemVersion === status.targetRelease?.version
return (
<li
key={repo.hash}
className="border-secondary flex items-center gap-4 rounded border p-4"
>
<Images24Icon className="text-secondary shrink-0" aria-hidden />
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sans-semi-lg text-raise">
{repo.systemVersion}
</span>
{isTarget && <Badge color="default">Target</Badge>}
</div>
<div className="text-secondary">{repo.fileName}</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-1">
<Time16Icon aria-hidden />
<DateTime date={repo.timeCreated} />
</div>
</div>
<MoreActionsMenu label={`${repo.systemVersion} actions`} isSmall>
<DropdownMenu.Item
label="Set as target release"
onSelect={() => {
confirmAction({
actionType: 'primary',
doAction: () =>
setTargetRelease({
body: { systemVersion: repo.systemVersion },
}),
modalTitle: 'Confirm set target release',
modalContent: (
<p>
Are you sure you want to set <HL>{repo.systemVersion}</HL> as
the target release?
</p>
),
errorTitle: `Error setting target release to ${repo.systemVersion}`,
})
}}
// TODO: follow API logic, disabling for older releases.
// Or maybe just have the API tell us by adding a field to
// the TufRepo response type.
disabled={isTarget && 'Already set as target'}
/>
</MoreActionsMenu>
</li>
)
})}
</ul>
</CardBlock.Body>
</CardBlock>
</>
)
}
4 changes: 4 additions & 0 deletions app/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,10 @@ export const routes = createRoutesFromElements(
/>
</Route>
</Route>
<Route
path="update"
lazy={() => import('./pages/system/UpdatePage').then(convert)}
/>
</Route>

<Route index loader={() => redirect(pb.projects())} element={null} />
Expand Down
1 change: 1 addition & 0 deletions app/ui/lib/PropertiesTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export function PropertiesTable({
)
return (
<div
aria-label="Properties table"
className={cn(
className,
'properties-table border-default min-w-min basis-6/12 rounded-lg border',
Expand Down
6 changes: 6 additions & 0 deletions app/util/__snapshots__/path-builder.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,12 @@ exports[`breadcrumbs 2`] = `
"path": "/settings/ssh-keys",
},
],
"systemUpdate (/system/update)": [
{
"label": "System Update",
"path": "/system/update",
},
],
"systemUtilization (/system/utilization)": [
{
"label": "Utilization",
Expand Down
5 changes: 5 additions & 0 deletions app/util/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export const links = {
systemIpPoolsDocs: 'https://docs.oxide.computer/guides/operator/ip-pool-management',
systemMetricsDocs: 'https://docs.oxide.computer/guides/operator/system-metrics',
systemSiloDocs: 'https://docs.oxide.computer/guides/operator/silo-management',
systemUpdateDocs: 'https://docs.oxide.computer/guides/operator/system-update',
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

transitIpsDocs:
'https://docs.oxide.computer/guides/configuring-guest-networking#_example_4_software_routing_tunnels',
troubleshootingAccess:
Expand Down Expand Up @@ -155,6 +156,10 @@ export const docLinks = {
href: links.systemSiloDocs,
linkText: 'Silos',
},
systemUpdate: {
href: links.systemUpdateDocs,
linkText: 'System Update',
},
instances: {
href: links.instancesDocs,
linkText: 'Instances',
Expand Down
19 changes: 16 additions & 3 deletions app/util/math.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { describe, expect, it } from 'vitest'
import { diskSizeNearest10, displayBigNum, percentage, round, splitDecimal } from './math'
import { GiB } from './units'

function roundTest() {
it('round', () => {
expect(round(1, 2)).toEqual(1)
expect(round(100, 2)).toEqual(100)
expect(round(999, 2)).toEqual(999)
Expand All @@ -30,9 +30,22 @@ function roundTest() {
expect(round(4.997, 2)).toEqual(5)
expect(round(5 / 2, 2)).toEqual(2.5) // math expressions are resolved
expect(round(1879048192 / GiB, 2)).toEqual(1.75) // constants can be evaluated
}
})

it('round', roundTest)
it('round trunc', () => {
expect(round(0.456, 2, 'trunc')).toEqual(0.45)
expect(round(-0.456, 2, 'trunc')).toEqual(-0.45)
expect(round(123.456, 2, 'trunc')).toEqual(123.45)
expect(round(1.9, 0, 'trunc')).toEqual(1)
expect(round(4.997, 2, 'trunc')).toEqual(4.99)
expect(round(1438972340398.648, 2, 'trunc')).toEqual(1438972340398.64)
expect(round(123.0001, 3, 'trunc')).toEqual(123)
expect(round(5 / 2, 2, 'trunc')).toEqual(2.5)
expect(round(1879048192 / GiB, 2, 'trunc')).toEqual(1.75)
expect(round(99.999, 2, 'trunc')).toEqual(99.99)
expect(round(0.999, 0, 'trunc')).toEqual(0)
expect(round(-3.14159, 3, 'trunc')).toEqual(-3.141)
})

it.each([
[2, 5, 40],
Expand Down
10 changes: 9 additions & 1 deletion app/util/math.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,19 @@ export function percentage<T extends number | bigint>(top: T, bottom: T): number
return Number(((top as bigint) * 10_000n) / (bottom as bigint)) / 100
}

export function round(num: number, digits: number) {
// there are a lot more options, but let's only include the ones we need.
// halfExpand is the default when nothing/undefined is passed in. trunc is like floor
// except it always rounds toward zero, so, e.g., -0.99 rounds to -0.9 instead
// of -1.0
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#roundingmode
type RoundingMode = 'trunc'

export function round(num: number, digits: number, roundingMode?: RoundingMode) {
// unlike with splitDecimal, we hard-code en-US to ensure that Number() will
// be able to parse the result
const nf = Intl.NumberFormat('en-US', {
maximumFractionDigits: digits,
roundingMode,
// very important, otherwise turning back into number will fail on n >= 1000
// due to commas
useGrouping: false,
Expand Down
1 change: 1 addition & 0 deletions app/util/path-builder.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ test('path builder', () => {
"sshKeyEdit": "/settings/ssh-keys/ss/edit",
"sshKeys": "/settings/ssh-keys",
"sshKeysNew": "/settings/ssh-keys-new",
"systemUpdate": "/system/update",
"systemUtilization": "/system/utilization",
"vpc": "/projects/p/vpcs/v/firewall-rules",
"vpcEdit": "/projects/p/vpcs/v/edit",
Expand Down
2 changes: 2 additions & 0 deletions app/util/path-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ export const pb = {
samlIdp: (params: PP.IdentityProvider) =>
`${siloBase(params)}/idps/saml/${params.provider}`,

systemUpdate: () => '/system/update',

profile: () => '/settings/profile',
sshKeys: () => '/settings/ssh-keys',
sshKeysNew: () => '/settings/ssh-keys-new',
Expand Down
1 change: 1 addition & 0 deletions mock-api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export * from './sled'
export * from './snapshot'
export * from './sshKeys'
export * from './switch'
export * from './system-update'
export * from './token'
export * from './user'
export * from './user-group'
Expand Down
Loading
Loading