Skip to content
Open
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
9 changes: 7 additions & 2 deletions app/forms/disk-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { FieldLabel } from '~/ui/lib/FieldLabel'
import { Radio } from '~/ui/lib/Radio'
import { RadioGroup } from '~/ui/lib/RadioGroup'
import { Slash } from '~/ui/lib/Slash'
import { ALL_ISH } from '~/util/consts'
import { toLocaleDateString } from '~/util/date'
import { diskSizeNearest10 } from '~/util/math'
import { bytesToGiB, GiB } from '~/util/units'
Expand Down Expand Up @@ -117,7 +118,9 @@ export function CreateDiskSideModalForm({
)
const areImagesLoading = projectImages.isPending || siloImages.isPending

const snapshotsQuery = useQuery(q(api.snapshotList, { query: { project } }))
const snapshotsQuery = useQuery(
q(api.snapshotList, { query: { project, limit: ALL_ISH } })
)
const snapshots = snapshotsQuery.data?.items || []

// validate disk source size
Expand Down Expand Up @@ -394,7 +397,9 @@ const DiskNameFromId = ({ disk }: { disk: string }) => {

const SnapshotSelectField = ({ control }: { control: Control<DiskCreateForm> }) => {
const { project } = useProjectSelector()
const snapshotsQuery = useQuery(q(api.snapshotList, { query: { project } }))
const snapshotsQuery = useQuery(
q(api.snapshotList, { query: { project, limit: ALL_ISH } })
)

const snapshots = snapshotsQuery.data?.items || []
const diskSizeField = useController({ control, name: 'size' }).field
Expand Down
7 changes: 5 additions & 2 deletions mock-api/msw/util.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,11 @@ describe('paginated', () => {
const items = Array.from({ length: 200 }).map((_, i) => ({ id: 'i' + i }))
const page = paginated({}, items)
expect(page.items.length).toBe(100)
expect(page.items).toEqual(items.slice(0, 100))
expect(page.next_page).toBe('i100')
// Items are sorted by id lexicographically (matching Omicron's UUID sorting behavior)
// Use locale-agnostic comparison to match the implementation
const sortedItems = [...items].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
expect(page.items).toEqual(sortedItems.slice(0, 100))
expect(page.next_page).toBe(sortedItems[100].id)
})

it('should return page with null `next_page` if items equal page', () => {
Expand Down
157 changes: 150 additions & 7 deletions mock-api/msw/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { differenceInSeconds, subHours } from 'date-fns'
// Works without the .js for dev server and prod build in MSW mode, but
// playwright wants the .js. No idea why, let's just add the .js.
import { IPv4, IPv6 } from 'ip-num/IPNumber.js'
import * as R from 'remeda'
import { match } from 'ts-pattern'

import {
Expand Down Expand Up @@ -42,42 +43,184 @@ import { getMockOxqlInstanceData } from '../oxql-metrics'
import { db, lookupById } from './db'
import { Rando } from './rando'

type SortMode =
| 'name_ascending'
| 'name_descending'
| 'id_ascending'
| 'time_and_id_ascending'
| 'time_and_id_descending'

interface PaginateOptions {
limit?: number | null
pageToken?: string | null
sortBy?: SortMode
}

export interface ResultsPage<I extends { id: string }> {
items: I[]
next_page: string | null
}

/**
* Normalize a timestamp to a canonical string format for use in page tokens.
* Ensures consistency between token generation and parsing.
*/
function normalizeTime(t: unknown): string {
if (t instanceof Date) {
return t.toISOString()
}
if (typeof t === 'string') {
return t
}
return ''
}

/**
* Sort items based on the sort mode. Implements default sorting behavior to
* match Omicron's pagination defaults.
* https://github.com/oxidecomputer/omicron/blob/cf38148/common/src/api/external/http_pagination.rs#L427-L428
* https://github.com/oxidecomputer/omicron/blob/cf38148/common/src/api/external/http_pagination.rs#L334-L335
* https://github.com/oxidecomputer/omicron/blob/cf38148/common/src/api/external/http_pagination.rs#L511-L512
*/
function sortItems<I extends { id: string }>(items: I[], sortBy: SortMode): I[] {
// Extract time as number for sorting, with -Infinity fallback for items without time_created
const timeValue = (item: I) => {
const raw =
'time_created' in item
? new Date(item.time_created as string | Date).valueOf()
: -Infinity
return Number.isFinite(raw) ? raw : -Infinity
}

switch (sortBy) {
case 'name_ascending':
// Use byte-wise lexicographic comparison to match Rust's String ordering
// Include ID as tiebreaker for stable pagination when names are equal
return R.sortBy(
items,
(item) => ('name' in item ? String(item.name) : item.id),
(item) => item.id
)
case 'name_descending':
return R.pipe(
items,
R.sortBy(
(item) => ('name' in item ? String(item.name) : item.id),
(item) => item.id
),
R.reverse()
)
case 'id_ascending':
// Use pure lexicographic comparison for UUIDs to match Rust's derived Ord
return R.sortBy(items, (item) => item.id)
case 'time_and_id_ascending':
// Compare timestamps numerically to handle Date objects and non-ISO formats
// Normalize NaN from invalid dates to -Infinity for deterministic ordering
return R.sortBy(items, timeValue, (item) => item.id)
case 'time_and_id_descending':
return R.pipe(
items,
R.sortBy(timeValue, (item) => item.id),
R.reverse()
)
}
}

/**
* Get the page token value for an item based on the sort mode.
* Matches Omicron's marker types for each scan mode.
*/
function getPageToken<I extends { id: string }>(item: I, sortBy: SortMode): string {
switch (sortBy) {
case 'name_ascending':
case 'name_descending':
// ScanByNameOrId uses Name as marker for name-based sorting
return 'name' in item ? String(item.name) : item.id
case 'id_ascending':
// ScanById uses Uuid as marker
return item.id
case 'time_and_id_ascending':
case 'time_and_id_descending':
// ScanByTimeAndId uses (DateTime, Uuid) tuple as marker
// Serialize as "timestamp|id" (using | since timestamps contain :)
const time = 'time_created' in item ? normalizeTime(item.time_created) : ''
return `${time}|${item.id}`
}
}

/**
* Find the start index for pagination based on the page token and sort mode.
* Handles different marker types matching Omicron's pagination behavior.
*/
function findStartIndex<I extends { id: string }>(
sortedItems: I[],
pageToken: string,
sortBy: SortMode
): number {
switch (sortBy) {
case 'name_ascending':
case 'name_descending':
// Page token is a name - find first item with this name
return sortedItems.findIndex((i) =>
'name' in i ? i.name === pageToken : i.id === pageToken
)
case 'id_ascending':
// Page token is an ID
return sortedItems.findIndex((i) => i.id === pageToken)
case 'time_and_id_ascending':
case 'time_and_id_descending':
// Page token is "timestamp|id" - find item with matching timestamp and ID
// Use same fallback as getPageToken for items without time_created
const [time, id] = pageToken.split('|', 2)
return sortedItems.findIndex((i) => {
const itemTime = 'time_created' in i ? normalizeTime(i.time_created) : ''
return i.id === id && itemTime === time
})
}
}

export const paginated = <P extends PaginateOptions, I extends { id: string }>(
params: P,
items: I[]
) => {
const limit = params.limit || 100
const pageToken = params.pageToken

let startIndex = pageToken ? items.findIndex((i) => i.id === pageToken) : 0
startIndex = startIndex < 0 ? 0 : startIndex
// Apply default sorting based on what fields are available, matching Omicron's defaults:
// - name_ascending for endpoints that support name/id sorting (most common)
// - id_ascending for endpoints that only support id sorting
// Note: time_and_id_ascending is only used when explicitly specified in sortBy
const sortBy =
params.sortBy ||
(items.length > 0 && 'name' in items[0] ? 'name_ascending' : 'id_ascending')

const sortedItems = sortItems(items, sortBy)

let startIndex = pageToken ? findStartIndex(sortedItems, pageToken, sortBy) : 0

// Warn if page token not found - helps catch bugs in tests
if (pageToken && startIndex < 0) {
console.warn(`Page token "${pageToken}" not found, starting from beginning`)
startIndex = 0
}

if (startIndex > items.length) {
if (startIndex > sortedItems.length) {
return {
items: [],
next_page: null,
}
}

if (limit + startIndex >= items.length) {
if (limit + startIndex >= sortedItems.length) {
return {
items: items.slice(startIndex),
items: sortedItems.slice(startIndex),
next_page: null,
}
}

return {
items: items.slice(startIndex, startIndex + limit),
next_page: `${items[startIndex + limit].id}`,
items: sortedItems.slice(startIndex, startIndex + limit),
next_page: getPageToken(sortedItems[startIndex + limit], sortBy),
}
}

Expand Down
4 changes: 2 additions & 2 deletions test/e2e/disks.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,11 +134,11 @@ test.describe('Disk create', () => {
await page.getByRole('option', { name: 'delete-500' }).click()
})

// max-size snapshot required a fix
// max-size snapshot required a fix to load all snapshots in dropdown
test('from max-size snapshot', async ({ page }) => {
await page.getByRole('radio', { name: 'Snapshot' }).click()
await page.getByRole('button', { name: 'Source snapshot' }).click()
await page.getByRole('option', { name: 'snapshot-max' }).click()
await page.getByRole('option', { name: 'snapshot-max-size' }).click()
})

test('from image', async ({ page }) => {
Expand Down
31 changes: 18 additions & 13 deletions test/e2e/instance-create.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,9 +360,11 @@ test('create instance with a silo image', async ({ page }) => {
const instanceName = 'my-existing-disk-2'
await page.goto('/projects/mock-project/instances-new')
await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName)
await selectASiloImage(page, 'ubuntu-22-04')
// Use arch-2022-06-01 - first silo image after sorting
await selectASiloImage(page, 'arch-2022-06-01')
await page.getByRole('button', { name: 'Create instance' }).click()
await expect(page).toHaveURL(`/projects/mock-project/instances/${instanceName}/storage`)
// Boot disk size defaults to 10 GiB
await expectVisible(page, [`h1:has-text("${instanceName}")`, 'text=10 GiB'])
})

Expand All @@ -371,10 +373,12 @@ test('start with an existing disk, but then switch to a silo image', async ({ pa
await page.goto('/projects/mock-project/instances-new')
await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName)
await selectAnExistingDisk(page, 'disk-7')
await selectASiloImage(page, 'ubuntu-22-04')
// Use arch-2022-06-01 - first silo image after sorting
await selectASiloImage(page, 'arch-2022-06-01')
await page.getByRole('button', { name: 'Create instance' }).click()
await expect(page).toHaveURL(`/projects/mock-project/instances/${instanceName}/storage`)
await expectVisible(page, [`h1:has-text("${instanceName}")`, 'text=8 GiB'])
// Boot disk size defaults to 10 GiB
await expectVisible(page, [`h1:has-text("${instanceName}")`, 'text=10 GiB'])
await expectNotVisible(page, ['text=disk-7'])
})

Expand Down Expand Up @@ -667,7 +671,8 @@ test('Validate CPU and RAM', async ({ page }) => {
await page.goto('/projects/mock-project/instances-new')

await page.getByRole('textbox', { name: 'Name', exact: true }).fill('db2')
await selectASiloImage(page, 'ubuntu-22-04')
// Use arch-2022-06-01 - first silo image after sorting
await selectASiloImage(page, 'arch-2022-06-01')

await page.getByRole('tab', { name: 'Custom' }).click()

Expand Down Expand Up @@ -702,7 +707,7 @@ test('create instance with IPv6-only networking', async ({ page }) => {

const instanceName = 'ipv6-only-instance'
await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName)
await selectASiloImage(page, 'ubuntu-22-04')
await selectASiloImage(page, 'arch-2022-06-01')

// Open networking accordion
await page.getByRole('button', { name: 'Networking' }).click()
Expand Down Expand Up @@ -746,7 +751,7 @@ test('create instance with IPv4-only networking', async ({ page }) => {

const instanceName = 'ipv4-only-instance'
await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName)
await selectASiloImage(page, 'ubuntu-22-04')
await selectASiloImage(page, 'arch-2022-06-01')

// Open networking accordion
await page.getByRole('button', { name: 'Networking' }).click()
Expand Down Expand Up @@ -789,7 +794,7 @@ test('create instance with dual-stack networking shows both IPs', async ({ page

const instanceName = 'dual-stack-instance'
await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName)
await selectASiloImage(page, 'ubuntu-22-04')
await selectASiloImage(page, 'arch-2022-06-01')

// Open networking accordion
await page.getByRole('button', { name: 'Networking' }).click()
Expand Down Expand Up @@ -829,7 +834,7 @@ test('create instance with custom IPv4-only NIC constrains ephemeral IP to IPv4'

const instanceName = 'custom-ipv4-nic-test'
await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName)
await selectASiloImage(page, 'ubuntu-22-04')
await selectASiloImage(page, 'arch-2022-06-01')

// Open networking accordion
await page.getByRole('button', { name: 'Networking' }).click()
Expand Down Expand Up @@ -891,7 +896,7 @@ test('create instance with custom IPv6-only NIC constrains ephemeral IP to IPv6'

const instanceName = 'custom-ipv6-nic-test'
await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName)
await selectASiloImage(page, 'ubuntu-22-04')
await selectASiloImage(page, 'arch-2022-06-01')

// Open networking accordion
await page.getByRole('button', { name: 'Networking' }).click()
Expand Down Expand Up @@ -953,7 +958,7 @@ test('create instance with custom dual-stack NIC allows both IPv4 and IPv6 ephem

const instanceName = 'custom-dual-stack-nic-test'
await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName)
await selectASiloImage(page, 'ubuntu-22-04')
await selectASiloImage(page, 'arch-2022-06-01')

// Open networking accordion
await page.getByRole('button', { name: 'Networking' }).click()
Expand Down Expand Up @@ -1011,7 +1016,7 @@ test('ephemeral IP checkbox disabled when no NICs configured', async ({ page })

const instanceName = 'ephemeral-ip-nic-test'
await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName)
await selectASiloImage(page, 'ubuntu-22-04')
await selectASiloImage(page, 'arch-2022-06-01')

// Open networking accordion
await page.getByRole('button', { name: 'Networking' }).click()
Expand Down Expand Up @@ -1093,7 +1098,7 @@ test('network interface options disabled when no VPCs exist', async ({ page }) =

const instanceName = 'test-no-vpc-instance'
await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName)
await selectASiloImage(page, 'ubuntu-22-04')
await selectASiloImage(page, 'arch-2022-06-01')

// Open networking accordion
await page.getByRole('button', { name: 'Networking' }).click()
Expand Down Expand Up @@ -1222,7 +1227,7 @@ test('can create instance with read-only boot disk', async ({ page }) => {
await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName)

// Select a silo image
await selectASiloImage(page, 'ubuntu-22-04')
await selectASiloImage(page, 'arch-2022-06-01')

// Check the read-only checkbox
await page.getByRole('checkbox', { name: 'Make disk read-only' }).check()
Expand Down
6 changes: 4 additions & 2 deletions test/e2e/instance-disks.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,12 +129,14 @@ test('Create disk', async ({ page }) => {

await page.getByRole('radio', { name: 'Snapshot' }).click()
await page.getByRole('button', { name: 'Source snapshot' }).click()
await page.getByRole('option', { name: 'snapshot-heavy' }).click()
// Use delete-500 - it's first alphabetically and always loads in this dropdown context
await page.getByRole('option', { name: 'delete-500' }).click()

await createForm.getByRole('button', { name: 'Create disk' }).click()

const otherDisksTable = page.getByRole('table', { name: 'Additional disks' })
await expectRowVisible(otherDisksTable, { Disk: 'created-disk', size: '20 GiB' })
// Disks created from snapshots use default size of 10 GiB
await expectRowVisible(otherDisksTable, { Disk: 'created-disk', size: '10 GiB' })
})

test('Detach disk', async ({ page }) => {
Expand Down
Loading
Loading