From 91aebf49861e6ac701795af9049eba9231cc2f9a Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 4 Oct 2024 13:24:34 -0500 Subject: [PATCH 01/14] bump API for instance resize and avoid getting broken by it --- OMICRON_VERSION | 2 +- app/api/__generated__/Api.ts | 32 +++++++++++++------ app/api/__generated__/OMICRON_VERSION | 2 +- app/api/__generated__/validate.ts | 24 ++++++++------ .../instances/instance/tabs/StorageTab.tsx | 12 ++++--- mock-api/msw/handlers.ts | 4 +++ 6 files changed, 51 insertions(+), 25 deletions(-) diff --git a/OMICRON_VERSION b/OMICRON_VERSION index f7a1d4ae62..4fb54663b6 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -c50cf019cd9be35f98266a7f4acacab0236b3a3d +fd7e5a1387475d1ebec9880e3bbb854f69e5fcf6 diff --git a/app/api/__generated__/Api.ts b/app/api/__generated__/Api.ts index e64990f1f9..1bd48cdfc4 100644 --- a/app/api/__generated__/Api.ts +++ b/app/api/__generated__/Api.ts @@ -1710,6 +1710,16 @@ export type ImageResultsPage = { */ export type ImportBlocksBulkWrite = { base64EncodedData: string; offset: number } +/** + * A policy determining when an instance should be automatically restarted by the control plane. + */ +export type InstanceAutoRestartPolicy = + /** The instance should not be automatically restarted by the control plane if it fails. */ + | 'never' + + /** If this instance is running and unexpectedly fails (e.g. due to a host software crash or unexpected host reboot), the control plane will make a best-effort attempt to restart it. The control plane may choose not to restart the instance to preserve the overall availability of the system. */ + | 'best_effort' + /** * The number of CPUs in an Instance */ @@ -1761,6 +1771,10 @@ If this is not present, then either the instance has never been automatically re autoRestartCooldownExpiration?: Date /** `true` if this instance's auto-restart policy will permit the control plane to automatically restart it if it enters the `Failed` state. */ autoRestartEnabled: boolean + /** The auto-restart policy configured for this instance, or `None` if no explicit policy is configured. + +If this is not present, then this instance uses the default auto-restart policy, which may or may not allow it to be restarted. The `auto_restart_enabled` field indicates whether the instance will be automatically restarted. */ + autoRestartPolicy?: InstanceAutoRestartPolicy /** the ID of the disk used to boot this Instance, if a specific one is assigned. */ bootDiskId?: string /** human-readable free-form text about a resource */ @@ -1789,16 +1803,6 @@ If this is not present, then this instance has not been automatically restarted. timeRunStateUpdated: Date } -/** - * A policy determining when an instance should be automatically restarted by the control plane. - */ -export type InstanceAutoRestartPolicy = - /** The instance should not be automatically restarted by the control plane if it fails. */ - | 'never' - - /** If this instance is running and unexpectedly fails (e.g. due to a host software crash or unexpected host reboot), the control plane will make a best-effort attempt to restart it. The control plane may choose not to restart the instance to preserve the overall availability of the system. */ - | 'best_effort' - /** * Describe the instance's disks at creation time */ @@ -1976,10 +1980,18 @@ export type InstanceSerialConsoleData = { * Parameters of an `Instance` that can be reconfigured after creation. */ export type InstanceUpdate = { + /** The auto-restart policy for this instance. + +If not provided, unset the instance's auto-restart policy. */ + autoRestartPolicy?: InstanceAutoRestartPolicy /** Name or ID of the disk the instance should be instructed to boot from. If not provided, unset the instance's boot disk. */ bootDisk?: NameOrId + /** The amount of memory to assign to this instance. */ + memory: ByteCount + /** The number of CPUs to assign to this instance. */ + ncpus: InstanceCpuCount } /** diff --git a/app/api/__generated__/OMICRON_VERSION b/app/api/__generated__/OMICRON_VERSION index 2c1de3ebde..73f543dd91 100644 --- a/app/api/__generated__/OMICRON_VERSION +++ b/app/api/__generated__/OMICRON_VERSION @@ -1,2 +1,2 @@ # generated file. do not update manually. see docs/update-pinned-api.md -c50cf019cd9be35f98266a7f4acacab0236b3a3d +fd7e5a1387475d1ebec9880e3bbb854f69e5fcf6 diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index 77402ef0df..dd36c43722 100644 --- a/app/api/__generated__/validate.ts +++ b/app/api/__generated__/validate.ts @@ -1645,6 +1645,14 @@ export const ImportBlocksBulkWrite = z.preprocess( z.object({ base64EncodedData: z.string(), offset: z.number().min(0) }) ) +/** + * A policy determining when an instance should be automatically restarted by the control plane. + */ +export const InstanceAutoRestartPolicy = z.preprocess( + processResponseBody, + z.enum(['never', 'best_effort']) +) + /** * The number of CPUs in an Instance */ @@ -1682,6 +1690,7 @@ export const Instance = z.preprocess( z.object({ autoRestartCooldownExpiration: z.coerce.date().optional(), autoRestartEnabled: SafeBoolean, + autoRestartPolicy: InstanceAutoRestartPolicy.optional(), bootDiskId: z.string().uuid().optional(), description: z.string(), hostname: z.string(), @@ -1698,14 +1707,6 @@ export const Instance = z.preprocess( }) ) -/** - * A policy determining when an instance should be automatically restarted by the control plane. - */ -export const InstanceAutoRestartPolicy = z.preprocess( - processResponseBody, - z.enum(['never', 'best_effort']) -) - /** * Describe the instance's disks at creation time */ @@ -1852,7 +1853,12 @@ export const InstanceSerialConsoleData = z.preprocess( */ export const InstanceUpdate = z.preprocess( processResponseBody, - z.object({ bootDisk: NameOrId.optional() }) + z.object({ + autoRestartPolicy: InstanceAutoRestartPolicy.optional(), + bootDisk: NameOrId.optional(), + memory: ByteCount, + ncpus: InstanceCpuCount, + }) ) /** diff --git a/app/pages/project/instances/instance/tabs/StorageTab.tsx b/app/pages/project/instances/instance/tabs/StorageTab.tsx index 1083b509e5..ec39b1d1c7 100644 --- a/app/pages/project/instances/instance/tabs/StorageTab.tsx +++ b/app/pages/project/instances/instance/tabs/StorageTab.tsx @@ -145,6 +145,10 @@ export function StorageTab() { [disks.items, instance.bootDiskId] ) + // Needed to keep them the same while setting boot disk. + // Extracted to keep dep array appropriately zealous. + const { ncpus, memory } = instance + const makeBootDiskActions = useCallback( (disk: InstanceDisk): MenuAction[] => [ getSnapshotAction(disk), @@ -161,7 +165,7 @@ export function StorageTab() { doAction: () => instanceUpdate({ path: { instance: instance.id }, - body: { bootDisk: undefined }, + body: { bootDisk: undefined, ncpus, memory }, }), errorTitle: 'Could not unset boot disk', modalTitle: 'Confirm unset boot disk', @@ -189,7 +193,7 @@ export function StorageTab() { onActivate() {}, // it's always disabled, so noop is ok }, ], - [instanceUpdate, instance.id, getSnapshotAction] + [instanceUpdate, instance.id, getSnapshotAction, ncpus, memory] ) const makeOtherDiskActions = useCallback( @@ -210,7 +214,7 @@ export function StorageTab() { doAction: () => instanceUpdate({ path: { instance: instance.id }, - body: { bootDisk: disk.id }, + body: { bootDisk: disk.id, ncpus, memory }, }), errorTitle: `Could not ${verb} boot disk`, modalTitle: `Confirm ${verb} boot disk`, @@ -245,7 +249,7 @@ export function StorageTab() { }, }, ], - [detachDisk, instanceUpdate, instance.id, getSnapshotAction, bootDisks] + [detachDisk, instanceUpdate, instance.id, getSnapshotAction, bootDisks, ncpus, memory] ) const attachDisk = useApiMutation('instanceDiskAttach', { diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index c81f53d957..d2b80c2faf 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -613,6 +613,10 @@ export const handlers = makeHandlers({ instance.boot_disk_id = undefined } + // always present on the body, always set them + instance.ncpus = body.ncpus + instance.memory = body.memory + return instance }, instanceDelete({ path, query }) { From c71671f8d87edc2ae461682570232f957d613c3f Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 4 Oct 2024 14:41:44 -0500 Subject: [PATCH 02/14] clunky but functioning side modal form based resize UI --- app/forms/instance-resize.tsx | 75 +++++++++++++++++++++++++ app/pages/project/instances/actions.tsx | 7 ++- app/routes.tsx | 12 ++++ app/util/path-builder.spec.ts | 1 + app/util/path-builder.ts | 1 + 5 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 app/forms/instance-resize.tsx diff --git a/app/forms/instance-resize.tsx b/app/forms/instance-resize.tsx new file mode 100644 index 0000000000..9d916a10e0 --- /dev/null +++ b/app/forms/instance-resize.tsx @@ -0,0 +1,75 @@ +/* + * 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 { useForm } from 'react-hook-form' +import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom' +import * as R from 'remeda' + +import { + apiQueryClient, + useApiMutation, + useApiQueryClient, + usePrefetchedApiQuery, +} from '@oxide/api' + +import { NumberField } from '~/components/form/fields/NumberField' +import { SideModalForm } from '~/components/form/SideModalForm' +import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' +import { pb } from '~/util/path-builder' + +InstanceResizeForm.loader = async ({ params }: LoaderFunctionArgs) => { + const { project, instance } = getInstanceSelector(params) + await apiQueryClient.prefetchQuery('instanceView', { + path: { instance }, + query: { project }, + }) + return null +} + +export function InstanceResizeForm() { + const { instance: instanceName, project } = useInstanceSelector() + const queryClient = useApiQueryClient() + const navigate = useNavigate() + + const { data: instance } = usePrefetchedApiQuery('instanceView', { + path: { instance: instanceName }, + query: { project }, + }) + + const instanceUpdate = useApiMutation('instanceUpdate', { + onSuccess(_updatedInstance) { + queryClient.invalidateQueries('instanceView') + navigate(pb.instance({ project, instance: instanceName })) + addToast({ title: 'Instance updated' }) + }, + }) + + const form = useForm({ defaultValues: R.pick(instance, ['ncpus', 'memory']) }) + + return ( + navigate(pb.instance({ project, instance: instanceName }))} + onSubmit={({ ncpus, memory }) => { + instanceUpdate.mutate({ + path: { instance: instanceName }, + query: { project }, + // very important to include the boot disk or it will be unset + body: { ncpus, memory, bootDisk: instance.bootDiskId }, + }) + }} + loading={instanceUpdate.isPending} + submitError={instanceUpdate.error} + > + + + + ) +} diff --git a/app/pages/project/instances/actions.tsx b/app/pages/project/instances/actions.tsx index df251f3ab5..79d7a7accc 100644 --- a/app/pages/project/instances/actions.tsx +++ b/app/pages/project/instances/actions.tsx @@ -118,10 +118,13 @@ export const useMakeInstanceActions = ( ), }, { - label: 'View serial console', + label: 'Resize', onActivate() { - navigate(pb.serialConsole(instanceSelector)) + navigate(pb.instanceResize(instanceSelector)) }, + disabled: !instanceCan.update(instance) && ( + <>Only {fancifyStates(instanceCan.update.states)} instances can be resized + ), }, { label: 'Delete', diff --git a/app/routes.tsx b/app/routes.tsx index 86b159f5fa..2f1377547b 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -23,6 +23,7 @@ import { import { CreateImageFromSnapshotSideModalForm } from './forms/image-from-snapshot' import { CreateImageSideModalForm } from './forms/image-upload' import { CreateInstanceForm } from './forms/instance-create' +import { InstanceResizeForm } from './forms/instance-resize' import { CreateIpPoolSideModalForm } from './forms/ip-pool-create' import { EditIpPoolSideModalForm } from './forms/ip-pool-edit' import { IpPoolAddRangeSideModalForm } from './forms/ip-pool-range-add' @@ -319,6 +320,17 @@ export const routes = createRoutesFromElements( loader={StorageTab.loader} handle={{ crumb: 'Storage' }} /> + + + + + } + loader={StorageTab.loader} + handle={{ crumb: 'Resize' }} + /> } diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 76e39846ef..0f5789256d 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -44,6 +44,7 @@ test('path builder', () => { "instanceConnect": "/projects/p/instances/i/connect", "instanceMetrics": "/projects/p/instances/i/metrics", "instanceNetworking": "/projects/p/instances/i/networking", + "instanceResize": "/projects/p/instances/i/resize", "instanceStorage": "/projects/p/instances/i/storage", "instances": "/projects/p/instances", "instancesNew": "/projects/p/instances-new", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 1709b19c59..b135a17dd6 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -62,6 +62,7 @@ export const pb = { instanceStorage: (params: Instance) => `${instanceBase(params)}/storage`, instanceConnect: (params: Instance) => `${instanceBase(params)}/connect`, instanceNetworking: (params: Instance) => `${instanceBase(params)}/networking`, + instanceResize: (params: Instance) => `${instanceBase(params)}/resize`, serialConsole: (params: Instance) => `${instanceBase(params)}/serial-console`, disksNew: (params: Project) => `${projectBase(params)}/disks-new`, From c25857457da037049fbac04e1a162d070382e14b Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Wed, 30 Oct 2024 17:02:51 +0000 Subject: [PATCH 03/14] Instance resize modal (#2495) * Switch to regular modal * Improve modal close button alignment * Semi 'currently using:' and fix semi weight * Revert: fix semi weight Handled in #2496 instead * Switch route for modal * Disable submit if the specs are the same * Improve toast * Fix lint error `no-unused-expressions` --- app/forms/instance-resize.tsx | 75 --------- app/pages/project/instances/InstancesPage.tsx | 18 +- app/pages/project/instances/actions.tsx | 14 +- .../instances/instance/InstancePage.tsx | 155 +++++++++++++++++- app/routes.tsx | 12 -- app/ui/lib/Modal.tsx | 2 +- app/util/path-builder.spec.ts | 1 - app/util/path-builder.ts | 1 - 8 files changed, 176 insertions(+), 102 deletions(-) delete mode 100644 app/forms/instance-resize.tsx diff --git a/app/forms/instance-resize.tsx b/app/forms/instance-resize.tsx deleted file mode 100644 index 9d916a10e0..0000000000 --- a/app/forms/instance-resize.tsx +++ /dev/null @@ -1,75 +0,0 @@ -/* - * 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 { useForm } from 'react-hook-form' -import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom' -import * as R from 'remeda' - -import { - apiQueryClient, - useApiMutation, - useApiQueryClient, - usePrefetchedApiQuery, -} from '@oxide/api' - -import { NumberField } from '~/components/form/fields/NumberField' -import { SideModalForm } from '~/components/form/SideModalForm' -import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params' -import { addToast } from '~/stores/toast' -import { pb } from '~/util/path-builder' - -InstanceResizeForm.loader = async ({ params }: LoaderFunctionArgs) => { - const { project, instance } = getInstanceSelector(params) - await apiQueryClient.prefetchQuery('instanceView', { - path: { instance }, - query: { project }, - }) - return null -} - -export function InstanceResizeForm() { - const { instance: instanceName, project } = useInstanceSelector() - const queryClient = useApiQueryClient() - const navigate = useNavigate() - - const { data: instance } = usePrefetchedApiQuery('instanceView', { - path: { instance: instanceName }, - query: { project }, - }) - - const instanceUpdate = useApiMutation('instanceUpdate', { - onSuccess(_updatedInstance) { - queryClient.invalidateQueries('instanceView') - navigate(pb.instance({ project, instance: instanceName })) - addToast({ title: 'Instance updated' }) - }, - }) - - const form = useForm({ defaultValues: R.pick(instance, ['ncpus', 'memory']) }) - - return ( - navigate(pb.instance({ project, instance: instanceName }))} - onSubmit={({ ncpus, memory }) => { - instanceUpdate.mutate({ - path: { instance: instanceName }, - query: { project }, - // very important to include the boot disk or it will be unset - body: { ncpus, memory, bootDisk: instance.bootDiskId }, - }) - }} - loading={instanceUpdate.isPending} - submitError={instanceUpdate.error} - > - - - - ) -} diff --git a/app/pages/project/instances/InstancesPage.tsx b/app/pages/project/instances/InstancesPage.tsx index 343f4c1871..9fce0627a1 100644 --- a/app/pages/project/instances/InstancesPage.tsx +++ b/app/pages/project/instances/InstancesPage.tsx @@ -7,7 +7,7 @@ */ import { createColumnHelper } from '@tanstack/react-table' import { filesize } from 'filesize' -import { useMemo, useRef } from 'react' +import { useMemo, useRef, useState } from 'react' import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom' import { apiQueryClient, usePrefetchedApiQuery, type Instance } from '@oxide/api' @@ -33,6 +33,7 @@ import { toLocaleTimeString } from '~/util/date' import { pb } from '~/util/path-builder' import { useMakeInstanceActions } from './actions' +import { ResizeInstanceModal } from './instance/InstancePage' const EmptyState = () => ( (null) const makeActions = useMakeInstanceActions( { project }, - { onSuccess: refetchInstances, onDelete: refetchInstances } + { + onSuccess: refetchInstances, + onDelete: refetchInstances, + onResizeClick: (instance) => setResizeInstance(instance), + } ) // this is a whole thing. sit down. @@ -212,6 +218,14 @@ export function InstancesPage() { New Instance } /> + {resizeInstance && ( + setResizeInstance(null)} + onListView + /> + )} ) } diff --git a/app/pages/project/instances/actions.tsx b/app/pages/project/instances/actions.tsx index 79d7a7accc..37e59a4757 100644 --- a/app/pages/project/instances/actions.tsx +++ b/app/pages/project/instances/actions.tsx @@ -6,7 +6,6 @@ * Copyright Oxide Computer Company */ import { useCallback } from 'react' -import { useNavigate } from 'react-router-dom' import { instanceCan, useApiMutation, type Instance } from '@oxide/api' @@ -15,7 +14,6 @@ import { confirmAction } from '~/stores/confirm-action' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import type { MakeActions } from '~/table/columns/action-col' -import { pb } from '~/util/path-builder' import { fancifyStates } from './instance/tabs/common' @@ -26,14 +24,13 @@ type Options = { // hook has to expand to encompass the sum of all the APIs of these hooks it // call internally, the abstraction is not good onDelete?: () => void + onResizeClick?: (instance: Instance) => void } export const useMakeInstanceActions = ( { project }: { project: string }, options: Options = {} ): MakeActions => { - const navigate = useNavigate() - // if you also pass onSuccess to mutate(), this one is not overridden — this // one runs first, then the one passed to mutate(). // @@ -51,7 +48,6 @@ export const useMakeInstanceActions = ( return useCallback( (instance) => { - const instanceSelector = { project, instance: instance.name } const instanceParams = { path: { instance: instance.name }, query: { project } } return [ { @@ -119,8 +115,10 @@ export const useMakeInstanceActions = ( }, { label: 'Resize', - onActivate() { - navigate(pb.instanceResize(instanceSelector)) + onActivate: () => { + if (options.onResizeClick) { + options.onResizeClick(instance) + } }, disabled: !instanceCan.update(instance) && ( <>Only {fancifyStates(instanceCan.update.states)} instances can be resized @@ -147,11 +145,11 @@ export const useMakeInstanceActions = ( }, [ project, - navigate, deleteInstanceAsync, rebootInstance, startInstance, stopInstanceAsync, + options, ] ) } diff --git a/app/pages/project/instances/instance/InstancePage.tsx b/app/pages/project/instances/instance/InstancePage.tsx index 8e1a69ca3e..6a892136c4 100644 --- a/app/pages/project/instances/instance/InstancePage.tsx +++ b/app/pages/project/instances/instance/InstancePage.tsx @@ -6,33 +6,46 @@ * Copyright Oxide Computer Company */ import { filesize } from 'filesize' -import { useMemo } from 'react' +import { useMemo, useState } from 'react' +import { useForm } from 'react-hook-form' import { Link, useNavigate, type LoaderFunctionArgs } from 'react-router-dom' import { apiQueryClient, + useApiMutation, useApiQuery, usePrefetchedApiQuery, + type Instance, type InstanceNetworkInterface, } from '@oxide/api' import { Instances24Icon } from '@oxide/design-system/icons/react' -import { instanceTransitioning } from '~/api/util' +import { + INSTANCE_MAX_CPU, + INSTANCE_MAX_RAM_GiB, + instanceCan, + instanceTransitioning, +} from '~/api/util' import { ExternalIps } from '~/components/ExternalIps' +import { NumberField } from '~/components/form/fields/NumberField' import { InstanceDocsPopover } from '~/components/InstanceDocsPopover' import { MoreActionsMenu } from '~/components/MoreActionsMenu' import { RefreshButton } from '~/components/RefreshButton' import { RouteTabs, Tab } from '~/components/RouteTabs' import { InstanceStateBadge } from '~/components/StateBadge' import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' import { EmptyCell } from '~/table/cells/EmptyCell' import { DateTime } from '~/ui/lib/DateTime' +import { Message } from '~/ui/lib/Message' +import { Modal } from '~/ui/lib/Modal' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { Spinner } from '~/ui/lib/Spinner' import { Tooltip } from '~/ui/lib/Tooltip' import { Truncate } from '~/ui/lib/Truncate' import { pb } from '~/util/path-builder' +import { GiB } from '~/util/units' import { useMakeInstanceActions } from '../actions' @@ -90,6 +103,7 @@ const POLL_INTERVAL = 1000 export function InstancePage() { const instanceSelector = useInstanceSelector() + const [resizeInstance, setResizeInstance] = useState(false) const navigate = useNavigate() const makeActions = useMakeInstanceActions(instanceSelector, { @@ -99,6 +113,7 @@ export function InstancePage() { apiQueryClient.invalidateQueries('instanceList') navigate(pb.instances(instanceSelector)) }, + onResizeClick: () => setResizeInstance(true), }) const { data: instance } = usePrefetchedApiQuery( @@ -217,6 +232,142 @@ export function InstancePage() { Networking Connect + {resizeInstance && ( + setResizeInstance(false)} + /> + )} ) } + +export function ResizeInstanceModal({ + instance, + project, + onDismiss, + onListView = false, +}: { + instance: Instance + project: string + onDismiss: () => void + onListView?: boolean +}) { + const instanceUpdate = useApiMutation('instanceUpdate', { + onSuccess(_updatedInstance) { + if (onListView) { + apiQueryClient.invalidateQueries('instanceList') + } else { + apiQueryClient.invalidateQueries('instanceView') + } + onDismiss() + addToast({ + content: `${instance.name} has been resized`, + cta: onListView + ? { + text: `View instance`, + link: pb.instance({ project, instance: instance.name }), + } + : undefined, // Only link to the instance if we're not already on that page + }) + }, + onError: (err) => { + addToast({ title: 'Error', content: err.message, variant: 'error' }) + }, + onSettled: onDismiss, + }) + + const form = useForm({ + defaultValues: { + ncpus: instance.ncpus, + memory: instance.memory / GiB, // memory is stored as bytes + }, + mode: 'onChange', + }) + + const canResize = instanceCan.update(instance) + const willChange = + form.watch('ncpus') !== instance.ncpus || form.watch('memory') !== instance.memory / GiB + const isDisabled = !form.formState.isValid || !canResize || !willChange + + const onAction = form.handleSubmit(({ ncpus, memory }) => { + instanceUpdate.mutate({ + path: { instance: instance.name }, + query: { project }, + body: { ncpus, memory: memory * GiB, bootDisk: instance.bootDiskId }, + }) + }) + + return ( + + + + {!canResize ? ( + + ) : ( + +
+ {instance.ncpus}{' '} + vCPUs / {instance.memory / GiB} GiB +
+ + } + /> + )} +
+ { + if (cpus < 1) { + return `Must be at least 1 vCPU` + } + if (cpus > INSTANCE_MAX_CPU) { + return `CPUs capped to ${INSTANCE_MAX_CPU}` + } + // We can show this error and therefore inform the user + // of the limit rather than preventing it completely + }} + disabled={!canResize} + /> + { + if (memory < 1) { + return `Must be at least 1 GiB` + } + if (memory > INSTANCE_MAX_RAM_GiB) { + return `Can be at most ${INSTANCE_MAX_RAM_GiB} GiB` + } + }} + disabled={!canResize} + /> + + {instanceUpdate.error && ( +

{instanceUpdate.error.message}

+ )} +
+
+ +
+ ) +} diff --git a/app/routes.tsx b/app/routes.tsx index 2f1377547b..86b159f5fa 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -23,7 +23,6 @@ import { import { CreateImageFromSnapshotSideModalForm } from './forms/image-from-snapshot' import { CreateImageSideModalForm } from './forms/image-upload' import { CreateInstanceForm } from './forms/instance-create' -import { InstanceResizeForm } from './forms/instance-resize' import { CreateIpPoolSideModalForm } from './forms/ip-pool-create' import { EditIpPoolSideModalForm } from './forms/ip-pool-edit' import { IpPoolAddRangeSideModalForm } from './forms/ip-pool-range-add' @@ -320,17 +319,6 @@ export const routes = createRoutesFromElements( loader={StorageTab.loader} handle={{ crumb: 'Storage' }} /> - - - - - } - loader={StorageTab.loader} - handle={{ crumb: 'Resize' }} - /> } diff --git a/app/ui/lib/Modal.tsx b/app/ui/lib/Modal.tsx index 3994420c02..6f444baf97 100644 --- a/app/ui/lib/Modal.tsx +++ b/app/ui/lib/Modal.tsx @@ -75,7 +75,7 @@ export function Modal({ children, onDismiss, title, isOpen }: ModalProps) { )} {children} diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 0f5789256d..76e39846ef 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -44,7 +44,6 @@ test('path builder', () => { "instanceConnect": "/projects/p/instances/i/connect", "instanceMetrics": "/projects/p/instances/i/metrics", "instanceNetworking": "/projects/p/instances/i/networking", - "instanceResize": "/projects/p/instances/i/resize", "instanceStorage": "/projects/p/instances/i/storage", "instances": "/projects/p/instances", "instancesNew": "/projects/p/instances-new", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index b135a17dd6..1709b19c59 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -62,7 +62,6 @@ export const pb = { instanceStorage: (params: Instance) => `${instanceBase(params)}/storage`, instanceConnect: (params: Instance) => `${instanceBase(params)}/connect`, instanceNetworking: (params: Instance) => `${instanceBase(params)}/networking`, - instanceResize: (params: Instance) => `${instanceBase(params)}/resize`, serialConsole: (params: Instance) => `${instanceBase(params)}/serial-console`, disksNew: (params: Project) => `${projectBase(params)}/disks-new`, From 3db4db058419d6ee9e7a506923e9b1eb3c2dc414 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 4 Nov 2024 13:48:31 -0600 Subject: [PATCH 04/14] bump API to get the resize change plus omicron main --- OMICRON_VERSION | 2 +- app/api/__generated__/Api.ts | 423 +++++++++++++++++++++++++- app/api/__generated__/OMICRON_VERSION | 2 +- app/api/__generated__/msw-handlers.ts | 135 ++++++++ app/api/__generated__/validate.ts | 252 ++++++++++++++- mock-api/msw/handlers.ts | 10 + 6 files changed, 810 insertions(+), 14 deletions(-) diff --git a/OMICRON_VERSION b/OMICRON_VERSION index 4fb54663b6..4b7f82c891 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -fd7e5a1387475d1ebec9880e3bbb854f69e5fcf6 +055e19ef71aa42dda9d6415b883c7e015c773353 diff --git a/app/api/__generated__/Api.ts b/app/api/__generated__/Api.ts index 1bd48cdfc4..47563b090c 100644 --- a/app/api/__generated__/Api.ts +++ b/app/api/__generated__/Api.ts @@ -1771,9 +1771,9 @@ If this is not present, then either the instance has never been automatically re autoRestartCooldownExpiration?: Date /** `true` if this instance's auto-restart policy will permit the control plane to automatically restart it if it enters the `Failed` state. */ autoRestartEnabled: boolean - /** The auto-restart policy configured for this instance, or `None` if no explicit policy is configured. + /** The auto-restart policy configured for this instance, or `null` if no explicit policy has been configured. -If this is not present, then this instance uses the default auto-restart policy, which may or may not allow it to be restarted. The `auto_restart_enabled` field indicates whether the instance will be automatically restarted. */ +This policy determines whether the instance should be automatically restarted by the control plane on failure. If this is `null`, the control plane will use the default policy when determining whether or not to automatically restart this instance, which may or may not allow it to be restarted. The value of the `auto_restart_enabled` field indicates whether the instance will be auto-restarted, based on its current policy or the default if it has no configured policy. */ autoRestartPolicy?: InstanceAutoRestartPolicy /** the ID of the disk used to boot this Instance, if a specific one is assigned. */ bootDiskId?: string @@ -1857,7 +1857,9 @@ If more than one interface is provided, then the first will be designated the pr export type InstanceCreate = { /** The auto-restart policy for this instance. -This indicates whether the instance should be automatically restarted by the control plane on failure. If this is `null`, no auto-restart policy has been configured for this instance by the user. */ +This policy determines whether the instance should be automatically restarted by the control plane on failure. If this is `null`, no auto-restart policy will be explicitly configured for this instance, and the control plane will select the default policy when determining whether the instance can be automatically restarted. + +Currently, the global default auto-restart policy is "best-effort", so instances with `null` auto-restart policies will be automatically restarted. However, in the future, the default policy may be configurable through other mechanisms, such as on a per-project basis. In that case, any configured default policy will be used if this is `null`. */ autoRestartPolicy?: InstanceAutoRestartPolicy /** The disk this instance should boot into. This disk can either be attached if it already exists, or created, if it should be a new disk. @@ -1980,9 +1982,11 @@ export type InstanceSerialConsoleData = { * Parameters of an `Instance` that can be reconfigured after creation. */ export type InstanceUpdate = { - /** The auto-restart policy for this instance. + /** Sets the auto-restart policy for this instance. + +This policy determines whether the instance should be automatically restarted by the control plane on failure. If this is `null`, any explicitly configured auto-restart policy will be unset, and the control plane will select the default policy when determining whether the instance can be automatically restarted. -If not provided, unset the instance's auto-restart policy. */ +Currently, the global default auto-restart policy is "best-effort", so instances with `null` auto-restart policies will be automatically restarted. However, in the future, the default policy may be configurable through other mechanisms, such as on a per-project basis. In that case, any configured default policy will be used if this is `null`. */ autoRestartPolicy?: InstanceAutoRestartPolicy /** Name or ID of the disk the instance should be instructed to boot from. @@ -1994,6 +1998,117 @@ If not provided, unset the instance's boot disk. */ ncpus: InstanceCpuCount } +/** + * An internet gateway provides a path between VPC networks and external networks. + */ +export type InternetGateway = { + /** human-readable free-form text about a resource */ + description: string + /** unique, immutable, system-controlled identifier for each resource */ + id: string + /** unique, mutable, user-controlled identifier for each resource */ + name: Name + /** timestamp when this resource was created */ + timeCreated: Date + /** timestamp when this resource was last modified */ + timeModified: Date + /** The VPC to which the gateway belongs. */ + vpcId: string +} + +/** + * Create-time parameters for an `InternetGateway` + */ +export type InternetGatewayCreate = { description: string; name: Name } + +/** + * An IP address that is attached to an internet gateway + */ +export type InternetGatewayIpAddress = { + /** The associated IP address, */ + address: string + /** human-readable free-form text about a resource */ + description: string + /** unique, immutable, system-controlled identifier for each resource */ + id: string + /** The associated internet gateway. */ + internetGatewayId: string + /** unique, mutable, user-controlled identifier for each resource */ + name: Name + /** timestamp when this resource was created */ + timeCreated: Date + /** timestamp when this resource was last modified */ + timeModified: Date +} + +/** + * Create-time identity-related parameters + */ +export type InternetGatewayIpAddressCreate = { + address: string + description: string + name: Name +} + +/** + * A single page of results + */ +export type InternetGatewayIpAddressResultsPage = { + /** list of items on this page of results */ + items: InternetGatewayIpAddress[] + /** token used to fetch the next page of results (if any) */ + nextPage?: string +} + +/** + * An IP pool that is attached to an internet gateway + */ +export type InternetGatewayIpPool = { + /** human-readable free-form text about a resource */ + description: string + /** unique, immutable, system-controlled identifier for each resource */ + id: string + /** The associated internet gateway. */ + internetGatewayId: string + /** The associated IP pool. */ + ipPoolId: string + /** unique, mutable, user-controlled identifier for each resource */ + name: Name + /** timestamp when this resource was created */ + timeCreated: Date + /** timestamp when this resource was last modified */ + timeModified: Date +} + +/** + * Create-time identity-related parameters + */ +export type InternetGatewayIpPoolCreate = { + description: string + ipPool: NameOrId + name: Name +} + +/** + * A single page of results + */ +export type InternetGatewayIpPoolResultsPage = { + /** list of items on this page of results */ + items: InternetGatewayIpPool[] + /** token used to fetch the next page of results (if any) */ + nextPage?: string +} + +/** + * A single page of results + */ +export type InternetGatewayResultsPage = { + /** list of items on this page of results */ + items: InternetGateway[] + /** token used to fetch the next page of results (if any) */ + nextPage?: string +} + /** * A collection of IP ranges. If a pool is linked to a silo, IP addresses from the pool can be allocated within that silo */ @@ -2186,6 +2301,22 @@ export type LinkSpeed = /** 400 gigabits per second. */ | 'speed400_g' +/** + * Per-port tx-eq overrides. This can be used to fine-tune the transceiver equalization settings to improve signal integrity. + */ +export type TxEqConfig = { + /** Main tap */ + main?: number + /** Post-cursor tap1 */ + post1?: number + /** Post-cursor tap2 */ + post2?: number + /** Pre-cursor tap1 */ + pre1?: number + /** Pre-cursor tap2 */ + pre2?: number +} + /** * Switch link configuration. */ @@ -2200,6 +2331,8 @@ export type LinkConfigCreate = { mtu: number /** The speed of the link. */ speed: LinkSpeed + /** Optional tx_eq settings */ + txEq?: TxEqConfig } /** @@ -2622,7 +2755,7 @@ export type Route = { /** The route gateway. */ gw: string /** Local preference for route. Higher preference indictes precedence within and across protocols. */ - localPref?: number + ribPriority?: number /** VLAN id the gateway is reachable over. */ vid?: number } @@ -2699,7 +2832,7 @@ export type RouterRouteKind = export type RouterRoute = { /** human-readable free-form text about a resource */ description: string - /** Selects which traffic this routing rule will apply to. */ + /** Selects which traffic this routing rule will apply to */ destination: RouteDestination /** unique, immutable, system-controlled identifier for each resource */ id: string @@ -2707,7 +2840,7 @@ export type RouterRoute = { kind: RouterRouteKind /** unique, mutable, user-controlled identifier for each resource */ name: Name - /** The location that matched packets should be forwarded to. */ + /** The location that matched packets should be forwarded to */ target: RouteTarget /** timestamp when this resource was created */ timeCreated: Date @@ -3387,6 +3520,8 @@ export type SwitchPortLinkConfig = { portSettingsId: string /** The configured speed of the link. */ speed: LinkSpeed + /** The tx_eq configuration id for this link. */ + txEqConfigId?: string } /** @@ -3409,10 +3544,10 @@ export type SwitchPortRouteConfig = { gw: IpNet /** The interface name this route configuration is assigned to. */ interfaceName: string - /** Local preference indicating priority within and across protocols. */ - localPref?: number /** The port settings object this route configuration belongs to. */ portSettingsId: string + /** RIB Priority indicating priority within and across protocols. */ + ribPriority?: number /** The VLAN identifier for the route. Use this if the gateway is reachable over an 802.1Q tagged L2 segment. */ vlanId?: number } @@ -3505,6 +3640,8 @@ export type SwitchPortSettingsView = { routes: SwitchPortRouteConfig[] /** The primary switch port settings handle. */ settings: SwitchPortSettings + /** TX equalization settings. These are optional, and most links will not need them. */ + txEq: TxEqConfig[] /** Vlan interface settings. */ vlanInterfaces: SwitchVlanInterfaceConfig[] } @@ -4375,6 +4512,90 @@ export interface InstanceStopQueryParams { project?: NameOrId } +export interface InternetGatewayIpAddressListQueryParams { + gateway?: NameOrId + limit?: number + pageToken?: string + project?: NameOrId + sortBy?: NameOrIdSortMode + vpc?: NameOrId +} + +export interface InternetGatewayIpAddressCreateQueryParams { + gateway: NameOrId + project?: NameOrId + vpc?: NameOrId +} + +export interface InternetGatewayIpAddressDeletePathParams { + address: NameOrId +} + +export interface InternetGatewayIpAddressDeleteQueryParams { + cascade?: boolean + gateway?: NameOrId + project?: NameOrId + vpc?: NameOrId +} + +export interface InternetGatewayIpPoolListQueryParams { + gateway?: NameOrId + limit?: number + pageToken?: string + project?: NameOrId + sortBy?: NameOrIdSortMode + vpc?: NameOrId +} + +export interface InternetGatewayIpPoolCreateQueryParams { + gateway: NameOrId + project?: NameOrId + vpc?: NameOrId +} + +export interface InternetGatewayIpPoolDeletePathParams { + pool: NameOrId +} + +export interface InternetGatewayIpPoolDeleteQueryParams { + cascade?: boolean + gateway?: NameOrId + project?: NameOrId + vpc?: NameOrId +} + +export interface InternetGatewayListQueryParams { + limit?: number + pageToken?: string + project?: NameOrId + sortBy?: NameOrIdSortMode + vpc?: NameOrId +} + +export interface InternetGatewayCreateQueryParams { + project?: NameOrId + vpc: NameOrId +} + +export interface InternetGatewayViewPathParams { + gateway: NameOrId +} + +export interface InternetGatewayViewQueryParams { + project?: NameOrId + vpc?: NameOrId +} + +export interface InternetGatewayDeletePathParams { + gateway: NameOrId +} + +export interface InternetGatewayDeleteQueryParams { + cascade?: boolean + project?: NameOrId + vpc?: NameOrId +} + export interface ProjectIpPoolListQueryParams { limit?: number pageToken?: string @@ -5115,6 +5336,9 @@ export type ApiListMethods = Pick< | 'instanceDiskList' | 'instanceExternalIpList' | 'instanceSshPublicKeyList' + | 'internetGatewayIpAddressList' + | 'internetGatewayIpPoolList' + | 'internetGatewayList' | 'projectIpPoolList' | 'currentUserSshKeyList' | 'instanceNetworkInterfaceList' @@ -6010,6 +6234,185 @@ export class Api extends HttpClient { ...params, }) }, + /** + * List IP addresses attached to internet gateway + */ + internetGatewayIpAddressList: ( + { query = {} }: { query?: InternetGatewayIpAddressListQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/internet-gateway-ip-addresses`, + method: 'GET', + query, + ...params, + }) + }, + /** + * Attach IP address to internet gateway + */ + internetGatewayIpAddressCreate: ( + { + query, + body, + }: { + query: InternetGatewayIpAddressCreateQueryParams + body: InternetGatewayIpAddressCreate + }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/internet-gateway-ip-addresses`, + method: 'POST', + body, + query, + ...params, + }) + }, + /** + * Detach IP address from internet gateway + */ + internetGatewayIpAddressDelete: ( + { + path, + query = {}, + }: { + path: InternetGatewayIpAddressDeletePathParams + query?: InternetGatewayIpAddressDeleteQueryParams + }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/internet-gateway-ip-addresses/${path.address}`, + method: 'DELETE', + query, + ...params, + }) + }, + /** + * List IP pools attached to internet gateway + */ + internetGatewayIpPoolList: ( + { query = {} }: { query?: InternetGatewayIpPoolListQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/internet-gateway-ip-pools`, + method: 'GET', + query, + ...params, + }) + }, + /** + * Attach IP pool to internet gateway + */ + internetGatewayIpPoolCreate: ( + { + query, + body, + }: { + query: InternetGatewayIpPoolCreateQueryParams + body: InternetGatewayIpPoolCreate + }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/internet-gateway-ip-pools`, + method: 'POST', + body, + query, + ...params, + }) + }, + /** + * Detach IP pool from internet gateway + */ + internetGatewayIpPoolDelete: ( + { + path, + query = {}, + }: { + path: InternetGatewayIpPoolDeletePathParams + query?: InternetGatewayIpPoolDeleteQueryParams + }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/internet-gateway-ip-pools/${path.pool}`, + method: 'DELETE', + query, + ...params, + }) + }, + /** + * List internet gateways + */ + internetGatewayList: ( + { query = {} }: { query?: InternetGatewayListQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/internet-gateways`, + method: 'GET', + query, + ...params, + }) + }, + /** + * Create VPC internet gateway + */ + internetGatewayCreate: ( + { + query, + body, + }: { query: InternetGatewayCreateQueryParams; body: InternetGatewayCreate }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/internet-gateways`, + method: 'POST', + body, + query, + ...params, + }) + }, + /** + * Fetch internet gateway + */ + internetGatewayView: ( + { + path, + query = {}, + }: { path: InternetGatewayViewPathParams; query?: InternetGatewayViewQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/internet-gateways/${path.gateway}`, + method: 'GET', + query, + ...params, + }) + }, + /** + * Delete internet gateway + */ + internetGatewayDelete: ( + { + path, + query = {}, + }: { + path: InternetGatewayDeletePathParams + query?: InternetGatewayDeleteQueryParams + }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/internet-gateways/${path.gateway}`, + method: 'DELETE', + query, + ...params, + }) + }, /** * List IP pools */ diff --git a/app/api/__generated__/OMICRON_VERSION b/app/api/__generated__/OMICRON_VERSION index 73f543dd91..ed6d131367 100644 --- a/app/api/__generated__/OMICRON_VERSION +++ b/app/api/__generated__/OMICRON_VERSION @@ -1,2 +1,2 @@ # generated file. do not update manually. see docs/update-pinned-api.md -fd7e5a1387475d1ebec9880e3bbb854f69e5fcf6 +055e19ef71aa42dda9d6415b883c7e015c773353 diff --git a/app/api/__generated__/msw-handlers.ts b/app/api/__generated__/msw-handlers.ts index 49df51a418..0e455163ac 100644 --- a/app/api/__generated__/msw-handlers.ts +++ b/app/api/__generated__/msw-handlers.ts @@ -407,6 +407,73 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable> + /** `GET /v1/internet-gateway-ip-addresses` */ + internetGatewayIpAddressList: (params: { + query: Api.InternetGatewayIpAddressListQueryParams + req: Request + cookies: Record + }) => Promisable> + /** `POST /v1/internet-gateway-ip-addresses` */ + internetGatewayIpAddressCreate: (params: { + query: Api.InternetGatewayIpAddressCreateQueryParams + body: Json + req: Request + cookies: Record + }) => Promisable> + /** `DELETE /v1/internet-gateway-ip-addresses/:address` */ + internetGatewayIpAddressDelete: (params: { + path: Api.InternetGatewayIpAddressDeletePathParams + query: Api.InternetGatewayIpAddressDeleteQueryParams + req: Request + cookies: Record + }) => Promisable + /** `GET /v1/internet-gateway-ip-pools` */ + internetGatewayIpPoolList: (params: { + query: Api.InternetGatewayIpPoolListQueryParams + req: Request + cookies: Record + }) => Promisable> + /** `POST /v1/internet-gateway-ip-pools` */ + internetGatewayIpPoolCreate: (params: { + query: Api.InternetGatewayIpPoolCreateQueryParams + body: Json + req: Request + cookies: Record + }) => Promisable> + /** `DELETE /v1/internet-gateway-ip-pools/:pool` */ + internetGatewayIpPoolDelete: (params: { + path: Api.InternetGatewayIpPoolDeletePathParams + query: Api.InternetGatewayIpPoolDeleteQueryParams + req: Request + cookies: Record + }) => Promisable + /** `GET /v1/internet-gateways` */ + internetGatewayList: (params: { + query: Api.InternetGatewayListQueryParams + req: Request + cookies: Record + }) => Promisable> + /** `POST /v1/internet-gateways` */ + internetGatewayCreate: (params: { + query: Api.InternetGatewayCreateQueryParams + body: Json + req: Request + cookies: Record + }) => Promisable> + /** `GET /v1/internet-gateways/:gateway` */ + internetGatewayView: (params: { + path: Api.InternetGatewayViewPathParams + query: Api.InternetGatewayViewQueryParams + req: Request + cookies: Record + }) => Promisable> + /** `DELETE /v1/internet-gateways/:gateway` */ + internetGatewayDelete: (params: { + path: Api.InternetGatewayDeletePathParams + query: Api.InternetGatewayDeleteQueryParams + req: Request + cookies: Record + }) => Promisable /** `GET /v1/ip-pools` */ projectIpPoolList: (params: { query: Api.ProjectIpPoolListQueryParams @@ -1685,6 +1752,74 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { '/v1/instances/:instance/stop', handler(handlers['instanceStop'], schema.InstanceStopParams, null) ), + http.get( + '/v1/internet-gateway-ip-addresses', + handler( + handlers['internetGatewayIpAddressList'], + schema.InternetGatewayIpAddressListParams, + null + ) + ), + http.post( + '/v1/internet-gateway-ip-addresses', + handler( + handlers['internetGatewayIpAddressCreate'], + schema.InternetGatewayIpAddressCreateParams, + schema.InternetGatewayIpAddressCreate + ) + ), + http.delete( + '/v1/internet-gateway-ip-addresses/:address', + handler( + handlers['internetGatewayIpAddressDelete'], + schema.InternetGatewayIpAddressDeleteParams, + null + ) + ), + http.get( + '/v1/internet-gateway-ip-pools', + handler( + handlers['internetGatewayIpPoolList'], + schema.InternetGatewayIpPoolListParams, + null + ) + ), + http.post( + '/v1/internet-gateway-ip-pools', + handler( + handlers['internetGatewayIpPoolCreate'], + schema.InternetGatewayIpPoolCreateParams, + schema.InternetGatewayIpPoolCreate + ) + ), + http.delete( + '/v1/internet-gateway-ip-pools/:pool', + handler( + handlers['internetGatewayIpPoolDelete'], + schema.InternetGatewayIpPoolDeleteParams, + null + ) + ), + http.get( + '/v1/internet-gateways', + handler(handlers['internetGatewayList'], schema.InternetGatewayListParams, null) + ), + http.post( + '/v1/internet-gateways', + handler( + handlers['internetGatewayCreate'], + schema.InternetGatewayCreateParams, + schema.InternetGatewayCreate + ) + ), + http.get( + '/v1/internet-gateways/:gateway', + handler(handlers['internetGatewayView'], schema.InternetGatewayViewParams, null) + ), + http.delete( + '/v1/internet-gateways/:gateway', + handler(handlers['internetGatewayDelete'], schema.InternetGatewayDeleteParams, null) + ), http.get( '/v1/ip-pools', handler(handlers['projectIpPoolList'], schema.ProjectIpPoolListParams, null) diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index dd36c43722..5ab3739e8c 100644 --- a/app/api/__generated__/validate.ts +++ b/app/api/__generated__/validate.ts @@ -1861,6 +1861,101 @@ export const InstanceUpdate = z.preprocess( }) ) +/** + * An internet gateway provides a path between VPC networks and external networks. + */ +export const InternetGateway = z.preprocess( + processResponseBody, + z.object({ + description: z.string(), + id: z.string().uuid(), + name: Name, + timeCreated: z.coerce.date(), + timeModified: z.coerce.date(), + vpcId: z.string().uuid(), + }) +) + +/** + * Create-time parameters for an `InternetGateway` + */ +export const InternetGatewayCreate = z.preprocess( + processResponseBody, + z.object({ description: z.string(), name: Name }) +) + +/** + * An IP address that is attached to an internet gateway + */ +export const InternetGatewayIpAddress = z.preprocess( + processResponseBody, + z.object({ + address: z.string().ip(), + description: z.string(), + id: z.string().uuid(), + internetGatewayId: z.string().uuid(), + name: Name, + timeCreated: z.coerce.date(), + timeModified: z.coerce.date(), + }) +) + +/** + * Create-time identity-related parameters + */ +export const InternetGatewayIpAddressCreate = z.preprocess( + processResponseBody, + z.object({ address: z.string().ip(), description: z.string(), name: Name }) +) + +/** + * A single page of results + */ +export const InternetGatewayIpAddressResultsPage = z.preprocess( + processResponseBody, + z.object({ items: InternetGatewayIpAddress.array(), nextPage: z.string().optional() }) +) + +/** + * An IP pool that is attached to an internet gateway + */ +export const InternetGatewayIpPool = z.preprocess( + processResponseBody, + z.object({ + description: z.string(), + id: z.string().uuid(), + internetGatewayId: z.string().uuid(), + ipPoolId: z.string().uuid(), + name: Name, + timeCreated: z.coerce.date(), + timeModified: z.coerce.date(), + }) +) + +/** + * Create-time identity-related parameters + */ +export const InternetGatewayIpPoolCreate = z.preprocess( + processResponseBody, + z.object({ description: z.string(), ipPool: NameOrId, name: Name }) +) + +/** + * A single page of results + */ +export const InternetGatewayIpPoolResultsPage = z.preprocess( + processResponseBody, + z.object({ items: InternetGatewayIpPool.array(), nextPage: z.string().optional() }) +) + +/** + * A single page of results + */ +export const InternetGatewayResultsPage = z.preprocess( + processResponseBody, + z.object({ items: InternetGateway.array(), nextPage: z.string().optional() }) +) + /** * A collection of IP ranges. If a pool is linked to a silo, IP addresses from the pool can be allocated within that silo */ @@ -2046,6 +2141,20 @@ export const LinkSpeed = z.preprocess( ]) ) +/** + * Per-port tx-eq overrides. This can be used to fine-tune the transceiver equalization settings to improve signal integrity. + */ +export const TxEqConfig = z.preprocess( + processResponseBody, + z.object({ + main: z.number().min(-2147483647).max(2147483647).optional(), + post1: z.number().min(-2147483647).max(2147483647).optional(), + post2: z.number().min(-2147483647).max(2147483647).optional(), + pre1: z.number().min(-2147483647).max(2147483647).optional(), + pre2: z.number().min(-2147483647).max(2147483647).optional(), + }) +) + /** * Switch link configuration. */ @@ -2057,6 +2166,7 @@ export const LinkConfigCreate = z.preprocess( lldp: LldpLinkConfigCreate, mtu: z.number().min(0).max(65535), speed: LinkSpeed, + txEq: TxEqConfig.optional(), }) ) @@ -2489,7 +2599,7 @@ export const Route = z.preprocess( z.object({ dst: IpNet, gw: z.string().ip(), - localPref: z.number().min(0).max(4294967295).optional(), + ribPriority: z.number().min(0).max(255).optional(), vid: z.number().min(0).max(65535).optional(), }) ) @@ -3124,6 +3234,7 @@ export const SwitchPortLinkConfig = z.preprocess( mtu: z.number().min(0).max(65535), portSettingsId: z.string().uuid(), speed: LinkSpeed, + txEqConfigId: z.string().uuid().optional(), }) ) @@ -3144,8 +3255,8 @@ export const SwitchPortRouteConfig = z.preprocess( dst: IpNet, gw: IpNet, interfaceName: z.string(), - localPref: z.number().min(0).max(4294967295).optional(), portSettingsId: z.string().uuid(), + ribPriority: z.number().min(0).max(255).optional(), vlanId: z.number().min(0).max(65535).optional(), }) ) @@ -3221,6 +3332,7 @@ export const SwitchPortSettingsView = z.preprocess( port: SwitchPortConfig, routes: SwitchPortRouteConfig.array(), settings: SwitchPortSettings, + txEq: TxEqConfig.array(), vlanInterfaces: SwitchVlanInterfaceConfig.array(), }) ) @@ -4354,6 +4466,142 @@ export const InstanceStopParams = z.preprocess( }) ) +export const InternetGatewayIpAddressListParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({ + gateway: NameOrId.optional(), + limit: z.number().min(1).max(4294967295).optional(), + pageToken: z.string().optional(), + project: NameOrId.optional(), + sortBy: NameOrIdSortMode.optional(), + vpc: NameOrId.optional(), + }), + }) +) + +export const InternetGatewayIpAddressCreateParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({ + gateway: NameOrId, + project: NameOrId.optional(), + vpc: NameOrId.optional(), + }), + }) +) + +export const InternetGatewayIpAddressDeleteParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + address: NameOrId, + }), + query: z.object({ + cascade: SafeBoolean.optional(), + gateway: NameOrId.optional(), + project: NameOrId.optional(), + vpc: NameOrId.optional(), + }), + }) +) + +export const InternetGatewayIpPoolListParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({ + gateway: NameOrId.optional(), + limit: z.number().min(1).max(4294967295).optional(), + pageToken: z.string().optional(), + project: NameOrId.optional(), + sortBy: NameOrIdSortMode.optional(), + vpc: NameOrId.optional(), + }), + }) +) + +export const InternetGatewayIpPoolCreateParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({ + gateway: NameOrId, + project: NameOrId.optional(), + vpc: NameOrId.optional(), + }), + }) +) + +export const InternetGatewayIpPoolDeleteParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + pool: NameOrId, + }), + query: z.object({ + cascade: SafeBoolean.optional(), + gateway: NameOrId.optional(), + project: NameOrId.optional(), + vpc: NameOrId.optional(), + }), + }) +) + +export const InternetGatewayListParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({ + limit: z.number().min(1).max(4294967295).optional(), + pageToken: z.string().optional(), + project: NameOrId.optional(), + sortBy: NameOrIdSortMode.optional(), + vpc: NameOrId.optional(), + }), + }) +) + +export const InternetGatewayCreateParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({ + project: NameOrId.optional(), + vpc: NameOrId, + }), + }) +) + +export const InternetGatewayViewParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + gateway: NameOrId, + }), + query: z.object({ + project: NameOrId.optional(), + vpc: NameOrId.optional(), + }), + }) +) + +export const InternetGatewayDeleteParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + gateway: NameOrId, + }), + query: z.object({ + cascade: SafeBoolean.optional(), + project: NameOrId.optional(), + vpc: NameOrId.optional(), + }), + }) +) + export const ProjectIpPoolListParams = z.preprocess( processResponseBody, z.object({ diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index d2b80c2faf..0bcbf0433b 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1527,6 +1527,16 @@ export const handlers = makeHandlers({ ipPoolServiceRangeList: NotImplemented, ipPoolServiceRangeRemove: NotImplemented, ipPoolServiceView: NotImplemented, + internetGatewayIpAddressList: NotImplemented, + internetGatewayIpAddressCreate: NotImplemented, + internetGatewayIpAddressDelete: NotImplemented, + internetGatewayIpPoolList: NotImplemented, + internetGatewayIpPoolDelete: NotImplemented, + internetGatewayList: NotImplemented, + internetGatewayCreate: NotImplemented, + internetGatewayIpPoolCreate: NotImplemented, + internetGatewayView: NotImplemented, + internetGatewayDelete: NotImplemented, localIdpUserCreate: NotImplemented, localIdpUserDelete: NotImplemented, localIdpUserSetPassword: NotImplemented, From 8b5d6a4a3e3d5608ae0f2202a3d311e442918a6a Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 4 Nov 2024 14:02:33 -0600 Subject: [PATCH 05/14] merge main --- .github/workflows/lintBuildTest.yml | 6 +- .github/workflows/reformatter.yaml | 2 +- .github/workflows/upload-assets.yaml | 2 +- app/api/util.spec.ts | 21 +++- app/api/util.ts | 12 +- app/components/AttachEphemeralIpModal.tsx | 20 +--- app/components/AttachFloatingIpModal.tsx | 5 +- app/components/DocsPopover.tsx | 6 +- app/components/ErrorBoundary.tsx | 1 + app/components/HL.tsx | 9 +- app/components/ToastStack.tsx | 5 +- app/components/TopBar.tsx | 2 +- app/components/TopBarPicker.tsx | 25 +++- app/components/form/fields/ComboboxField.tsx | 17 ++- .../form/fields/DisksTableField.tsx | 5 +- .../form/fields/ImageSelectField.tsx | 52 ++++----- app/components/form/fields/NameField.tsx | 11 ++ app/components/form/fields/TextField.tsx | 2 +- app/components/form/fields/ip-pool-item.tsx | 30 +++++ app/forms/disk-attach.tsx | 17 ++- app/forms/disk-create.tsx | 7 +- app/forms/firewall-rules-common.tsx | 15 ++- app/forms/firewall-rules-create.tsx | 6 +- app/forms/firewall-rules-edit.tsx | 6 +- app/forms/floating-ip-create.tsx | 29 +---- app/forms/floating-ip-edit.tsx | 3 +- app/forms/idp/create.tsx | 7 +- app/forms/idp/edit.tsx | 16 +-- app/forms/image-from-snapshot.tsx | 5 +- app/forms/image-upload.tsx | 8 +- app/forms/instance-create.tsx | 25 ++-- app/forms/ip-pool-create.tsx | 12 +- app/forms/ip-pool-edit.tsx | 6 +- app/forms/network-interface-create.tsx | 14 +-- app/forms/network-interface-edit.tsx | 5 +- app/forms/project-access.tsx | 4 + app/forms/project-create.tsx | 3 +- app/forms/project-edit.tsx | 3 +- app/forms/silo-create.tsx | 3 +- app/forms/snapshot-create.tsx | 16 +-- app/forms/ssh-key-create.tsx | 5 +- app/forms/subnet-create.tsx | 5 +- app/forms/subnet-edit.tsx | 5 +- app/forms/vpc-create.tsx | 3 +- app/forms/vpc-edit.tsx | 3 +- app/forms/vpc-router-create.tsx | 5 +- app/forms/vpc-router-edit.tsx | 5 +- app/forms/vpc-router-route-common.tsx | 6 +- app/forms/vpc-router-route-create.tsx | 5 +- app/forms/vpc-router-route-edit.tsx | 5 +- app/layouts/SystemLayout.tsx | 10 +- app/pages/LoginPage.tsx | 2 +- .../project/access/ProjectAccessPage.tsx | 6 +- app/pages/project/disks/DisksPage.tsx | 10 +- .../project/floating-ips/FloatingIpsPage.tsx | 37 +++--- app/pages/project/images/ImagesPage.tsx | 9 +- app/pages/project/instances/InstancesPage.tsx | 9 +- app/pages/project/instances/actions.tsx | 64 ++++++---- .../instances/instance/InstancePage.tsx | 30 +++-- .../instances/instance/tabs/NetworkingTab.tsx | 10 +- .../instances/instance/tabs/StorageTab.tsx | 28 +++-- app/pages/project/vpcs/RouterPage.tsx | 3 +- app/pages/project/vpcs/VpcPage/VpcPage.tsx | 5 +- .../vpcs/VpcPage/tabs/VpcRoutersTab.tsx | 5 +- .../vpcs/VpcPage/tabs/VpcSubnetsTab.tsx | 3 + app/pages/project/vpcs/VpcsPage.tsx | 5 +- app/pages/settings/SSHKeysPage.tsx | 5 +- app/pages/system/SiloImagesPage.tsx | 26 ++--- app/pages/system/networking/IpPoolPage.tsx | 9 +- app/pages/system/networking/IpPoolsPage.tsx | 5 +- app/pages/system/silos/SiloIpPoolsTab.tsx | 7 +- app/pages/system/silos/SiloQuotasTab.tsx | 2 + app/pages/system/silos/SilosPage.tsx | 5 +- app/stores/toast.ts | 12 +- app/table/cells/IpPoolCell.tsx | 21 ++++ app/table/cells/LinkCell.tsx | 3 +- app/table/columns/action-col.tsx | 2 +- app/ui/assets/fonts/SuisseIntl-Book-WebS.woff | Bin 21937 -> 0 bytes .../assets/fonts/SuisseIntl-Book-WebS.woff2 | Bin 17996 -> 0 bytes .../assets/fonts/SuisseIntl-Medium-WebS.woff | Bin 0 -> 21851 bytes .../assets/fonts/SuisseIntl-Medium-WebS.woff2 | Bin 0 -> 17788 bytes app/ui/lib/Button.tsx | 4 +- app/ui/lib/Combobox.tsx | 110 ++++++++++++------ app/ui/lib/CopyableIp.tsx | 2 +- app/ui/lib/Listbox.tsx | 17 ++- app/ui/lib/Toast.tsx | 17 ++- app/ui/lib/Tooltip.tsx | 6 +- app/ui/styles/components/menu-button.css | 3 +- app/ui/styles/components/menu-list.css | 3 + app/ui/styles/components/mini-table.css | 2 +- app/ui/styles/fonts.css | 6 +- app/util/{str.spec.ts => str.spec.tsx} | 36 +++++- app/util/str.ts | 18 +++ mock-api/msw/handlers.ts | 18 +-- mock-api/silo.ts | 1 + package-lock.json | 15 ++- tailwind.config.js | 2 +- test/e2e/disks.e2e.ts | 31 +++-- test/e2e/floating-ip-create.e2e.ts | 10 +- test/e2e/floating-ip-update.e2e.ts | 11 +- test/e2e/images.e2e.ts | 48 ++++---- test/e2e/instance-create.e2e.ts | 50 ++++---- test/e2e/instance-disks.e2e.ts | 14 ++- test/e2e/instance-networking.e2e.ts | 2 +- test/e2e/instance-serial.e2e.ts | 2 +- test/e2e/instance.e2e.ts | 2 + test/e2e/ip-pools.e2e.ts | 10 +- test/e2e/project-create.e2e.ts | 8 +- test/e2e/silos.e2e.ts | 4 + test/e2e/utils.ts | 30 +++-- 110 files changed, 840 insertions(+), 465 deletions(-) create mode 100644 app/components/form/fields/ip-pool-item.tsx create mode 100644 app/table/cells/IpPoolCell.tsx delete mode 100644 app/ui/assets/fonts/SuisseIntl-Book-WebS.woff delete mode 100644 app/ui/assets/fonts/SuisseIntl-Book-WebS.woff2 create mode 100644 app/ui/assets/fonts/SuisseIntl-Medium-WebS.woff create mode 100644 app/ui/assets/fonts/SuisseIntl-Medium-WebS.woff2 rename app/util/{str.spec.ts => str.spec.tsx} (78%) diff --git a/.github/workflows/lintBuildTest.yml b/.github/workflows/lintBuildTest.yml index 55af5bc78c..9d3c4fbcd8 100644 --- a/.github/workflows/lintBuildTest.yml +++ b/.github/workflows/lintBuildTest.yml @@ -13,7 +13,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'npm' - name: Cache node_modules uses: actions/cache@v4 @@ -32,7 +32,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'npm' - name: Get node_modules from cache uses: actions/cache@v4 @@ -61,7 +61,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'npm' - name: Get node_modules from cache uses: actions/cache@v4 diff --git a/.github/workflows/reformatter.yaml b/.github/workflows/reformatter.yaml index c163c4d522..15004f92c7 100644 --- a/.github/workflows/reformatter.yaml +++ b/.github/workflows/reformatter.yaml @@ -14,7 +14,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'npm' - name: Install dependencies diff --git a/.github/workflows/upload-assets.yaml b/.github/workflows/upload-assets.yaml index 498ded32f4..1c275dcd09 100644 --- a/.github/workflows/upload-assets.yaml +++ b/.github/workflows/upload-assets.yaml @@ -12,7 +12,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'npm' - name: 'Authenticate to Google Cloud' uses: 'google-github-actions/auth@v2' diff --git a/app/api/util.spec.ts b/app/api/util.spec.ts index 1c3f97fb23..51fdf260ab 100644 --- a/app/api/util.spec.ts +++ b/app/api/util.spec.ts @@ -7,7 +7,7 @@ */ import { describe, expect, it, test } from 'vitest' -import { genName, parsePortRange, synthesizeData } from './util' +import { diskCan, genName, instanceCan, parsePortRange, synthesizeData } from './util' describe('parsePortRange', () => { describe('parses', () => { @@ -136,3 +136,22 @@ describe('synthesizeData', () => { ]) }) }) + +test('instanceCan', () => { + expect(instanceCan.start({ runState: 'running' })).toBe(false) + expect(instanceCan.start({ runState: 'stopped' })).toBe(true) + + // @ts-expect-error typechecker rejects actions that don't exist + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + instanceCan.abc +}) + +test('diskCan', () => { + expect(diskCan.delete({ state: { state: 'creating' } })).toBe(false) + expect(diskCan.delete({ state: { state: 'attached', instance: 'xyz' } })).toBe(false) + expect(diskCan.delete({ state: { state: 'detached' } })).toBe(true) + + // @ts-expect-error typechecker rejects actions that don't exist + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + diskCan.abc +}) diff --git a/app/api/util.ts b/app/api/util.ts index d2a42750dd..954cd77b0e 100644 --- a/app/api/util.ts +++ b/app/api/util.ts @@ -89,7 +89,7 @@ export const genName = (...parts: [string, ...string[]]) => { ) } -const instanceActions: Record = { +const instanceActions = { // NoVmm maps to to Stopped: // https://github.com/oxidecomputer/omicron/blob/6dd9802/nexus/db-model/src/instance_state.rs#L55 @@ -120,12 +120,12 @@ const instanceActions: Record = { updateNic: ['stopped'], // https://github.com/oxidecomputer/omicron/blob/6dd9802/nexus/src/app/instance.rs#L1520-L1522 serialConsole: ['running', 'rebooting', 'migrating', 'repairing'], -} +} satisfies Record // setting .states is a cute way to make it ergonomic to call the test function // while also making the states available directly -export const instanceCan = R.mapValues(instanceActions, (states) => { +export const instanceCan = R.mapValues(instanceActions, (states: InstanceState[]) => { const test = (i: { runState: InstanceState }) => states.includes(i.runState) test.states = states return test @@ -140,7 +140,7 @@ export function instanceTransitioning({ runState }: Instance) { ) } -const diskActions: Record = { +const diskActions = { // this is a weird one because the list of states is dynamic and it includes // 'creating' in the unwind of the disk create saga, but does not include // 'creating' in the disk delete saga, which is what we care about @@ -154,9 +154,9 @@ const diskActions: Record = { detach: ['attached'], // https://github.com/oxidecomputer/omicron/blob/3093818/nexus/db-queries/src/db/datastore/instance.rs#L1077-L1081 setAsBootDisk: ['attached'], -} +} satisfies Record -export const diskCan = R.mapValues(diskActions, (states) => { +export const diskCan = R.mapValues(diskActions, (states: DiskState['state'][]) => { // only have to Pick because we want this to work for both Disk and // Json, which we pass to it in the MSW handlers const test = (d: Pick) => states.includes(d.state.state) diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index 878021a11f..25177c0dde 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -11,12 +11,14 @@ import { useForm } from 'react-hook-form' import { useApiMutation, useApiQueryClient, usePrefetchedApiQuery } from '~/api' import { ListboxField } from '~/components/form/fields/ListboxField' +import { HL } from '~/components/HL' import { useInstanceSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' -import { Badge } from '~/ui/lib/Badge' import { Modal } from '~/ui/lib/Modal' import { ALL_ISH } from '~/util/consts' +import { toIpPoolItem } from './form/fields/ip-pool-item' + export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) => { const queryClient = useApiQueryClient() const { project, instance } = useInstanceSelector() @@ -28,9 +30,9 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) [siloPools] ) const instanceEphemeralIpAttach = useApiMutation('instanceEphemeralIpAttach', { - onSuccess() { + onSuccess(ephemeralIp) { queryClient.invalidateQueries('instanceExternalIpList') - addToast({ content: 'Your ephemeral IP has been attached' }) + addToast(<>IP {ephemeralIp.ip} attached) // prettier-ignore onDismiss() }, onError: (err) => { @@ -54,17 +56,7 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) ? 'Select a pool' : 'No pools available' } - items={ - siloPools?.items.map((pool) => ({ - label: ( -
- {pool.name} - {pool.isDefault && default} -
- ), - value: pool.name, - })) || [] - } + items={siloPools.items.map(toIpPoolItem)} required /> diff --git a/app/components/AttachFloatingIpModal.tsx b/app/components/AttachFloatingIpModal.tsx index eaedd2dbdc..cc351bde18 100644 --- a/app/components/AttachFloatingIpModal.tsx +++ b/app/components/AttachFloatingIpModal.tsx @@ -10,6 +10,7 @@ import { useForm } from 'react-hook-form' import { useApiMutation, useApiQueryClient, type FloatingIp, type Instance } from '~/api' import { ListboxField } from '~/components/form/fields/ListboxField' +import { HL } from '~/components/HL' import { addToast } from '~/stores/toast' import { Message } from '~/ui/lib/Message' import { Modal } from '~/ui/lib/Modal' @@ -45,10 +46,10 @@ export const AttachFloatingIpModal = ({ }) => { const queryClient = useApiQueryClient() const floatingIpAttach = useApiMutation('floatingIpAttach', { - onSuccess() { + onSuccess(floatingIp) { queryClient.invalidateQueries('floatingIpList') queryClient.invalidateQueries('instanceExternalIpList') - addToast({ content: 'Your floating IP has been attached' }) + addToast(<>IP {floatingIp.name} attached) // prettier-ignore onDismiss() }, onError: (err) => { diff --git a/app/components/DocsPopover.tsx b/app/components/DocsPopover.tsx index 154ce2be0c..76ddbcc791 100644 --- a/app/components/DocsPopover.tsx +++ b/app/components/DocsPopover.tsx @@ -9,7 +9,7 @@ import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react' import cn from 'classnames' -import { OpenLink12Icon, Question12Icon } from '@oxide/design-system/icons/react' +import { Info16Icon, OpenLink12Icon } from '@oxide/design-system/icons/react' import { buttonStyle } from '~/ui/lib/Button' @@ -44,8 +44,8 @@ type DocsPopoverProps = { export const DocsPopover = ({ heading, icon, summary, links }: DocsPopoverProps) => { return ( - - + + ( export function RouterDataErrorBoundary() { // TODO: validate this unknown at runtime _before_ passing to ErrorFallback const error = useRouteError() as Props['error'] + console.error(error) return } diff --git a/app/components/HL.tsx b/app/components/HL.tsx index 234dab7d30..9a5ca1e1b1 100644 --- a/app/components/HL.tsx +++ b/app/components/HL.tsx @@ -7,4 +7,11 @@ */ import { classed } from '~/util/classed' -export const HL = classed.span`text-sans-semi-md text-default` +// note parent with secondary text color must have 'group' on it for +// this to work. see Toast for an example +export const HL = classed.span` + text-sans-md text-default + group-[.text-accent-secondary]:text-accent + group-[.text-error-secondary]:text-error + group-[.text-info-secondary]:text-info +` diff --git a/app/components/ToastStack.tsx b/app/components/ToastStack.tsx index 56d5c9a4b5..78fe655e69 100644 --- a/app/components/ToastStack.tsx +++ b/app/components/ToastStack.tsx @@ -22,7 +22,10 @@ export function ToastStack() { }) return ( -
+
{transition((style, item) => ( - + Settings logout.mutate({})}> Sign out diff --git a/app/components/TopBarPicker.tsx b/app/components/TopBarPicker.tsx index 9e3c51599e..5ae3432948 100644 --- a/app/components/TopBarPicker.tsx +++ b/app/components/TopBarPicker.tsx @@ -19,6 +19,7 @@ import { useInstanceSelector, useIpPoolSelector, useSiloSelector, + useSledParams, useVpcRouterSelector, useVpcSelector, } from '~/hooks/use-params' @@ -111,7 +112,7 @@ const TopBarPicker = (props: TopBarPickerProps) => { {/* TODO: popover position should be further right */} {props.items && ( {props.items.length > 0 ? ( @@ -343,3 +344,25 @@ export function InstancePicker() { /> ) } + +export function SledPicker() { + // picker only shows up when a sled is in scope + const { sledId } = useSledParams() + const { data: sleds } = useApiQuery('sledList', { + query: { limit: PAGE_SIZE }, + }) + const items = (sleds?.items || []).map(({ id }) => ({ + label: id, + to: pb.sled({ sledId: id }), + })) + return ( + + ) +} diff --git a/app/components/form/fields/ComboboxField.tsx b/app/components/form/fields/ComboboxField.tsx index ce79af7e4d..a49561b7dd 100644 --- a/app/components/form/fields/ComboboxField.tsx +++ b/app/components/form/fields/ComboboxField.tsx @@ -6,6 +6,7 @@ * Copyright Oxide Computer Company */ +import { useState } from 'react' import { useController, type Control, @@ -15,7 +16,11 @@ import { type Validate, } from 'react-hook-form' -import { Combobox, type ComboboxBaseProps } from '~/ui/lib/Combobox' +import { + Combobox, + getSelectedLabelFromValue, + type ComboboxBaseProps, +} from '~/ui/lib/Combobox' import { capitalize } from '~/util/str' import { ErrorMessage } from './ErrorMessage' @@ -54,6 +59,7 @@ export function ComboboxField< : allowArbitraryValues ? 'Select an option or enter a custom value' : 'Select an option', + items, validate, ...props }: ComboboxFieldProps) { @@ -62,20 +68,27 @@ export function ComboboxField< control, rules: { required, validate }, }) + const [selectedItemLabel, setSelectedItemLabel] = useState( + getSelectedLabelFromValue(items, field.value || '') + ) return (
{ field.onChange(value) onChange?.(value) + setSelectedItemLabel(getSelectedLabelFromValue(items, value)) }} allowArbitraryValues={allowArbitraryValues} + inputRef={field.ref} {...props} /> diff --git a/app/components/form/fields/DisksTableField.tsx b/app/components/form/fields/DisksTableField.tsx index 4084c7eacd..2c3c8f0559 100644 --- a/app/components/form/fields/DisksTableField.tsx +++ b/app/components/form/fields/DisksTableField.tsx @@ -16,6 +16,7 @@ import type { InstanceCreateInput } from '~/forms/instance-create' import { Badge } from '~/ui/lib/Badge' import { Button } from '~/ui/lib/Button' import * as MiniTable from '~/ui/lib/MiniTable' +import { Truncate } from '~/ui/lib/Truncate' import { bytesToGiB } from '~/util/units' export type DiskTableItem = @@ -60,7 +61,9 @@ export function DisksTableField({ aria-label={`Name: ${item.name}, Type: ${item.type}`} key={item.name} > - {item.name} + + + {item.type} diff --git a/app/components/form/fields/ImageSelectField.tsx b/app/components/form/fields/ImageSelectField.tsx index 4f11e6f3fb..0f2307c063 100644 --- a/app/components/form/fields/ImageSelectField.tsx +++ b/app/components/form/fields/ImageSelectField.tsx @@ -10,12 +10,12 @@ import { useController, type Control } from 'react-hook-form' import type { Image } from '@oxide/api' import type { InstanceCreateInput } from '~/forms/instance-create' -import type { ListboxItem } from '~/ui/lib/Listbox' +import type { ComboboxItem } from '~/ui/lib/Combobox' import { Slash } from '~/ui/lib/Slash' import { nearest10 } from '~/util/math' import { bytesToGiB, GiB } from '~/util/units' -import { ListboxField } from './ListboxField' +import { ComboboxField } from './ComboboxField' type ImageSelectFieldProps = { images: Image[] @@ -32,18 +32,22 @@ export function BootDiskImageSelectField({ }: ImageSelectFieldProps) { const diskSizeField = useController({ control, name: 'bootDiskSize' }).field return ( - // This should be migrated to a `ComboboxField` (and with a `toComboboxItem`), once - // we have a combobox that supports more elaborate labels (beyond just strings). - toListboxItem(i))} + placeholder={ + name === 'siloImageSource' ? 'Select a silo image' : 'Select a project image' + } + items={images.map((i) => toImageComboboxItem(i))} required onChange={(id) => { - const image = images.find((i) => i.id === id)! // if it's selected, it must be present + const image = images.find((i) => i.id === id) + // the most likely scenario where image would be undefined is if the user has + // manually cleared the ComboboxField; they will need to pick a boot disk image + // in order to submit the form, so we don't need to do anything here + if (!image) return const imageSizeGiB = image.size / GiB if (diskSizeField.value < imageSizeGiB) { diskSizeField.onChange(nearest10(imageSizeGiB)) @@ -53,24 +57,18 @@ export function BootDiskImageSelectField({ ) } -export function toListboxItem(i: Image, includeProjectSiloIndicator = false): ListboxItem { - const { name, os, projectId, size, version } = i - const formattedSize = `${bytesToGiB(size, 1)} GiB` - - // filter out any undefined metadata and create a comma-separated list - // for the selected listbox item (shown in selectedLabel) - const condensedImageMetadata = [os, version, formattedSize].filter((i) => !!i).join(', ') - const metadataForSelectedLabel = condensedImageMetadata.length - ? ` (${condensedImageMetadata})` - : '' +export function toImageComboboxItem( + image: Image, + includeProjectSiloIndicator = false +): ComboboxItem { + const { id, name, os, projectId, size, version } = image // for metadata showing in the dropdown's options, include the project / silo indicator if requested const projectSiloIndicator = includeProjectSiloIndicator ? `${projectId ? 'Project' : 'Silo'} image` : null - // filter out undefined metadata here, as well, and create a ``-separated list - // for the listbox item (shown for each item in the dropdown) - const metadataForLabel = [os, version, formattedSize, projectSiloIndicator] + // filter out undefined metadata and create a ``-separated list for each comboboxitem + const itemMetadata = [os, version, `${bytesToGiB(size, 1)} GiB`, projectSiloIndicator] .filter((i) => !!i) .map((i, index) => ( @@ -79,15 +77,13 @@ export function toListboxItem(i: Image, includeProjectSiloIndicator = false): Li )) return { - value: i.id, - selectedLabel: `${name}${metadataForSelectedLabel}`, + value: id, + selectedLabel: name, label: ( - <> +
{name}
-
- {metadataForLabel} -
- +
{itemMetadata}
+
), } } diff --git a/app/components/form/fields/NameField.tsx b/app/components/form/fields/NameField.tsx index a8fa6d1e16..7f3100c978 100644 --- a/app/components/form/fields/NameField.tsx +++ b/app/components/form/fields/NameField.tsx @@ -30,6 +30,17 @@ export function NameField< required={required} label={label} name={name} + transform={(value) => + value + .toLowerCase() + .replace(/[\s_]+/g, '-') + .replace(/[^a-z0-9-]/g, '') + } + // https://www.stefanjudis.com/snippets/turn-off-password-managers/ + data-1p-ignore + data-bwignore + data-lpignore="true" + data-form-type="other" {...textFieldProps} /> ) diff --git a/app/components/form/fields/TextField.tsx b/app/components/form/fields/TextField.tsx index 9163a02e48..46b2f41c22 100644 --- a/app/components/form/fields/TextField.tsx +++ b/app/components/form/fields/TextField.tsx @@ -47,7 +47,7 @@ export interface TextFieldProps< validate?: Validate, TFieldValues> control: Control /** Alters the value of the input during the field's onChange event. */ - transform?: (value: string) => FieldPathValue + transform?: (value: string) => string } export function TextField< diff --git a/app/components/form/fields/ip-pool-item.tsx b/app/components/form/fields/ip-pool-item.tsx new file mode 100644 index 0000000000..cc977e0639 --- /dev/null +++ b/app/components/form/fields/ip-pool-item.tsx @@ -0,0 +1,30 @@ +/* + * 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 type { SiloIpPool } from '~/api' +import { Badge } from '~/ui/lib/Badge' + +export function toIpPoolItem(p: SiloIpPool) { + const value = p.name + const selectedLabel = p.name + const label = ( +
+
+ {p.name} + {p.isDefault && ( + + default + + )} +
+ {!!p.description && ( +
{p.description}
+ )} +
+ ) + return { value, selectedLabel, label } +} diff --git a/app/forms/disk-attach.tsx b/app/forms/disk-attach.tsx index 02f76203e7..ccfccdc7f7 100644 --- a/app/forms/disk-attach.tsx +++ b/app/forms/disk-attach.tsx @@ -5,6 +5,7 @@ * * Copyright Oxide Computer Company */ +import { useMemo } from 'react' import { useForm } from 'react-hook-form' import { useApiQuery, type ApiError } from '@oxide/api' @@ -12,6 +13,7 @@ import { useApiQuery, type ApiError } from '@oxide/api' import { ComboboxField } from '~/components/form/fields/ComboboxField' import { SideModalForm } from '~/components/form/SideModalForm' import { useProjectSelector } from '~/hooks/use-params' +import { toComboboxItems } from '~/ui/lib/Combobox' import { ALL_ISH } from '~/util/consts' const defaultValues = { name: '' } @@ -41,10 +43,15 @@ export function AttachDiskSideModalForm({ const { data } = useApiQuery('diskList', { query: { project, limit: ALL_ISH }, }) - const detachedDisks = - data?.items.filter( - (d) => d.state.state === 'detached' && !diskNamesToExclude.includes(d.name) - ) || [] + const detachedDisks = useMemo( + () => + toComboboxItems( + data?.items.filter( + (d) => d.state.state === 'detached' && !diskNamesToExclude.includes(d.name) + ) + ), + [data, diskNamesToExclude] + ) const form = useForm({ defaultValues }) @@ -63,7 +70,7 @@ export function AttachDiskSideModalForm({ label="Disk name" placeholder="Select a disk" name="name" - items={detachedDisks.map(({ name }) => ({ value: name, label: name }))} + items={detachedDisks} required control={form.control} /> diff --git a/app/forms/disk-create.tsx b/app/forms/disk-create.tsx index 8f50a6d230..593056f4df 100644 --- a/app/forms/disk-create.tsx +++ b/app/forms/disk-create.tsx @@ -23,11 +23,12 @@ import { import { DescriptionField } from '~/components/form/fields/DescriptionField' import { DiskSizeField } from '~/components/form/fields/DiskSizeField' -import { toListboxItem } from '~/components/form/fields/ImageSelectField' +import { toImageComboboxItem } from '~/components/form/fields/ImageSelectField' import { ListboxField } from '~/components/form/fields/ListboxField' import { NameField } from '~/components/form/fields/NameField' import { RadioField } from '~/components/form/fields/RadioField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { FormDivider } from '~/ui/lib/Divider' @@ -76,7 +77,7 @@ export function CreateDiskSideModalForm({ const createDisk = useApiMutation('diskCreate', { onSuccess(data) { queryClient.invalidateQueries('diskList') - addToast({ content: 'Your disk has been created' }) + addToast(<>Disk {data.name} created) // prettier-ignore onSuccess?.(data) onDismiss(navigate) }, @@ -210,7 +211,7 @@ const DiskSourceField = ({ label="Source image" placeholder="Select an image" isLoading={areImagesLoading} - items={images.map((i) => toListboxItem(i, true))} + items={images.map((i) => toImageComboboxItem(i, true))} required onChange={(id) => { const image = images.find((i) => i.id === id)! // if it's selected, it must be present diff --git a/app/forms/firewall-rules-common.tsx b/app/forms/firewall-rules-common.tsx index abb0992d34..b0935c893b 100644 --- a/app/forms/firewall-rules-common.tsx +++ b/app/forms/firewall-rules-common.tsx @@ -34,6 +34,7 @@ import { RadioField } from '~/components/form/fields/RadioField' import { TextField, TextFieldInner } from '~/components/form/fields/TextField' import { useVpcSelector } from '~/hooks/use-params' import { Badge } from '~/ui/lib/Badge' +import { toComboboxItems, type ComboboxItem } from '~/ui/lib/Combobox' import { FormDivider } from '~/ui/lib/Divider' import { Message } from '~/ui/lib/Message' import * as MiniTable from '~/ui/lib/MiniTable' @@ -99,7 +100,7 @@ const DynamicTypeAndValueFields = ({ sectionType: 'target' | 'host' control: Control valueType: TargetAndHostFilterType - items: Array<{ value: string; label: string }> + items: Array disabled?: boolean onInputChange?: (value: string) => void onTypeChange: () => void @@ -204,8 +205,8 @@ const TypeAndValueTable = ({ sectionType, items }: TypeAndValueTableProps) => ( ) -// Given an array of committed items (VPCs, Subnets, Instances) and -// a list of all items, return the items that are available +/** Given an array of *committed* items (VPCs, Subnets, Instances) and a list of *all* items, + * return the items that are available */ const availableItems = ( committedItems: Array, itemType: 'vpc' | 'subnet' | 'instance', @@ -214,13 +215,11 @@ const availableItems = ( if (!items) return [] return ( items - .map((item) => item.name) // remove any items that match the committed items on both type and value .filter( - (name) => + ({ name }) => !committedItems.filter((ci) => ci.type === itemType && ci.value === name).length ) - .map((name) => ({ label: name, value: name })) ) } @@ -434,7 +433,7 @@ export const CommonFields = ({ control, nameTaken, error }: CommonFieldsProps) = sectionType="target" control={targetForm.control} valueType={targetType} - items={targetItems[targetType]} + items={toComboboxItems(targetItems[targetType])} // HACK: reset the whole subform, keeping type (because we just set // it). most importantly, this resets isSubmitted so the form can go // back to validating on submit instead of change @@ -546,7 +545,7 @@ export const CommonFields = ({ control, nameTaken, error }: CommonFieldsProps) = sectionType="host" control={hostForm.control} valueType={hostType} - items={hostFilterItems[hostType]} + items={toComboboxItems(hostFilterItems[hostType])} // HACK: reset the whole subform, keeping type (because we just set // it). most importantly, this resets isSubmitted so the form can go // back to validating on submit instead of change diff --git a/app/forms/firewall-rules-create.tsx b/app/forms/firewall-rules-create.tsx index 99bebe081f..35aee97230 100644 --- a/app/forms/firewall-rules-create.tsx +++ b/app/forms/firewall-rules-create.tsx @@ -18,6 +18,7 @@ import { } from '@oxide/api' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { ALL_ISH } from '~/util/consts' @@ -74,9 +75,10 @@ export function CreateFirewallRuleForm() { const onDismiss = () => navigate(pb.vpcFirewallRules(vpcSelector)) const updateRules = useApiMutation('vpcFirewallRulesUpdate', { - onSuccess() { + onSuccess(updatedRules) { + const newRule = updatedRules.rules[updatedRules.rules.length - 1] queryClient.invalidateQueries('vpcFirewallRulesView') - addToast({ content: 'Your firewall rule has been created' }) + addToast(<>Firewall rule {newRule.name} created) // prettier-ignore navigate(pb.vpcFirewallRules(vpcSelector)) }, }) diff --git a/app/forms/firewall-rules-edit.tsx b/app/forms/firewall-rules-edit.tsx index 50957bff10..bbea4f975e 100644 --- a/app/forms/firewall-rules-edit.tsx +++ b/app/forms/firewall-rules-edit.tsx @@ -18,11 +18,13 @@ import { import { trigger404 } from '~/components/ErrorBoundary' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getFirewallRuleSelector, useFirewallRuleSelector, useVpcSelector, } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' import { ALL_ISH } from '~/util/consts' import { invariant } from '~/util/invariant' import { pb } from '~/util/path-builder' @@ -64,13 +66,15 @@ export function EditFirewallRuleForm() { const onDismiss = () => navigate(pb.vpcFirewallRules(vpcSelector)) const updateRules = useApiMutation('vpcFirewallRulesUpdate', { - onSuccess() { + onSuccess(updatedRules, { body }) { // Nav before the invalidate because I once saw the above invariant fail // briefly after successful edit (error page flashed but then we land // on the rules list ok) and I think it was a race condition where the // invalidate managed to complete while the modal was still open. onDismiss() queryClient.invalidateQueries('vpcFirewallRulesView') + const updatedRule = body.rules[body.rules.length - 1] + addToast(<>Firewall rule {updatedRule.name} updated) // prettier-ignore }, }) diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index 77424696c7..cab5b694e6 100644 --- a/app/forms/floating-ip-create.tsx +++ b/app/forms/floating-ip-create.tsx @@ -15,40 +15,21 @@ import { useApiQuery, useApiQueryClient, type FloatingIpCreate, - type SiloIpPool, } from '@oxide/api' import { AccordionItem } from '~/components/AccordionItem' import { DescriptionField } from '~/components/form/fields/DescriptionField' +import { toIpPoolItem } from '~/components/form/fields/ip-pool-item' import { ListboxField } from '~/components/form/fields/ListboxField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' -import { Badge } from '~/ui/lib/Badge' import { Message } from '~/ui/lib/Message' import { ALL_ISH } from '~/util/consts' import { pb } from '~/util/path-builder' -const toListboxItem = (p: SiloIpPool) => { - if (!p.isDefault) { - return { value: p.name, label: p.name } - } - // For the default pool, add a label to the dropdown - return { - value: p.name, - selectedLabel: p.name, - label: ( - <> - {p.name}{' '} - - default - - - ), - } -} - const defaultValues: Omit = { name: '', description: '', @@ -65,10 +46,10 @@ export function CreateFloatingIpSideModalForm() { const navigate = useNavigate() const createFloatingIp = useApiMutation('floatingIpCreate', { - onSuccess() { + onSuccess(floatingIp) { queryClient.invalidateQueries('floatingIpList') queryClient.invalidateQueries('ipPoolUtilizationView') - addToast({ content: 'Your Floating IP has been created' }) + addToast(<>Floating IP {floatingIp.name} created) // prettier-ignore navigate(pb.floatingIps(projectSelector)) }, }) @@ -108,7 +89,7 @@ export function CreateFloatingIpSideModalForm() { toListboxItem(p))} + items={(allPools?.items || []).map(toIpPoolItem)} label="IP pool" control={form.control} placeholder="Select a pool" diff --git a/app/forms/floating-ip-edit.tsx b/app/forms/floating-ip-edit.tsx index 44b19bd538..26fe356f92 100644 --- a/app/forms/floating-ip-edit.tsx +++ b/app/forms/floating-ip-edit.tsx @@ -18,6 +18,7 @@ import { import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getFloatingIpSelector, useFloatingIpSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from 'app/util/path-builder' @@ -47,7 +48,7 @@ export function EditFloatingIpSideModalForm() { const editFloatingIp = useApiMutation('floatingIpUpdate', { onSuccess(_floatingIp) { queryClient.invalidateQueries('floatingIpList') - addToast({ content: 'Your floating IP has been updated' }) + addToast(<>Floating IP {_floatingIp.name} updated) // prettier-ignore onDismiss() }, }) diff --git a/app/forms/idp/create.tsx b/app/forms/idp/create.tsx index 4fa8e10fa7..42004e88e1 100644 --- a/app/forms/idp/create.tsx +++ b/app/forms/idp/create.tsx @@ -15,6 +15,7 @@ import { FileField } from '~/components/form/fields/FileField' import { NameField } from '~/components/form/fields/NameField' import { TextField } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { useSiloSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { readBlobAsBase64 } from '~/util/file' @@ -51,9 +52,9 @@ export function CreateIdpSideModalForm() { const onDismiss = () => navigate(pb.silo({ silo })) const createIdp = useApiMutation('samlIdentityProviderCreate', { - onSuccess() { + onSuccess(idp) { queryClient.invalidateQueries('siloIdentityProviderList') - addToast({ content: 'Your identity provider has been created' }) + addToast(<>IdP {idp.name} created) // prettier-ignore onDismiss() }, }) @@ -133,7 +134,7 @@ export function CreateIdpSideModalForm() { {/* TODO: Email field, probably */} diff --git a/app/forms/idp/edit.tsx b/app/forms/idp/edit.tsx index a5cbc1e07a..b51d1d9043 100644 --- a/app/forms/idp/edit.tsx +++ b/app/forms/idp/edit.tsx @@ -104,14 +104,14 @@ export function EditIdpSideModalForm() { control={form.control} disabled /> - {/* TODO: add group attribute name when it is added to the API - */} + {/* TODO: Email field, probably */} navigate(pb.snapshots({ project })) const createImage = useApiMutation('imageCreate', { - onSuccess() { + onSuccess(image) { queryClient.invalidateQueries('imageList') - addToast({ content: 'Your image has been created' }) + addToast(<>Image {image.name} created) // prettier-ignore onDismiss() }, }) diff --git a/app/forms/image-upload.tsx b/app/forms/image-upload.tsx index cba8df02d9..b7b7dbbbe8 100644 --- a/app/forms/image-upload.tsx +++ b/app/forms/image-upload.tsx @@ -215,7 +215,13 @@ export function CreateImageSideModalForm() { const createDisk = useApiMutation('diskCreate') const startImport = useApiMutation('diskBulkWriteImportStart') - const uploadChunk = useApiMutation('diskBulkWriteImport') + + // gcTime: 0 prevents the mutation cache from holding onto all the chunks for + // 5 minutes. It can be a ton of memory. To be honest, I don't even understand + // why the mutation cache exists. It's not like the query cache, which dedupes + // identical queries made around the same time. + // https://tanstack.com/query/v5/docs/reference/MutationCache + const uploadChunk = useApiMutation('diskBulkWriteImport', { gcTime: 0 }) // synthetic state for upload step because it consists of multiple requests const [syntheticUploadState, setSyntheticUploadState] = diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 44cfd90d2b..8350ccf20a 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -25,6 +25,7 @@ import { type InstanceCreate, type InstanceDiskAttachment, type NameOrId, + type SiloIpPool, } from '@oxide/api' import { Images16Icon, @@ -46,6 +47,7 @@ import { } from '~/components/form/fields/DisksTableField' import { FileField } from '~/components/form/fields/FileField' import { BootDiskImageSelectField as ImageSelectField } from '~/components/form/fields/ImageSelectField' +import { toIpPoolItem } from '~/components/form/fields/ip-pool-item' import { NameField } from '~/components/form/fields/NameField' import { NetworkInterfaceField } from '~/components/form/fields/NetworkInterfaceField' import { NumberField } from '~/components/form/fields/NumberField' @@ -57,9 +59,9 @@ import { FullPageForm } from '~/components/form/FullPageForm' import { HL } from '~/components/HL' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' -import { Badge } from '~/ui/lib/Badge' import { Button } from '~/ui/lib/Button' import { Checkbox } from '~/ui/lib/Checkbox' +import { toComboboxItems } from '~/ui/lib/Combobox' import { FormDivider } from '~/ui/lib/Divider' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { Listbox } from '~/ui/lib/Listbox' @@ -182,7 +184,7 @@ export function CreateInstanceForm() { { path: { instance: instance.name }, query: { project } }, instance ) - addToast({ content: 'Your instance has been created' }) + addToast(<>Instance {instance.name} created) // prettier-ignore navigate(pb.instance({ project, instance: instance.name })) }, }) @@ -197,10 +199,7 @@ export function CreateInstanceForm() { const allDisks = usePrefetchedApiQuery('diskList', { query: { project, limit: ALL_ISH }, }).data.items - const disks = useMemo( - () => allDisks.filter(diskCan.attach).map(({ name }) => ({ value: name, label: name })), - [allDisks] - ) + const disks = useMemo(() => toComboboxItems(allDisks.filter(diskCan.attach)), [allDisks]) const { data: sshKeys } = usePrefetchedApiQuery('currentUserSshKeyList', {}) const allKeys = useMemo(() => sshKeys.items.map((key) => key.id), [sshKeys]) @@ -611,7 +610,7 @@ const AdvancedAccordion = ({ }: { control: Control isSubmitting: boolean - siloPools: Array<{ name: string; isDefault: boolean }> + siloPools: Array }) => { // we track this state manually for the sole reason that we need to be able to // tell, inside AccordionItem, when an accordion is opened so we can scroll its @@ -735,17 +734,7 @@ const AdvancedAccordion = ({ label="IP pool for ephemeral IP" placeholder={defaultPool ? `${defaultPool} (default)` : 'Select a pool'} selected={`${siloPools.find((pool) => pool.name === selectedPool)?.name}`} - items={ - siloPools.map((pool) => ({ - label: ( -
- {pool.name} - {pool.isDefault && default} -
- ), - value: pool.name, - })) || [] - } + items={siloPools.map(toIpPoolItem)} disabled={!assignEphemeralIp || isSubmitting} required onChange={(value) => { diff --git a/app/forms/ip-pool-create.tsx b/app/forms/ip-pool-create.tsx index c91e8d8d31..8afa803e9e 100644 --- a/app/forms/ip-pool-create.tsx +++ b/app/forms/ip-pool-create.tsx @@ -13,7 +13,9 @@ import { useApiMutation, useApiQueryClient, type IpPoolCreate } from '@oxide/api import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { addToast } from '~/stores/toast' +import { Message } from '~/ui/lib/Message' import { pb } from '~/util/path-builder' const defaultValues: IpPoolCreate = { @@ -30,7 +32,7 @@ export function CreateIpPoolSideModalForm() { const createPool = useApiMutation('ipPoolCreate', { onSuccess(_pool) { queryClient.invalidateQueries('ipPoolList') - addToast({ content: 'Your IP pool has been created' }) + addToast(<>IP pool {_pool.name} created) // prettier-ignore navigate(pb.ipPools()) }, }) @@ -51,6 +53,14 @@ export function CreateIpPoolSideModalForm() { > + ) } + +export const IpPoolVisibilityMessage = () => ( + +) diff --git a/app/forms/ip-pool-edit.tsx b/app/forms/ip-pool-edit.tsx index 73e2c942c5..cbd0b7db7d 100644 --- a/app/forms/ip-pool-edit.tsx +++ b/app/forms/ip-pool-edit.tsx @@ -18,10 +18,13 @@ import { import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getIpPoolSelector, useIpPoolSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' +import { IpPoolVisibilityMessage } from './ip-pool-create' + EditIpPoolSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => { const { pool } = getIpPoolSelector(params) await apiQueryClient.prefetchQuery('ipPoolView', { path: { pool } }) @@ -41,7 +44,7 @@ export function EditIpPoolSideModalForm() { onSuccess(updatedPool) { queryClient.invalidateQueries('ipPoolList') navigate(pb.ipPool({ pool: updatedPool.name })) - addToast({ content: 'Your IP pool has been updated' }) + addToast(<>IP pool {updatedPool.name} updated) // prettier-ignore // Only invalidate if we're staying on the same page. If the name // _has_ changed, invalidating ipPoolView causes an error page to flash @@ -68,6 +71,7 @@ export function EditIpPoolSideModalForm() { > + ) } diff --git a/app/forms/network-interface-create.tsx b/app/forms/network-interface-create.tsx index 43a93b9414..e224016a8b 100644 --- a/app/forms/network-interface-create.tsx +++ b/app/forms/network-interface-create.tsx @@ -7,6 +7,7 @@ */ import { useMemo } from 'react' import { useForm } from 'react-hook-form' +import type { SetRequired } from 'type-fest' import { useApiQuery, type ApiError, type InstanceNetworkInterfaceCreate } from '@oxide/api' @@ -19,10 +20,10 @@ import { SideModalForm } from '~/components/form/SideModalForm' import { useProjectSelector } from '~/hooks/use-params' import { FormDivider } from '~/ui/lib/Divider' -const defaultValues: InstanceNetworkInterfaceCreate = { +const defaultValues: SetRequired = { name: '', description: '', - ip: undefined, + ip: '', subnetName: '', vpcName: '', } @@ -58,7 +59,7 @@ export function CreateNetworkInterfaceForm({ resourceName="network interface" title="Add network interface" onDismiss={onDismiss} - onSubmit={onSubmit} + onSubmit={({ ip, ...rest }) => onSubmit({ ip: ip.trim() || undefined, ...rest })} loading={loading} submitError={submitError} > @@ -81,12 +82,7 @@ export function CreateNetworkInterfaceForm({ required control={form.control} /> - (ip.trim() === '' ? undefined : ip)} - /> + ) } diff --git a/app/forms/network-interface-edit.tsx b/app/forms/network-interface-edit.tsx index c57bde7899..401403f900 100644 --- a/app/forms/network-interface-edit.tsx +++ b/app/forms/network-interface-edit.tsx @@ -20,7 +20,9 @@ import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { TextFieldInner } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { useInstanceSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' import { FormDivider } from '~/ui/lib/Divider' import { FieldLabel } from '~/ui/lib/FieldLabel' import * as MiniTable from '~/ui/lib/MiniTable' @@ -42,8 +44,9 @@ export function EditNetworkInterfaceForm({ const instanceSelector = useInstanceSelector() const editNetworkInterface = useApiMutation('instanceNetworkInterfaceUpdate', { - onSuccess() { + onSuccess(nic) { queryClient.invalidateQueries('instanceNetworkInterfaceList') + addToast(<>Network interface {nic.name} updated) // prettier-ignore onDismiss() }, }) diff --git a/app/forms/project-access.tsx b/app/forms/project-access.tsx index 826b587744..ae9551cd37 100644 --- a/app/forms/project-access.tsx +++ b/app/forms/project-access.tsx @@ -17,6 +17,7 @@ import { import { ListboxField } from '~/components/form/fields/ListboxField' import { SideModalForm } from '~/components/form/SideModalForm' import { useProjectSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' import { actorToItem, @@ -35,6 +36,8 @@ export function ProjectAccessAddUserSideModal({ onDismiss, policy }: AddRoleModa const updatePolicy = useApiMutation('projectPolicyUpdate', { onSuccess: () => { queryClient.invalidateQueries('projectPolicyView') + // We don't have the name of the user or group, so we'll just have a generic message + addToast({ content: 'Role assigned' }) onDismiss() }, }) @@ -97,6 +100,7 @@ export function ProjectAccessEditUserSideModal({ const updatePolicy = useApiMutation('projectPolicyUpdate', { onSuccess: () => { queryClient.invalidateQueries('projectPolicyView') + addToast({ content: 'Role updated' }) onDismiss() }, }) diff --git a/app/forms/project-create.tsx b/app/forms/project-create.tsx index 020894826c..faaee13df7 100644 --- a/app/forms/project-create.tsx +++ b/app/forms/project-create.tsx @@ -13,6 +13,7 @@ import { useApiMutation, useApiQueryClient, type ProjectCreate } from '@oxide/ap import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' @@ -33,7 +34,7 @@ export function CreateProjectSideModalForm() { queryClient.invalidateQueries('projectList') // avoid the project fetch when the project page loads since we have the data queryClient.setQueryData('projectView', { path: { project: project.name } }, project) - addToast({ content: 'Your project has been created' }) + addToast(<>Project {project.name} created) // prettier-ignore navigate(pb.project({ project: project.name })) }, }) diff --git a/app/forms/project-edit.tsx b/app/forms/project-edit.tsx index 284c1de8de..7af23a1723 100644 --- a/app/forms/project-edit.tsx +++ b/app/forms/project-edit.tsx @@ -18,6 +18,7 @@ import { import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' @@ -45,7 +46,7 @@ export function EditProjectSideModalForm() { queryClient.invalidateQueries('projectList') // avoid the project fetch when the project page loads since we have the data queryClient.setQueryData('projectView', { path: { project: project.name } }, project) - addToast({ content: 'Your project has been updated' }) + addToast(<>Project {project.name} updated) // prettier-ignore onDismiss() }, }) diff --git a/app/forms/silo-create.tsx b/app/forms/silo-create.tsx index 9508386bae..ea6b82651e 100644 --- a/app/forms/silo-create.tsx +++ b/app/forms/silo-create.tsx @@ -19,6 +19,7 @@ import { RadioField } from '~/components/form/fields/RadioField' import { TextField } from '~/components/form/fields/TextField' import { TlsCertsField } from '~/components/form/fields/TlsCertsField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { addToast } from '~/stores/toast' import { FormDivider } from '~/ui/lib/Divider' import { FieldLabel } from '~/ui/lib/FieldLabel' @@ -57,7 +58,7 @@ export function CreateSiloSideModalForm() { onSuccess(silo) { queryClient.invalidateQueries('siloList') queryClient.setQueryData('siloView', { path: { silo: silo.name } }, silo) - addToast({ content: 'Your silo has been created' }) + addToast(<>Silo {silo.name} created) // prettier-ignore onDismiss() }, }) diff --git a/app/forms/snapshot-create.tsx b/app/forms/snapshot-create.tsx index 656f9aebb6..25c7f90db8 100644 --- a/app/forms/snapshot-create.tsx +++ b/app/forms/snapshot-create.tsx @@ -5,6 +5,7 @@ * * Copyright Oxide Computer Company */ +import { useMemo } from 'react' import { useForm } from 'react-hook-form' import { useNavigate } from 'react-router-dom' @@ -21,8 +22,10 @@ import { ComboboxField } from '~/components/form/fields/ComboboxField' import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' +import { toComboboxItems } from '~/ui/lib/Combobox' import { ALL_ISH } from '~/util/consts' import { pb } from '~/util/path-builder' @@ -30,11 +33,7 @@ const useSnapshotDiskItems = (projectSelector: PP.Project) => { const { data: disks } = useApiQuery('diskList', { query: { ...projectSelector, limit: ALL_ISH }, }) - return ( - disks?.items - .filter(diskCan.snapshot) - .map((disk) => ({ value: disk.name, label: disk.name })) || [] - ) + return disks?.items.filter(diskCan.snapshot) } const defaultValues: SnapshotCreate = { @@ -49,13 +48,14 @@ export function CreateSnapshotSideModalForm() { const navigate = useNavigate() const diskItems = useSnapshotDiskItems(projectSelector) + const diskItemsForCombobox = useMemo(() => toComboboxItems(diskItems), [diskItems]) const onDismiss = () => navigate(pb.snapshots(projectSelector)) const createSnapshot = useApiMutation('snapshotCreate', { - onSuccess() { + onSuccess(snapshot) { queryClient.invalidateQueries('snapshotList') - addToast({ content: 'Your snapshot has been created' }) + addToast(<>Snapshot {snapshot.name} created) // prettier-ignore onDismiss() }, }) @@ -79,7 +79,7 @@ export function CreateSnapshotSideModalForm() { label="Disk" name="disk" placeholder="Select a disk" - items={diskItems} + items={diskItemsForCombobox} required control={form.control} /> diff --git a/app/forms/ssh-key-create.tsx b/app/forms/ssh-key-create.tsx index 14e5b399a3..82ba183e23 100644 --- a/app/forms/ssh-key-create.tsx +++ b/app/forms/ssh-key-create.tsx @@ -14,6 +14,7 @@ import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { TextField } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' @@ -35,10 +36,10 @@ export function CreateSSHKeySideModalForm({ onDismiss, message }: Props) { const handleDismiss = onDismiss ? onDismiss : () => navigate(pb.sshKeys()) const createSshKey = useApiMutation('currentUserSshKeyCreate', { - onSuccess() { + onSuccess(sshKey) { queryClient.invalidateQueries('currentUserSshKeyList') handleDismiss() - addToast({ content: 'Your SSH key has been created' }) + addToast(<>SSH key {sshKey.name} created) // prettier-ignore }, }) const form = useForm({ defaultValues }) diff --git a/app/forms/subnet-create.tsx b/app/forms/subnet-create.tsx index 5ed229999c..e2bbb2666a 100644 --- a/app/forms/subnet-create.tsx +++ b/app/forms/subnet-create.tsx @@ -20,7 +20,9 @@ import { useCustomRouterItems, } from '~/components/form/fields/useItemsList' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { useVpcSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' import { FormDivider } from '~/ui/lib/Divider' import { pb } from '~/util/path-builder' @@ -42,9 +44,10 @@ export function CreateSubnetForm() { const onDismiss = () => navigate(pb.vpcSubnets(vpcSelector)) const createSubnet = useApiMutation('vpcSubnetCreate', { - onSuccess() { + onSuccess(subnet) { queryClient.invalidateQueries('vpcSubnetList') onDismiss() + addToast(<>Subnet {subnet.name} created) // prettier-ignore }, }) diff --git a/app/forms/subnet-edit.tsx b/app/forms/subnet-edit.tsx index 6bfd7e18c1..49ab973fbc 100644 --- a/app/forms/subnet-edit.tsx +++ b/app/forms/subnet-edit.tsx @@ -25,7 +25,9 @@ import { useCustomRouterItems, } from '~/components/form/fields/useItemsList' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getVpcSubnetSelector, useVpcSubnetSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' import { FormDivider } from '~/ui/lib/Divider' import { pb } from '~/util/path-builder' @@ -51,8 +53,9 @@ export function EditSubnetForm() { }) const updateSubnet = useApiMutation('vpcSubnetUpdate', { - onSuccess() { + onSuccess(subnet) { queryClient.invalidateQueries('vpcSubnetList') + addToast(<>Subnet {subnet.name} updated) // prettier-ignore onDismiss() }, }) diff --git a/app/forms/vpc-create.tsx b/app/forms/vpc-create.tsx index f93d040b8c..43f8fa15a1 100644 --- a/app/forms/vpc-create.tsx +++ b/app/forms/vpc-create.tsx @@ -14,6 +14,7 @@ import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { TextField } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' @@ -38,7 +39,7 @@ export function CreateVpcSideModalForm() { { path: { vpc: vpc.name }, query: projectSelector }, vpc ) - addToast({ content: 'Your VPC has been created' }) + addToast(<>VPC {vpc.name} created) // prettier-ignore navigate(pb.vpc({ vpc: vpc.name, ...projectSelector })) }, }) diff --git a/app/forms/vpc-edit.tsx b/app/forms/vpc-edit.tsx index 9a6380f5fa..0982d17f10 100644 --- a/app/forms/vpc-edit.tsx +++ b/app/forms/vpc-edit.tsx @@ -18,6 +18,7 @@ import { import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' @@ -42,7 +43,7 @@ export function EditVpcSideModalForm() { onSuccess(updatedVpc) { queryClient.invalidateQueries('vpcList') navigate(pb.vpc({ project, vpc: updatedVpc.name })) - addToast({ content: 'Your VPC has been updated' }) + addToast(<>VPC {updatedVpc.name} updated) // prettier-ignore // Only invalidate if we're staying on the same page. If the name // _has_ changed, invalidating vpcView causes an error page to flash diff --git a/app/forms/vpc-router-create.tsx b/app/forms/vpc-router-create.tsx index c808d3a099..3d08d456cc 100644 --- a/app/forms/vpc-router-create.tsx +++ b/app/forms/vpc-router-create.tsx @@ -13,6 +13,7 @@ import { useApiMutation, useApiQueryClient, type VpcRouterCreate } from '@oxide/ import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { useVpcSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' @@ -30,9 +31,9 @@ export function CreateRouterSideModalForm() { const onDismiss = () => navigate(pb.vpcRouters(vpcSelector)) const createRouter = useApiMutation('vpcRouterCreate', { - onSuccess() { + onSuccess(router) { queryClient.invalidateQueries('vpcRouterList') - addToast({ content: 'Your router has been created' }) + addToast(<>Router {router.name} created) // prettier-ignore onDismiss() }, }) diff --git a/app/forms/vpc-router-edit.tsx b/app/forms/vpc-router-edit.tsx index 3d8067022d..134aadcf26 100644 --- a/app/forms/vpc-router-edit.tsx +++ b/app/forms/vpc-router-edit.tsx @@ -23,6 +23,7 @@ import { import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getVpcRouterSelector, useVpcRouterSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' @@ -51,9 +52,9 @@ export function EditRouterSideModalForm() { } const editRouter = useApiMutation('vpcRouterUpdate', { - onSuccess() { + onSuccess(updatedRouter) { queryClient.invalidateQueries('vpcRouterList') - addToast({ content: 'Your router has been updated' }) + addToast(<>Router {updatedRouter.name} updated) // prettier-ignore navigate(pb.vpcRouters({ project, vpc })) }, }) diff --git a/app/forms/vpc-router-route-common.tsx b/app/forms/vpc-router-route-common.tsx index 7215113bea..186667bef8 100644 --- a/app/forms/vpc-router-route-common.tsx +++ b/app/forms/vpc-router-route-common.tsx @@ -10,12 +10,10 @@ import type { UseFormReturn } from 'react-hook-form' import { usePrefetchedApiQuery, - type Instance, type RouteDestination, type RouterRouteCreate, type RouterRouteUpdate, type RouteTarget, - type VpcSubnet, } from '~/api' import { ComboboxField } from '~/components/form/fields/ComboboxField' import { DescriptionField } from '~/components/form/fields/DescriptionField' @@ -23,6 +21,7 @@ import { ListboxField } from '~/components/form/fields/ListboxField' import { NameField } from '~/components/form/fields/NameField' import { TextField } from '~/components/form/fields/TextField' import { useVpcRouterSelector } from '~/hooks/use-params' +import { toComboboxItems } from '~/ui/lib/Combobox' import { Message } from '~/ui/lib/Message' import { validateIp, validateIpNet } from '~/util/ip' @@ -94,9 +93,6 @@ const targetValueDescription: Record = const toListboxItems = (mapping: Record) => Object.entries(mapping).map(([value, label]) => ({ value, label })) -const toComboboxItems = (items: Array) => - items.map(({ name }) => ({ value: name, label: name })) - type RouteFormFieldsProps = { form: UseFormReturn disabled?: boolean diff --git a/app/forms/vpc-router-route-create.tsx b/app/forms/vpc-router-route-create.tsx index 4ed2afe6c0..8030b55dcd 100644 --- a/app/forms/vpc-router-route-create.tsx +++ b/app/forms/vpc-router-route-create.tsx @@ -11,6 +11,7 @@ import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom' import { apiQueryClient, useApiMutation, useApiQueryClient } from '@oxide/api' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { RouteFormFields, type RouteFormValues } from '~/forms/vpc-router-route-common' import { getVpcRouterSelector, useVpcRouterSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' @@ -44,9 +45,9 @@ export function CreateRouterRouteSideModalForm() { const form = useForm({ defaultValues }) const createRouterRoute = useApiMutation('vpcRouterRouteCreate', { - onSuccess() { + onSuccess(route) { queryClient.invalidateQueries('vpcRouterRouteList') - addToast({ content: 'Your route has been created' }) + addToast(<>Route {route.name} created) // prettier-ignore navigate(pb.vpcRouter(routerSelector)) }, }) diff --git a/app/forms/vpc-router-route-edit.tsx b/app/forms/vpc-router-route-edit.tsx index 19ac9934e2..da1c06338e 100644 --- a/app/forms/vpc-router-route-edit.tsx +++ b/app/forms/vpc-router-route-edit.tsx @@ -17,6 +17,7 @@ import { } from '@oxide/api' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { RouteFormFields, routeFormMessage, @@ -62,9 +63,9 @@ export function EditRouterRouteSideModalForm() { const disabled = route?.kind === 'vpc_subnet' const updateRouterRoute = useApiMutation('vpcRouterRouteUpdate', { - onSuccess() { + onSuccess(updatedRoute) { queryClient.invalidateQueries('vpcRouterRouteList') - addToast({ content: 'Your route has been updated' }) + addToast(<>Route {updatedRoute.name} updated) // prettier-ignore navigate(pb.vpcRouter(routerSelector)) }, }) diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx index a2463e31c0..22aeda4281 100644 --- a/app/layouts/SystemLayout.tsx +++ b/app/layouts/SystemLayout.tsx @@ -19,7 +19,12 @@ import { import { trigger404 } from '~/components/ErrorBoundary' import { DocsLinkItem, NavLinkItem, Sidebar } from '~/components/Sidebar' import { TopBar } from '~/components/TopBar' -import { IpPoolPicker, SiloPicker, SiloSystemPicker } from '~/components/TopBarPicker' +import { + IpPoolPicker, + SiloPicker, + SiloSystemPicker, + SledPicker, +} from '~/components/TopBarPicker' import { useQuickActions } from '~/hooks/use-quick-actions' import { Divider } from '~/ui/lib/Divider' import { pb } from '~/util/path-builder' @@ -55,7 +60,7 @@ export function SystemLayout() { // robust way of doing this would be to make a separate layout for the // silo-specific routes in the route config, but it's overkill considering // this is a one-liner. Switch to that approach at the first sign of trouble. - const { silo, pool } = useParams() + const { silo, pool, sledId } = useParams() const navigate = useNavigate() const { pathname } = useLocation() @@ -92,6 +97,7 @@ export function SystemLayout() { {silo && } {pool && } + {sledId && } diff --git a/app/pages/LoginPage.tsx b/app/pages/LoginPage.tsx index 92b14cc7f2..4ff7a48d2d 100644 --- a/app/pages/LoginPage.tsx +++ b/app/pages/LoginPage.tsx @@ -35,7 +35,7 @@ export function LoginPage() { useEffect(() => { if (loginPost.isSuccess) { - addToast({ title: 'Logged in' }) + addToast('Logged in') navigate(searchParams.get('redirect_uri') || pb.projects()) } }, [loginPost.isSuccess, navigate, searchParams]) diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index 832749910e..05173294af 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -34,6 +34,7 @@ import { } from '~/forms/project-access' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' import { getActionsCol } from '~/table/columns/action-col' import { Table } from '~/table/Table' import { Badge } from '~/ui/lib/Badge' @@ -119,7 +120,10 @@ export function ProjectAccessPage() { const queryClient = useApiQueryClient() const { mutateAsync: updatePolicy } = useApiMutation('projectPolicyUpdate', { - onSuccess: () => queryClient.invalidateQueries('projectPolicyView'), + onSuccess: () => { + queryClient.invalidateQueries('projectPolicyView') + addToast({ content: 'Access removed' }) + }, // TODO: handle 403 }) diff --git a/app/pages/project/disks/DisksPage.tsx b/app/pages/project/disks/DisksPage.tsx index 11e0c215d6..298e4af5f9 100644 --- a/app/pages/project/disks/DisksPage.tsx +++ b/app/pages/project/disks/DisksPage.tsx @@ -20,6 +20,7 @@ import { import { Storage16Icon, Storage24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' import { DiskStateBadge } from '~/components/StateBadge' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { confirmDelete } from '~/stores/confirm-delete' @@ -99,15 +100,16 @@ export function DisksPage() { const { Table } = useQueryTable('diskList', { query: { project } }) const { mutateAsync: deleteDisk } = useApiMutation('diskDelete', { - onSuccess() { + onSuccess(_data, variables) { queryClient.invalidateQueries('diskList') + addToast(<>Disk {variables.path.disk} deleted) // prettier-ignore }, }) const { mutate: createSnapshot } = useApiMutation('snapshotCreate', { - onSuccess() { + onSuccess(_data, variables) { queryClient.invalidateQueries('snapshotList') - addToast({ content: 'Snapshot successfully created' }) + addToast(<>Snapshot {variables.body.name} created) // prettier-ignore }, onError(err) { addToast({ @@ -123,7 +125,7 @@ export function DisksPage() { { label: 'Snapshot', onActivate() { - addToast({ title: `Creating snapshot of disk '${disk.name}'` }) + addToast(<>Creating snapshot of disk {disk.name}) // prettier-ignore createSnapshot({ query: { project }, body: { diff --git a/app/pages/project/floating-ips/FloatingIpsPage.tsx b/app/pages/project/floating-ips/FloatingIpsPage.tsx index a8e35941ad..ae5b95b57c 100644 --- a/app/pages/project/floating-ips/FloatingIpsPage.tsx +++ b/app/pages/project/floating-ips/FloatingIpsPage.tsx @@ -13,7 +13,6 @@ import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router-dom' import { apiQueryClient, useApiMutation, - useApiQuery, useApiQueryClient, usePrefetchedApiQuery, type FloatingIp, @@ -28,18 +27,18 @@ import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { confirmAction } from '~/stores/confirm-action' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' -import { EmptyCell } from '~/table/cells/EmptyCell' import { InstanceLinkCell } from '~/table/cells/InstanceLinkCell' +import { IpPoolCell } from '~/table/cells/IpPoolCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' import { PAGE_SIZE, useQueryTable } from '~/table/QueryTable' +import { CopyableIp } from '~/ui/lib/CopyableIp' import { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { Message } from '~/ui/lib/Message' import { Modal } from '~/ui/lib/Modal' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { TableActions } from '~/ui/lib/Table' -import { Tooltip } from '~/ui/lib/Tooltip' import { ALL_ISH } from '~/util/consts' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' @@ -63,6 +62,9 @@ FloatingIpsPage.loader = async ({ params }: LoaderFunctionArgs) => { apiQueryClient.prefetchQuery('instanceList', { query: { project }, }), + // fetch IP Pools and preload into RQ cache so fetches by ID in + // IpPoolCell can be mostly instant yet gracefully fall back to + // fetching individually if we don't fetch them all here apiQueryClient .fetchQuery('projectIpPoolList', { query: { limit: ALL_ISH } }) .then((pools) => { @@ -78,32 +80,21 @@ FloatingIpsPage.loader = async ({ params }: LoaderFunctionArgs) => { return null } -const IpPoolCell = ({ ipPoolId }: { ipPoolId: string }) => { - const pool = useApiQuery('projectIpPoolView', { path: { pool: ipPoolId } }).data - if (!pool) return - return pool.description ? ( - - {pool.name} - - ) : ( - <>{pool.name} - ) -} - const colHelper = createColumnHelper() const staticCols = [ colHelper.accessor('name', {}), colHelper.accessor('description', Columns.description), colHelper.accessor('ip', { header: 'IP address', + cell: (info) => , }), colHelper.accessor('ipPoolId', { - cell: (info) => , header: 'IP pool', + cell: (info) => , }), colHelper.accessor('instanceId', { - cell: (info) => , header: 'Attached to instance', + cell: (info) => , }), ] @@ -117,19 +108,19 @@ export function FloatingIpsPage() { const navigate = useNavigate() const { mutateAsync: floatingIpDetach } = useApiMutation('floatingIpDetach', { - onSuccess() { + onSuccess(floatingIp) { queryClient.invalidateQueries('floatingIpList') - addToast({ content: 'Your floating IP has been detached' }) + addToast(<>Floating IP {floatingIp.name} detached) // prettier-ignore }, onError: (err) => { addToast({ title: 'Error', content: err.message, variant: 'error' }) }, }) const { mutateAsync: deleteFloatingIp } = useApiMutation('floatingIpDelete', { - onSuccess() { + onSuccess(_data, variables) { queryClient.invalidateQueries('floatingIpList') queryClient.invalidateQueries('ipPoolUtilizationView') - addToast({ content: 'Your floating IP has been deleted' }) + addToast(<>Floating IP {variables.path.floatingIp} deleted) // prettier-ignore }, }) @@ -259,9 +250,9 @@ const AttachFloatingIpModal = ({ }) => { const queryClient = useApiQueryClient() const floatingIpAttach = useApiMutation('floatingIpAttach', { - onSuccess() { + onSuccess(floatingIp) { queryClient.invalidateQueries('floatingIpList') - addToast({ content: 'Your floating IP has been attached' }) + addToast(<>Floating IP {floatingIp.name} attached) // prettier-ignore onDismiss() }, onError: (err) => { diff --git a/app/pages/project/images/ImagesPage.tsx b/app/pages/project/images/ImagesPage.tsx index 726357a6f8..cf5c43dee2 100644 --- a/app/pages/project/images/ImagesPage.tsx +++ b/app/pages/project/images/ImagesPage.tsx @@ -13,6 +13,7 @@ import { apiQueryClient, useApiMutation, useApiQueryClient, type Image } from '@ import { Images16Icon, Images24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' @@ -58,7 +59,7 @@ export function ImagesPage() { const { mutateAsync: deleteImage } = useApiMutation('imageDelete', { onSuccess(_data, variables) { - addToast({ content: `${variables.path.image} has been deleted` }) + addToast(<>Image {variables.path.image} deleted) // prettier-ignore queryClient.invalidateQueries('imageList') }, }) @@ -131,7 +132,11 @@ const PromoteImageModal = ({ onDismiss, imageName }: PromoteModalProps) => { const promoteImage = useApiMutation('imagePromote', { onSuccess(data) { addToast({ - content: `${data.name} has been promoted`, + content: ( + <> + Image {data.name} promoted + + ), cta: { text: 'View silo images', link: '/images', diff --git a/app/pages/project/instances/InstancesPage.tsx b/app/pages/project/instances/InstancesPage.tsx index 9fce0627a1..884d005c8b 100644 --- a/app/pages/project/instances/InstancesPage.tsx +++ b/app/pages/project/instances/InstancesPage.tsx @@ -67,7 +67,7 @@ export function InstancesPage() { const { project } = useProjectSelector() const [resizeInstance, setResizeInstance] = useState(null) - const makeActions = useMakeInstanceActions( + const { makeButtonActions, makeMenuActions } = useMakeInstanceActions( { project }, { onSuccess: refetchInstances, @@ -188,9 +188,12 @@ export function InstancesPage() { } ), colHelper.accessor('timeCreated', Columns.timeCreated), - getActionsCol(makeActions), + getActionsCol((instance: Instance) => [ + ...makeButtonActions(instance), + ...makeMenuActions(instance), + ]), ], - [project, makeActions] + [project, makeButtonActions, makeMenuActions] ) if (!instances) return null diff --git a/app/pages/project/instances/actions.tsx b/app/pages/project/instances/actions.tsx index 37e59a4757..cc9698a998 100644 --- a/app/pages/project/instances/actions.tsx +++ b/app/pages/project/instances/actions.tsx @@ -13,7 +13,6 @@ import { HL } from '~/components/HL' import { confirmAction } from '~/stores/confirm-action' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' -import type { MakeActions } from '~/table/columns/action-col' import { fancifyStates } from './instance/tabs/common' @@ -30,7 +29,7 @@ type Options = { export const useMakeInstanceActions = ( { project }: { project: string }, options: Options = {} -): MakeActions => { +) => { // if you also pass onSuccess to mutate(), this one is not overridden — this // one runs first, then the one passed to mutate(). // @@ -38,7 +37,7 @@ export const useMakeInstanceActions = ( // while the whole useMutation result object is not. The async ones are used // when we need to confirm because the confirm modals want that. const opts = { onSuccess: options.onSuccess } - const { mutate: startInstance } = useApiMutation('instanceStart', opts) + const { mutateAsync: startInstanceAsync } = useApiMutation('instanceStart', opts) const { mutateAsync: stopInstanceAsync } = useApiMutation('instanceStop', opts) const { mutate: rebootInstance } = useApiMutation('instanceReboot', opts) // delete has its own @@ -46,21 +45,32 @@ export const useMakeInstanceActions = ( onSuccess: options.onDelete, }) - return useCallback( - (instance) => { + const makeButtonActions = useCallback( + (instance: Instance) => { const instanceParams = { path: { instance: instance.name }, query: { project } } return [ { label: 'Start', onActivate() { - startInstance(instanceParams, { - onSuccess: () => addToast({ title: `Starting instance '${instance.name}'` }), - onError: (error) => - addToast({ - variant: 'error', - title: `Error starting instance '${instance.name}'`, - content: error.message, + confirmAction({ + actionType: 'primary', + doAction: () => + startInstanceAsync(instanceParams, { + onSuccess: () => addToast(<>Starting instance {instance.name}), // prettier-ignore + onError: (error) => + addToast({ + variant: 'error', + title: `Error starting instance '${instance.name}'`, + content: error.message, + }), }), + modalTitle: 'Confirm start instance', + modalContent: ( +

+ Are you sure you want to start {instance.name}? +

+ ), + errorTitle: `Error starting ${instance.name}`, }) }, disabled: !instanceCan.start(instance) && ( @@ -75,7 +85,7 @@ export const useMakeInstanceActions = ( doAction: () => stopInstanceAsync(instanceParams, { onSuccess: () => - addToast({ title: `Stopping instance '${instance.name}'` }), + addToast(<>Stopping instance {instance.name}), // prettier-ignore }), modalTitle: 'Confirm stop instance', modalContent: ( @@ -93,14 +103,25 @@ export const useMakeInstanceActions = ( }) }, disabled: !instanceCan.stop(instance) && ( - <>Only {fancifyStates(instanceCan.stop.states)} instances can be stopped + // don't list all the states, it's overwhelming + <>Only {fancifyStates(['running'])} instances can be stopped ), }, + ] + }, + [project, startInstanceAsync, stopInstanceAsync] + ) + + const makeMenuActions = useCallback( + (instance: Instance) => { + const instanceParams = { path: { instance: instance.name }, query: { project } } + return [ { label: 'Reboot', onActivate() { rebootInstance(instanceParams, { - onSuccess: () => addToast({ title: `Rebooting instance '${instance.name}'` }), + onSuccess: () => + addToast(<>Rebooting instance {instance.name}), // prettier-ignore onError: (error) => addToast({ variant: 'error', @@ -130,7 +151,7 @@ export const useMakeInstanceActions = ( doDelete: () => deleteInstanceAsync(instanceParams, { onSuccess: () => - addToast({ title: `Deleting instance '${instance.name}'` }), + addToast(<>Deleting instance {instance.name}), // prettier-ignore }), label: instance.name, resourceKind: 'instance', @@ -143,13 +164,8 @@ export const useMakeInstanceActions = ( }, ] }, - [ - project, - deleteInstanceAsync, - rebootInstance, - startInstance, - stopInstanceAsync, - options, - ] + [project, deleteInstanceAsync, rebootInstance, options] ) + + return { makeButtonActions, makeMenuActions } } diff --git a/app/pages/project/instances/instance/InstancePage.tsx b/app/pages/project/instances/instance/InstancePage.tsx index 6a892136c4..92ae068360 100644 --- a/app/pages/project/instances/instance/InstancePage.tsx +++ b/app/pages/project/instances/instance/InstancePage.tsx @@ -36,6 +36,7 @@ import { InstanceStateBadge } from '~/components/StateBadge' import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { EmptyCell } from '~/table/cells/EmptyCell' +import { Button } from '~/ui/lib/Button' import { DateTime } from '~/ui/lib/DateTime' import { Message } from '~/ui/lib/Message' import { Modal } from '~/ui/lib/Modal' @@ -106,7 +107,8 @@ export function InstancePage() { const [resizeInstance, setResizeInstance] = useState(false) const navigate = useNavigate() - const makeActions = useMakeInstanceActions(instanceSelector, { + + const { makeButtonActions, makeMenuActions } = useMakeInstanceActions(instanceSelector, { onSuccess: refreshData, // go to project instances list since there's no more instance onDelete: () => { @@ -147,7 +149,7 @@ export function InstancePage() { { enabled: !!primaryVpcId } ) - const actions = useMemo( + const allMenuActions = useMemo( () => [ { label: 'Copy ID', @@ -155,9 +157,9 @@ export function InstancePage() { window.navigator.clipboard.writeText(instance.id || '') }, }, - ...makeActions(instance), + ...makeMenuActions(instance), ], - [instance, makeActions] + [instance, makeMenuActions] ) const memory = filesize(instance.memory, { output: 'object', base: 2 }) @@ -167,9 +169,23 @@ export function InstancePage() { }>{instance.name}
- - + +
+ {makeButtonActions(instance).map((action) => ( + + ))} +
+
@@ -197,7 +213,7 @@ export function InstancePage() { {vpc ? ( {vpc.name} diff --git a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx index 0082f7cfc4..d1d8c11431 100644 --- a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx +++ b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx @@ -202,9 +202,9 @@ export function NetworkingTab() { }, }) const { mutateAsync: deleteNic } = useApiMutation('instanceNetworkInterfaceDelete', { - onSuccess() { + onSuccess(_data, variables) { queryClient.invalidateQueries('instanceNetworkInterfaceList') - addToast({ content: 'Network interface deleted' }) + addToast(<>Network interface {variables.path.interface} deleted) // prettier-ignore }, }) const { mutate: editNic } = useApiMutation('instanceNetworkInterfaceUpdate', { @@ -297,7 +297,7 @@ export function NetworkingTab() { const { mutateAsync: ephemeralIpDetach } = useApiMutation('instanceEphemeralIpDetach', { onSuccess() { queryClient.invalidateQueries('instanceExternalIpList') - addToast({ content: 'Your ephemeral IP has been detached' }) + addToast({ content: 'Ephemeral IP detached' }) }, onError: (err) => { addToast({ title: 'Error', content: err.message, variant: 'error' }) @@ -305,10 +305,10 @@ export function NetworkingTab() { }) const { mutateAsync: floatingIpDetach } = useApiMutation('floatingIpDetach', { - onSuccess() { + onSuccess(_data, variables) { queryClient.invalidateQueries('floatingIpList') queryClient.invalidateQueries('instanceExternalIpList') - addToast({ content: 'Your floating IP has been detached' }) + addToast(<>Floating IP {variables.path.floatingIp} detached) // prettier-ignore }, onError: (err) => { addToast({ title: 'Error', content: err.message, variant: 'error' }) diff --git a/app/pages/project/instances/instance/tabs/StorageTab.tsx b/app/pages/project/instances/instance/tabs/StorageTab.tsx index ec39b1d1c7..ad4873092a 100644 --- a/app/pages/project/instances/instance/tabs/StorageTab.tsx +++ b/app/pages/project/instances/instance/tabs/StorageTab.tsx @@ -87,9 +87,9 @@ export function StorageTab() { ) const { mutate: detachDisk } = useApiMutation('instanceDiskDetach', { - onSuccess() { + onSuccess(disk) { queryClient.invalidateQueries('instanceDiskList') - addToast({ content: 'Disk detached' }) + addToast(<>Disk {disk.name} detached) // prettier-ignore }, onError(err) { addToast({ @@ -100,9 +100,9 @@ export function StorageTab() { }, }) const { mutate: createSnapshot } = useApiMutation('snapshotCreate', { - onSuccess() { + onSuccess(snapshot) { queryClient.invalidateQueries('snapshotList') - addToast({ content: 'Snapshot created' }) + addToast(<>Snapshot {snapshot.name} created) // prettier-ignore }, onError(err) { addToast({ @@ -165,7 +165,13 @@ export function StorageTab() { doAction: () => instanceUpdate({ path: { instance: instance.id }, - body: { bootDisk: undefined, ncpus, memory }, + body: { + bootDisk: undefined, + ncpus, + memory, + // this would get unset if we left it out + autoRestartPolicy: instance.autoRestartPolicy, + }, }), errorTitle: 'Could not unset boot disk', modalTitle: 'Confirm unset boot disk', @@ -193,7 +199,7 @@ export function StorageTab() { onActivate() {}, // it's always disabled, so noop is ok }, ], - [instanceUpdate, instance.id, getSnapshotAction, ncpus, memory] + [instanceUpdate, instance, getSnapshotAction, ncpus, memory] ) const makeOtherDiskActions = useCallback( @@ -214,7 +220,13 @@ export function StorageTab() { doAction: () => instanceUpdate({ path: { instance: instance.id }, - body: { bootDisk: disk.id, ncpus, memory }, + body: { + bootDisk: disk.id, + ncpus, + memory, + // this would get unset if we left it out + autoRestartPolicy: instance.autoRestartPolicy, + }, }), errorTitle: `Could not ${verb} boot disk`, modalTitle: `Confirm ${verb} boot disk`, @@ -249,7 +261,7 @@ export function StorageTab() { }, }, ], - [detachDisk, instanceUpdate, instance.id, getSnapshotAction, bootDisks, ncpus, memory] + [detachDisk, instanceUpdate, instance, getSnapshotAction, bootDisks, ncpus, memory] ) const attachDisk = useApiMutation('instanceDiskAttach', { diff --git a/app/pages/project/vpcs/RouterPage.tsx b/app/pages/project/vpcs/RouterPage.tsx index b91ae862a5..1374602b31 100644 --- a/app/pages/project/vpcs/RouterPage.tsx +++ b/app/pages/project/vpcs/RouterPage.tsx @@ -90,7 +90,8 @@ export function RouterPage() { const { mutateAsync: deleteRouterRoute } = useApiMutation('vpcRouterRouteDelete', { onSuccess() { apiQueryClient.invalidateQueries('vpcRouterRouteList') - addToast({ content: 'Your route has been deleted' }) + // We only have the ID, so will show a generic confirmation message + addToast({ content: 'Route deleted' }) }, }) diff --git a/app/pages/project/vpcs/VpcPage/VpcPage.tsx b/app/pages/project/vpcs/VpcPage/VpcPage.tsx index 5c5c5d912a..97adda95eb 100644 --- a/app/pages/project/vpcs/VpcPage/VpcPage.tsx +++ b/app/pages/project/vpcs/VpcPage/VpcPage.tsx @@ -16,6 +16,7 @@ import { } from '@oxide/api' import { Networking24Icon } from '@oxide/design-system/icons/react' +import { HL } from '~/components/HL' import { MoreActionsMenu } from '~/components/MoreActionsMenu' import { RouteTabs, Tab } from '~/components/RouteTabs' import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' @@ -46,10 +47,10 @@ export function VpcPage() { }) const { mutateAsync: deleteVpc } = useApiMutation('vpcDelete', { - onSuccess() { + onSuccess(_data, variables) { queryClient.invalidateQueries('vpcList') navigate(pb.vpcs({ project })) - addToast({ content: 'Your VPC has been deleted' }) + addToast(<>VPC {variables.path.vpc} deleted) // prettier-ignore }, }) diff --git a/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx b/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx index 361d7a4921..cd411c4c90 100644 --- a/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx +++ b/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx @@ -11,6 +11,7 @@ import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router-dom' import { apiQueryClient, useApiMutation, type VpcRouter } from '@oxide/api' +import { HL } from '~/components/HL' import { routeFormMessage } from '~/forms/vpc-router-route-common' import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' import { confirmDelete } from '~/stores/confirm-delete' @@ -62,9 +63,9 @@ export function VpcRoutersTab() { ) const { mutateAsync: deleteRouter } = useApiMutation('vpcRouterDelete', { - onSuccess() { + onSuccess(_data, variables) { apiQueryClient.invalidateQueries('vpcRouterList') - addToast({ content: 'Your router has been deleted' }) + addToast(<>Router {variables.path.router} deleted) // prettier-ignore }, }) diff --git a/app/pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab.tsx b/app/pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab.tsx index 285bb2b82c..0dcb974a19 100644 --- a/app/pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab.tsx +++ b/app/pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab.tsx @@ -18,6 +18,7 @@ import { import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' import { makeLinkCell } from '~/table/cells/LinkCell' import { RouterLinkCell } from '~/table/cells/RouterLinkCell' import { TwoLineCell } from '~/table/cells/TwoLineCell' @@ -47,6 +48,8 @@ export function VpcSubnetsTab() { const { mutateAsync: deleteSubnet } = useApiMutation('vpcSubnetDelete', { onSuccess() { queryClient.invalidateQueries('vpcSubnetList') + // We only have the ID, so will show a generic confirmation message + addToast({ content: 'Subnet deleted' }) }, }) diff --git a/app/pages/project/vpcs/VpcsPage.tsx b/app/pages/project/vpcs/VpcsPage.tsx index e5ce773f28..69df4371e3 100644 --- a/app/pages/project/vpcs/VpcsPage.tsx +++ b/app/pages/project/vpcs/VpcsPage.tsx @@ -20,6 +20,7 @@ import { import { Networking16Icon, Networking24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { useQuickActions } from '~/hooks/use-quick-actions' import { confirmDelete } from '~/stores/confirm-delete' @@ -83,9 +84,9 @@ export function VpcsPage() { const navigate = useNavigate() const { mutateAsync: deleteVpc } = useApiMutation('vpcDelete', { - onSuccess() { + onSuccess(_data, variables) { queryClient.invalidateQueries('vpcList') - addToast({ content: 'Your VPC has been deleted' }) + addToast(<>VPC {variables.path.vpc} deleted) // prettier-ignore }, }) diff --git a/app/pages/settings/SSHKeysPage.tsx b/app/pages/settings/SSHKeysPage.tsx index 97ad48f883..3b2fd881c8 100644 --- a/app/pages/settings/SSHKeysPage.tsx +++ b/app/pages/settings/SSHKeysPage.tsx @@ -13,6 +13,7 @@ import { apiQueryClient, useApiMutation, useApiQueryClient, type SshKey } from ' import { Key16Icon, Key24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' @@ -46,9 +47,9 @@ export function SSHKeysPage() { const queryClient = useApiQueryClient() const { mutateAsync: deleteSshKey } = useApiMutation('currentUserSshKeyDelete', { - onSuccess: () => { + onSuccess: (_data, variables) => { queryClient.invalidateQueries('currentUserSshKeyList') - addToast({ content: 'Your SSH key has been deleted' }) + addToast(<>SSH key {variables.path.sshKey} deleted) // prettier-ignore }, }) diff --git a/app/pages/system/SiloImagesPage.tsx b/app/pages/system/SiloImagesPage.tsx index 45bc82f54a..2346153936 100644 --- a/app/pages/system/SiloImagesPage.tsx +++ b/app/pages/system/SiloImagesPage.tsx @@ -21,8 +21,9 @@ import { Images16Icon, Images24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' import { ComboboxField } from '~/components/form/fields/ComboboxField' -import { toListboxItem } from '~/components/form/fields/ImageSelectField' +import { toImageComboboxItem } from '~/components/form/fields/ImageSelectField' import { ListboxField } from '~/components/form/fields/ListboxField' +import { HL } from '~/components/HL' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { makeLinkCell } from '~/table/cells/LinkCell' @@ -30,6 +31,7 @@ import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' import { PAGE_SIZE, useQueryTable } from '~/table/QueryTable' import { Button } from '~/ui/lib/Button' +import { toComboboxItems } from '~/ui/lib/Combobox' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { Message } from '~/ui/lib/Message' import { Modal } from '~/ui/lib/Modal' @@ -71,7 +73,7 @@ export function SiloImagesPage() { const queryClient = useApiQueryClient() const { mutateAsync: deleteImage } = useApiMutation('imageDelete', { onSuccess(_data, variables) { - addToast({ content: `${variables.path.image} has been deleted` }) + addToast(<>Image {variables.path.image} deleted) // prettier-ignore queryClient.invalidateQueries('imageList') }, }) @@ -130,7 +132,7 @@ const PromoteImageModal = ({ onDismiss }: { onDismiss: () => void }) => { const promoteImage = useApiMutation('imagePromote', { onSuccess(data) { - addToast({ content: `${data.name} has been promoted` }) + addToast(<>Image {data.name} promoted) // prettier-ignore queryClient.invalidateQueries('imageList') }, onError: (err) => { @@ -140,10 +142,7 @@ const PromoteImageModal = ({ onDismiss }: { onDismiss: () => void }) => { }) const projects = useApiQuery('projectList', {}) - const projectItems = useMemo( - () => (projects.data?.items || []).map(({ name }) => ({ value: name, label: name })), - [projects.data] - ) + const projectItems = useMemo(() => toComboboxItems(projects.data?.items), [projects.data]) const selectedProject = watch('project') // can only fetch images if a project is selected @@ -153,7 +152,7 @@ const PromoteImageModal = ({ onDismiss }: { onDismiss: () => void }) => { { enabled: !!selectedProject } ) const imageItems = useMemo( - () => (images.data?.items || []).map((i) => toListboxItem(i)), + () => (images.data?.items || []).map((i) => toImageComboboxItem(i)), [images.data] ) @@ -220,7 +219,11 @@ const DemoteImageModal = ({ const demoteImage = useApiMutation('imageDemote', { onSuccess(data) { addToast({ - content: `${data.name} has been demoted`, + content: ( + <> + Image {data.name} demoted + + ), cta: selectedProject ? { text: `View images in ${selectedProject}`, @@ -242,10 +245,7 @@ const DemoteImageModal = ({ } const projects = useApiQuery('projectList', {}) - const projectItems = useMemo( - () => (projects.data?.items || []).map(({ name }) => ({ value: name, label: name })), - [projects.data] - ) + const projectItems = useMemo(() => toComboboxItems(projects.data?.items), [projects.data]) return ( diff --git a/app/pages/system/networking/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx index 2db3b07bce..a5c44487e7 100644 --- a/app/pages/system/networking/IpPoolPage.tsx +++ b/app/pages/system/networking/IpPoolPage.tsx @@ -39,6 +39,7 @@ import { LinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' import { PAGE_SIZE, useQueryTable } from '~/table/QueryTable' +import { toComboboxItems } from '~/ui/lib/Combobox' import { CreateButton, CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { Message } from '~/ui/lib/Message' @@ -81,10 +82,10 @@ export function IpPoolPage() { }) const navigate = useNavigate() const { mutateAsync: deletePool } = useApiMutation('ipPoolDelete', { - onSuccess() { + onSuccess(_data, variables) { apiQueryClient.invalidateQueries('ipPoolList') navigate(pb.ipPools()) - addToast({ content: 'IP pool deleted' }) + addToast(<>Pool {variables.path.pool} deleted) // prettier-ignore }, }) @@ -387,9 +388,7 @@ function LinkSiloModal({ onDismiss }: { onDismiss: () => void }) { const unlinkedSiloItems = useMemo( () => allSilos.data && linkedSiloIds - ? allSilos.data.items - .filter((s) => !linkedSiloIds.has(s.id)) - .map((s) => ({ value: s.name, label: s.name })) + ? toComboboxItems(allSilos.data.items.filter((s) => !linkedSiloIds.has(s.id))) : [], [allSilos, linkedSiloIds] ) diff --git a/app/pages/system/networking/IpPoolsPage.tsx b/app/pages/system/networking/IpPoolsPage.tsx index 8084dc77f7..eaa8fcf386 100644 --- a/app/pages/system/networking/IpPoolsPage.tsx +++ b/app/pages/system/networking/IpPoolsPage.tsx @@ -20,6 +20,7 @@ import { import { IpGlobal16Icon, IpGlobal24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' import { IpUtilCell } from '~/components/IpPoolUtilization' import { useQuickActions } from '~/hooks/use-quick-actions' import { confirmDelete } from '~/stores/confirm-delete' @@ -78,9 +79,9 @@ export function IpPoolsPage() { }) const { mutateAsync: deletePool } = useApiMutation('ipPoolDelete', { - onSuccess() { + onSuccess(_data, variables) { apiQueryClient.invalidateQueries('ipPoolList') - addToast({ content: 'IP pool deleted' }) + addToast(<>Pool {variables.path.pool} deleted) // prettier-ignore }, }) diff --git a/app/pages/system/silos/SiloIpPoolsTab.tsx b/app/pages/system/silos/SiloIpPoolsTab.tsx index a03f59f1c3..b19a51ba88 100644 --- a/app/pages/system/silos/SiloIpPoolsTab.tsx +++ b/app/pages/system/silos/SiloIpPoolsTab.tsx @@ -23,6 +23,7 @@ import { makeLinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' import { useQueryTable } from '~/table/QueryTable' +import { toComboboxItems } from '~/ui/lib/Combobox' import { CreateButton } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { Message } from '~/ui/lib/Message' @@ -80,6 +81,8 @@ export function SiloIpPoolsTab() { const { mutateAsync: unlinkPool } = useApiMutation('ipPoolSiloUnlink', { onSuccess() { queryClient.invalidateQueries('siloIpPoolList') + // We only have the ID, so will show a generic confirmation message + addToast({ content: 'IP pool unlinked' }) }, }) @@ -213,9 +216,7 @@ function LinkPoolModal({ onDismiss }: { onDismiss: () => void }) { const unlinkedPoolItems = useMemo( () => allPools.data && linkedPoolIds - ? allPools.data.items - .filter((p) => !linkedPoolIds.has(p.id)) - .map((p) => ({ value: p.name, label: p.name })) + ? toComboboxItems(allPools.data.items.filter((p) => !linkedPoolIds.has(p.id))) : [], [allPools, linkedPoolIds] ) diff --git a/app/pages/system/silos/SiloQuotasTab.tsx b/app/pages/system/silos/SiloQuotasTab.tsx index 14df8fbb74..8037974cd1 100644 --- a/app/pages/system/silos/SiloQuotasTab.tsx +++ b/app/pages/system/silos/SiloQuotasTab.tsx @@ -18,6 +18,7 @@ import { import { NumberField } from '~/components/form/fields/NumberField' import { SideModalForm } from '~/components/form/SideModalForm' import { useSiloSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' import { Button } from '~/ui/lib/Button' import { Message } from '~/ui/lib/Message' import { Table } from '~/ui/lib/Table' @@ -106,6 +107,7 @@ function EditQuotasForm({ onDismiss }: { onDismiss: () => void }) { const updateQuotas = useApiMutation('siloQuotasUpdate', { onSuccess() { apiQueryClient.invalidateQueries('siloUtilizationView') + addToast({ content: 'Quotas updated' }) onDismiss() }, }) diff --git a/app/pages/system/silos/SilosPage.tsx b/app/pages/system/silos/SilosPage.tsx index 6fbec47227..7f10b98449 100644 --- a/app/pages/system/silos/SilosPage.tsx +++ b/app/pages/system/silos/SilosPage.tsx @@ -19,8 +19,10 @@ import { import { Cloud16Icon, Cloud24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' import { useQuickActions } from '~/hooks/use-quick-actions' import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' import { BooleanCell } from '~/table/cells/BooleanCell' import { makeLinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' @@ -76,8 +78,9 @@ export function SilosPage() { }) const { mutateAsync: deleteSilo } = useApiMutation('siloDelete', { - onSuccess() { + onSuccess(silo, { path }) { queryClient.invalidateQueries('siloList') + addToast(<>Silo {path.silo} deleted) // prettier-ignore }, }) diff --git a/app/stores/toast.ts b/app/stores/toast.ts index ea06db7213..6bf3c4f5e8 100644 --- a/app/stores/toast.ts +++ b/app/stores/toast.ts @@ -5,6 +5,7 @@ * * Copyright Oxide Computer Company */ +import { type ReactElement } from 'react' import { v4 as uuid } from 'uuid' import { create } from 'zustand' @@ -17,9 +18,18 @@ type Toast = { export const useToastStore = create<{ toasts: Toast[] }>(() => ({ toasts: [] })) -export function addToast(options: Toast['options']) { +/** + * If argument is `ReactElement | string`, use it directly as `{ content }`. + * Otherwise it's a config object. + */ +export function addToast(optionsOrContent: Toast['options'] | ReactElement | string) { + const options = + typeof optionsOrContent === 'object' && 'content' in optionsOrContent + ? optionsOrContent + : { content: optionsOrContent } useToastStore.setState(({ toasts }) => ({ toasts: [...toasts, { id: uuid(), options }] })) } + export function removeToast(id: Toast['id']) { useToastStore.setState(({ toasts }) => ({ toasts: toasts.filter((t) => t.id !== id) })) } diff --git a/app/table/cells/IpPoolCell.tsx b/app/table/cells/IpPoolCell.tsx new file mode 100644 index 0000000000..f15a03fd87 --- /dev/null +++ b/app/table/cells/IpPoolCell.tsx @@ -0,0 +1,21 @@ +/* + * 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 { useApiQuery } from '~/api' +import { Tooltip } from '~/ui/lib/Tooltip' + +import { EmptyCell } from './EmptyCell' + +export const IpPoolCell = ({ ipPoolId }: { ipPoolId: string }) => { + const pool = useApiQuery('projectIpPoolView', { path: { pool: ipPoolId } }).data + if (!pool) return + return ( + + {pool.name} + + ) +} diff --git a/app/table/cells/LinkCell.tsx b/app/table/cells/LinkCell.tsx index 13e059663f..5e2b74227c 100644 --- a/app/table/cells/LinkCell.tsx +++ b/app/table/cells/LinkCell.tsx @@ -10,8 +10,7 @@ import { Link } from 'react-router-dom' import { classed } from '~/util/classed' -const linkClass = - 'link-with-underline group flex h-full w-full items-center text-sans-semi-md' +const linkClass = 'link-with-underline group flex h-full w-full items-center text-sans-md' /** Pushes out the link area to the entire cell for improved clickability™ */ const Pusher = classed.div`absolute inset-0 right-px group-hover:bg-raise` diff --git a/app/table/columns/action-col.tsx b/app/table/columns/action-col.tsx index f18d16a04a..a880245b45 100644 --- a/app/table/columns/action-col.tsx +++ b/app/table/columns/action-col.tsx @@ -16,7 +16,7 @@ import { Tooltip } from '~/ui/lib/Tooltip' import { Wrap } from '~/ui/util/wrap' import { kebabCase } from '~/util/str' -export type MakeActions = (item: Item) => Array +type MakeActions = (item: Item) => Array export type MenuAction = { label: string diff --git a/app/ui/assets/fonts/SuisseIntl-Book-WebS.woff b/app/ui/assets/fonts/SuisseIntl-Book-WebS.woff deleted file mode 100644 index 8629bb9a5b7aec46b9ac5b3f40a4b0b958917018..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21937 zcmXVV19T=$)ApTYV{hza<7{l(wr$(CZD(WKwr$(C_2qg0zt3E!PhVZtQ&rPvx~9fO zMpzgC1o&x6wE%?w`c_2%Fwg@4phyM)0EGbnAU^-G|1}X=83h19Oceltng#%fsR8>~ zdol_%^gnV3Khw%T&<)Sm6ELvSvjqU)7k>Cf001~SOfss>z}XQW06=2=kszL z$kfov6#)1(0suh6005YdNm$uWrbc>(KkHcj=rR8X$PTP~(;xDOEBG1X|9}X>>(_;; zmE(^tFy{}#0RYgz6C2t)OB;h9Iiw%ofUABma8V5-S-+7$O+ku(hL8Z@|5yOP705u>P*=C>@tOz( z#NyZ|;v2OBgBKCGClvyazBLb$CsN91m!<2K!M>0+?|$c^T{#V z)8iR2tLf*bM=u&J7Jf<*E3dl)1Xvk||A~q#kW(T^1+f-F;Q2#+XOmS1n#KB)umy+u zdJ{_Jq|fAb)8*>fM3&*!Ad$`)>#?Fs%qz;%vy-pR%*;!y3LK$=`#;r%95V9bI5dtC z9JK)6wHm6@^yp&Hqf}{zf&NxJ;n57}oatb9pvI?v` zT>swLnh*vDpyNzTsp=828Y4ZFHNHm}w`}Oe(@{-D;wKr9nQuo38(q8oZbX%kQ~U-3 z_KG0o3t85+U2n?uKdJ4UIm{uK-Ni%_XS@npFa)G>n*{tB1(5E!@M#bU%|e;*fRC_{ z>5>p*d}hPaFJ366PHzRzbr?Y1^3LvYF+}Bfq<9YKvf|A4VnvpBWe(yTkqcBt&lFhm zkScT*IUXmYMjNW;I2KI~7~JqqqiuR=aq|@y+cisE_FcEIT^Fit1#t?L8$TTcuCIP^ zcJOK6Ob%jy^RI+F?G?`nu$&RxkK~_u*LN*P9YuA~tbphr$7STi8XqS;D)r>#l#VVm z`#VsfQ2{+p6S(uo#z^B*NOObj2eGzz!3jGoM-BBx9n}Z?MRmsB$b>v-N^$i+!4dRW z3Qwu6Gk8k(l=LP>%T&i_X~4aK<}AvJmTOP=J+idIF6f1eX;6tGAKI{#4ml(#;=dH9 zAc3go)jGQ&o9XTlyKHLuRm?LjJE155MIM?ni|}^678P6o=WYBBXblXni+OId`J`No zD@zR$T*c90R}@!;at_rO#7EbD$bat=kvmV$i`BY{_~kadH*s&-_2`I@uU!mogB9N;E2AzZ*v2X-Ojyco(RUJ zgX2?=qFNQGVo=SBSC)a{2>}nRIn+*G@fRu@RY~GAQU68QYEalyO@Od&w`n4RALP*Q zcwXtkahGCJHr*%r@<)>^5x1i=A9}`)#w{xMz2YRUYn!g0W>m^zE&og7yZ^Pq(PW)p zmesWBE(<(y7`sHFwydtc#6VoG>P`f-C-2Da{!Pz&Hkcjv&{A1)gX!J9=Op!~!7V;d zr(HEaxT`z{rJH+d9p$b6?eIEUVI%zNC||(}E_$1zsqky{){(4><7?X3>!fLlP!FBi z+m2mq)NK9goBKI~0HuHn$nijq?*VsB+R+=^wEVYvlpPV)^zX!5gM5MT;+< zXZZvj>loh$-6hk~&v*P_I9GmCT$pFkC(%==+_l6H3!E``idOT!i51-NC2|(V8%dBJ zmsR#bd;y`^9pJ8o0j1Bk9HqpDj?ZK{tAEtxx>e5W18kTmkh+##-t(;1Ab8L-!A^?8 z!Ym6EOt7#U2|kS6xV3_sRkhUnPsgq!-qW`M-w2SiiuE_u|~tcJw& z{N?HA!paBezi+N&&8j*~Rt!xE1gY$kO?y{&Z;6E0wyUyTitN`EWG_GOm8K|PFuafZ zhPGfm9|m%K{I(e0_1K4UzeIV@w*#lr@?fr-9))0-;;|#@287Y=`-q~`%)Gpsuco&1 zcrag;*Lp1I3xQ=Ut_c+K+_zW0g&op~iu;veLM2ebGA~*cvY6Rx0?*D^WYD zUZRzg>BflobI}!<0{8jEiFXr@_HB+e*~Vpa&G<1Ys&^hJ%BuP4P{QEdvf>r`)6z5c zYN#47dT4-9dzgSjA0>G#hPd0i&ln`>@4U4Y z(R*yQjkH}-?v{}Fbd=@sq9dm1=agD59B$sWorYoa9o&Ly9}YD+Eci8IV`+!3JqCs4 z<8<)YOfpwjB4^$QT8)SGOie3NPkX5Y(=TtYzaPOF&Y8YHEhn674GbPBTL|vI$E)BE zo7{+@deU2?LxmrPsK@HKe6BA)buO|&=k5zxRqY9PUGO&SwxZ;Jv!pY7{;WBCRxhNwUm24y0 zgxUljeX^m`0NLgdkhM|=_-SJUS-c&r>A^lk2l0D#8;JB_ydRzKBw^KXTM`pg_OtPS zT>Ai5>*(*4+!piEd(u?Nq#VwSxbAFG??SH6!Tt4ivG3Hd!P+KpYTW#!6mwY0^y$ElZ!BnF&H=JXts($O z6AfopJD-gy`yiMp6_Yh`8d&rXLPMc7*!C*5`}62cHpT!xI#dP-c_D#3#4_=W6n&U; zw0r=jL7nm(;nYhYR7%MxX%#5g$jejBNa`r|)JOC#DOQ>|MId@!R9vlPN+PWpkJ43w z&N*^%CV!YlxEw^k=M6z90=k1G^uNr9CRPJbB#?W#No&h%)v2N*N<`K~_BhVY=|Pru z_@15+5^#7DbjB2tcu4cZpXlW-y`=)wNd1rItPnW14{tD@vJK+JqWh?P<;M_-3$LN@ z9WAVCK{-OA2@ArTF+-18*0u+D~X!9hH`^pXbgGu{EoG&SyUTKt&LsSYe>W%*0R200uEuH zB$`H%m&|2zA41?KVne{9O1D57UAO^LAVlw;xKSHwTqkfMb0?#Vh0rmgl;tM$Otw3r zSSe}~$FP`e7-b5S!HCGNeTlstf8O;cFC*`sQkfbK!>T$@br=QJ@VzI;o^PNet}GW- zU|+7ZRG5_LT$ZN=jk6z9JB)O?W9Ua$ypwp)o{Qk4cndXF^uI?hI&vb%*HvSow}MWT zSGdolw-s$ovj@)MN>3?@ZA64E5~ls~4ruBJnv`$}nJcuO%GtdJb>~kc>oEK`Y?YTJ zz6so=*7v8DyA6%avZEl%?Rjd?uTR{nWme@>c!&~@diy(_IyG;r2(O7xydH}5!n~%} zw10}f;?zy+*{iBKqs+pqHEX<;%1K9i*=UJrQKce+?784lDSzglbQk-HgQb%H;`r?jtgj6r?(qx- z{SST+cwKW;u*{VPnyP{S^&K*jE={%4!iCqe`Hb+AaOt@7MsL%CX-cd5D?FTDn$<&T z7UBxD{o})=Rn*X&0h#^9q_@81r^h_0W3gd-y`=TCu&b26g+K#~+IF+N z*JD0YtwZFWPv7sO#cZmp-8-v!y1F*Eu9`3H-d{U5E2#F{s^5*DrApO48}ziio!1{y z&kcq17n}pHj}A6M_vcqXURinj$Ir1^ySa9pH+#E%GM8B1@SnqD>Y9qQ&%jL;kvGHQQ>^W@hBY;#xP2Zr`WB^RNJyvIt&RwQmi3HceNpG)YTx0X{8 zI|g|giTF+xvO|_)I&s3G@J3M#nJnuM8!|7CR^Fp*eG*GdsD(>vX3g{-LuFG!^q{({y93>P90}e2@qXH1VM^3;at^=ukT1 z)YF%xYk-jI_M2WPJ9{Dgia#y+^faMCax|hJ>Fy~pHejA{Eq%dM5Buc+USOzh~ z4cEpF*TyWH@eW2M2?Kp~57N8kKw?7t?t%K$Na~qFG9RiRFqhjGU*r!3@~8QuF^hgn zvq|L%Thx|ugh(~pNNL_Q^;gln(ITj0j&#Vpmg=G~vN}wPDU+viimxjt;6H0D00}>>&Q15>Is=?HU{U?Q2uxsoq?)NdS8^-I5G(hZU>t0 ze+3z2fF6Jopz>33F$Ks2?0*>8Z#e)gFeU&2I1&H@ya<4G1rq87K|cSfWA$jyFF0da zG@Hdoby=-9{lCkp$qGv+hGqXH=ZtKh^^2dTxSwsiwf_|kb&#x}dh_H+2N8+6Yv-S5B4ZZVz#AE_VF9|`vIStZ^JWagNSq3R-+ zc~^6XC+dz2ZAhCjxI#L{<_wdm(vmvHzZlZerk!=)Yx-A5FF2iv+k-Zzv-PX(jao4^ z4XU`7Be2G#c9?Iyxd$CO#!`;T?>SxxKR~~tW#vmK7?IbYxPo%~RP~B#6j#XD2WwK% z{7l1=1}gXXZX;c_Jc+ufyh*;uzW%xuvnIR)M~Dw|a*hrUwT3AKZBVS`FI;%zLk#Erlk;a$nXBSqH!dZ3x8} zB^i|&`3YHiq)k)0iku~mC%HT2n?ipOWsnN0N)SbWym~=?iGm~1V2pI^d2G8(BC0G9 zYO%;%q%nq_f1gR}Z~5X(x+zY>)*7u+03n(lQ%u9azgsRA^bN#g>GU+HN$Q-6!(63tc;)`0avHEeKJ`<I))lt7IniV&HYQ^gFROe2V1g=3T6`-c;ar(jC(fZ7#;N5jz3jQwJp#ugHD z-EL%j_fn>jZ-}kt;hFLwyjeCYsiLIvS>Dzbg}xAm&^ERqxLATZIHME@)}$pQnRke} zF@MxN9*D_b!e`De%LP7g98VrM2_dWuinksq0_%bM$}H`@k2Sa4{W(HtY6WZe;+E&B%VsUiij2*r=|lgxt|`e!3N1lod_Aw*!x zDjk5y3sj@JtHh~$peT9ac*CfGJUH1~1S*42fm=~Ez?o9fPd+^wiqk+*_-RsPYj=p! z$oWPD#L#FoY2?&dHfxr2k7$*&8?IY>%r$L(nSSOUiJWsk5Z!L#+rG;|PQ6tFrZf_jWomy{qQb3|}%YH}`h~cm>c=_JGWzPtkYJP5< z9LB3;CJJakz^=@esKOSe-~e^5$oFAg(HV3E7O-&W;l z)5KB-I3Baxesb~-Z+ZVR6XF{h7Y(b%9CyOQVv}VO`N3R$TpWdtHT&VDYt)6zio1tB zD`BM3#l?oEQjJH%&NYNRrLmHeXB!VDmgriUurgjf_~qN0jzJce|Fp;;$p^i)Ii!E< zjYYS~H`hJ(T!7WBsG!%0;Ar1jXxkYwloOJ_2A?gWzeKJc4S{O}sxdDN|3cs1%(wz{ zIf#}9B8d7ke>JSTE>||IKq65A4vd^Ge%55eo10A3tnQovkji)3yAH%*zpw!-9u(TY zpcq?5OgQ0uzK?Q+c(890%?=#u##~dzUuaY8gSUeEnvTh9Q?;|RboOF4y4F>~iYV7e z;|ELLqa8gTXb0dPWM5&T7RaRnhl5vpLLWIyQHD5TG7JHmAkITGI1sO#5J(UFDS$%N8@9ddb0e_I z9>wyP9^!ry5Qlr0?f_Ct3kCFEB;f;rBwk4g2z5mfJa8P}14_hXkeqro0Ws?k;=QHw zO~i3HYK%Jl&y%2b=e7V3l_@y5Y_3L{lP$Mp(7>BDyyc>qh{D>$YH9&lH_E*+&+JKr zVHE6(h$UTdHf0J|iwdve5!JsQ5(WfJUmhK>L*{~_H1-tID6*gfz9U9~Hn@$r*OM^M z2XE1K!(eHhxmP{>rgn5Ke`j#op!In12e5S|OK>S8ZWN4JSY%Q9AQVi>fFb08ko8P0 zfWkL0xCwbp5hFVgJ+$4;z9VS3n~!-ui@Bx|v+FEGcB5i=CF!b{8(~6-SRsXh*Rx_C z9u5XXv^@?X?=)dL17&usF{!L$b4z9VV;HOrV4k}gOyK!bg#}$Xvfk;lBWy@Bow$Mh z;Ht%LM%2v7yP#$zUtQ@34S*P04r3o|23W)dBmKc~4Yh%|*yM^H;E>z~3S2{i5g3JB z1Qs?W)geY_B#Jztkaw?m(K+es9gZg+`uZMjX8E%5IN3Sv0~iaHV!*iFSf=V98{i+5rd!qz$C+lrjgkdkie9%B%Xok@A3#Zk)OOs;_?Syd0j&s z{Px@@OTg|k2e}65(U}AJ1>l!M!&WX|2FC*f88XU=$^nfE<|l)hIw^ug5gCu#mu(2M zz(_vMjG(*uWcSWKBy(VLNgsGIlB+cY@e%<+tdZDU9G*7di*mrQXymJ9ZQLu?xCa zX|mtsSz#IPHou9dgiQ^@xdaDRK4C;!U$!+(djT$nF)P1HFVc<9z z@+!DVWbj8C3_EcqJM8BCq2^e)py}Cj{zmSksQFMQz5a@Bl1^0%&I_xk%*}%9mQ-?E znhyA3+f-gXmI1PDjVuTltDEX8i=%jfAan1oB`rR; zHzfvI!v4?GTE;;$s{AEAByG zg|)HMr;$Fs=?c@H z8t0zd?ha!Wm80c~e~|#)d@t9sux5U6X@K1P^#_ulWG5I`m*j?nj~QhTe@R6lq9U9u zzY90dn@My1#->pHLxZ!X<;5r-h*mcQ;`^85&!1dIx!EHBK!gsfPUo{2bJKu@cz<8l z++%=Sc6)RrnIN5rofc^AMh%Hl47?z9%(~x%plNb;A67 zU4!ow8AGl2AKq^MJUIRnGI2$!eO9&NrvOKS0e>q;UK!slrvV_LfA;n_5k=KJz<7`j za!Ar|>m(;p^V&=gO}y|3%lq&P8%_8~BJ!Y0gh$w_VOn({VVbS2wGT~^j^4&Pv?>Xl zI|%)#8qN;%8>^uh!My^`J-9cZSh&G-81PNAHl1; z4jyaRr&o9@dD2!yC!wtkwcX+aO<4J#ka^z-(`OItPdsK9u&5bqTS!yaSI)%7(vL?e zBK=_2J^E8uZj%EChai_wG9{Y3em^BGGwa5e8@=&wNN ztr(5?G??YAP1y3Mvd-vZ;&HX8Z^}X&1N69eGlVCaV-mDNfM!)e9yT9>EHTX-ZS?Sg z7ezQz974Va?Q-e~gPj=$S+7VArEtLb2H@xF$5 z`KepcOG-Wzg3%=5DymHk@P{+YBU>0ZiOz$bl7!M!$0WGv5yVYA^jmhie>8kFU3BTW zXHT77BuR?k&b$*f$XW(JQ%ndLrdrxvj)ILl1SCGa#{0ic^#)`=yAp<2#;v%qAT(g1 zxY_u3!abQy{fx#)!MRWlk>)F8L@Lz4!0wX7ibzuGkdRgjM-v~isABL&52HD|4(ne= zp8BoY7O&6N;Pz$i8B~%}@UnLOKm*BH0%j^jyiiQkrFVQ&{@^Z+2!j@hw3Y>At7q%% zSAHkFu*~(pv^=1DH!2QqXnw3^`)E)`t$-0OYY+MnR1!tez>1;3J?9qxE#tOQw?TC)f(BruLT(7-e5`qF zkKx#Cfs@Rh+Br!UauTwA^X&CJnNM?N4B=%)kTmD$X}jGBK9hKxn)#xKP_xF)Qj4cF zHy4FHPV}{jFD+%9en=`M3JF0_fbwaJl(9^-obSL%?4vK;va@Icd(zuz$7QRk^@>0u z?*N^GIVilbOQW*!=1^8DB-JSBWl{7%^Rxob15GbgfByCXcV|Z~)jb_Ek!YH9`@rB6 zHrJh!y?;*%iGgp_(6{a%>xVtQ+T@gmqJL?Rf8A0^p*+h{6cIpCnKO!rrQk2|*BiHj zyMTsnOf^vw{sTRwlkDx+kLK-;{ZQ}!EcVTp-phgOn$}ucb7cM}_ihp^j}bzg7&+{+ zFXK9C;%`JmGiMyEyylAXt<;@l%HL{x!4l%YwLDeps?K1T1%GtSoT?g?|`D8FeL9Y8odHRQz?E4{NznuT_tKsJ>%#0FnRHu6t{t~$XI_`?_kN7IA-t_Zy8 zX#T1%ofD^fu+B1;85)P9YiEa}8@c{PMQFhPCoV0>+k*+e=v2y{cU;SVK72lzE~4DU zVE=4r*z&DqQfN^P^+ZzHq-X;uu6r=9?es!qK6CFe1+aHiy0NKidQHK2*W6#4}Xs|i{6&EX4DY@I`M z+0&8LprTvf6Ae2L&fgRAf8MIw{4roE$dBgNmoPlnC1UI1QBcK{Sjt?Wj)t{r4yU=%;F*Cc0<) z6C`yqfVwZE!qB&ENbob*J7HTccIpU7Xw}D%2)u6ZD|l6hTWB5jkn5p{_&|))%Q;ZX zq^^MQM!O>dbe+%bWhXYukv5X(XuQ{Ane1fm`ahMu3tf2A;-7OacFY(+GD3N0Pz-Uw29WNk9M;B!-$zDu-q9E_=-MQCOik{7! zem7Tp>*0io0x_%*Ify%Nlkn6)A77`tYDcYVd4%t48MniT6zk7BJzAG9)lbOJT#^%i z-asc2&JkzT?8*gbY&%Xbrqqs(Htmzd27m2v{b5(yqvKVD!k?tV#e&S0)8i-S7e#@L za!l);%X0;kl3S)D;S-^d09e77)H=jL>j%6coylQR^9F+q?k zMD^i;5O|bUHZP}0w5$m3>vMaR+go1JZ;ee>;wNjzD`;3Sbkuv%SuRb=(eL4Uz$ynV z9)%pYo_1U$bK!K#NG+9@JrbgWCE4e4YOvk@rNh5l;9)PVRT8whFhIPG1WcvJqD`u7 zL?o2QDDsVE{dx>;FNbsEj%XZt+nYe`Z`LH8J5O}d@&QPmb{^?2%`&j9?vH>?DkZ0B z>Nl+)pJz)Q7*k59Cg6(Mha4Tl+lL{3G(2S3_VIKqthY1n$po^X)sDl)pJ343C9OVM zHCYAfwrky%6>6@T>J~Y7_R4Ftbp9UhdNEX~F&5*ZSWyo!cBolQ#x2)T9(FI*$7y$W z)7i!yIv;@n+q=~CWKF^qv(=~PBwFdq4{5Up7YQ38&q38){Ws$}SQcDFUq;SgDZgJM zuckQTfMP@tT`-Z0&yFS~#mx7STUhw$UL`*Tq~>b{#ubCW1s{<8oi^fN>-~k0f*p4V z5zuux663rTcW%Z!@fdr7>K<4!-_mPFnK;BB5nh!SMB|#93dbH!-29lpdm()2T;qO& z;gUI+`^3FzZ$-;O^>A^091!5Ke(&LtsWjJ1VL^6&mR>q6XL)DDEb@8fIfuILSyFFn zOO%?)wRk=rB=w@?We~jt5TPtVv!Fqni|x zaOQ=6^pZ-G84#38ttYX`tlSKK8ziG4fduPtBi^tPRc9Qtos2X|QL4EJJmCt4|5I3I7s_Qr8}!NbB9*aW6xY)YD8;z(hd;tw)SUuSESt-5^((?E{$fYbo~Is zr=5V(;(8V(GkEpre5^E+S8fTqCi93B;(nBgI7;}3KzMtGAVr^nnu5K&uCJ{^;*Ue! zIf7{70(jloY*>w>Vj|YVnkHda0X#!%dT&jTPRx#sg|B7rdS+q;CgL(mk$U>$(o6@_ zF^6dTUQtC9P*sCpq8PgY-&YeX+U_r2S`x9L7hhA*bHzSe_mqP&X~W;GZ)4yoJ6wY% zt5Ui#e2&|1CgVVnzY|f=rB>I+=}%z$rTeKS?!7ukqT(jv!`4l!4sdVAqg;@MGq~>4 z1a(xq3+RJhJ+qmEsE62bC5sIBAEwwSAFb8WTtNQLfA}=g|2pS97XBcj$1 zRJu~fOVM6Y$h(AGXJ^!V6xr=L#S)!F;Gze6%c%L_P69m2z`sl^C`a%8sNkA*Y4etG zXb=YOJC=a%lDgr`S6r>7kdXNB5kmHmi^E>~PgrJyvfqxj0{JHxIoe1sB$EEA6xKsl zVPL=V#N__Vv=ZG1WCfYq&ilE?o_n{z^>frKIK8yZvLv=MlDbU`DDR{a7-P#*q$EkEaxxENJIR4i4gb< z;MvJsWslDX#`~vnq&XG-CX6Tsq#-V(z-G>gc|0JyNL>b$9xdl3d)bOezo$o}Fo^Y~YAxE(SFUrq}8T#y^Ojs=PyPf=;TMmE;=01VN07MRoo0ii|{nR-@9n_+Ngq_6WLS=hrW_H=2FY|E|1IUP;(?kY2_}&h-vXx zMV{CloqS~#XRC8Md0rVM+-!{QPpu~{j+;CYC>u!;*u**xv7rQP?j zXOTzFf?U0)5{v+A_j9SNuYyDrgg0|nR``#v-mT0 z{Zld^0>)rk`bQ%!w1gPvBox=o!S8b}HWp9x(ha%V{5veCjrew6P`^(6Ofw_T#G@VD1vP ze{{;0CO!=XSpyP5AVh8Nqlf5SDTMG=DES7v^{BwKum|GF;GxuId5O}OLJZ{!H1!Y} zoNv3UE1wXO_Msh;x-`^GegP2RVSRW}iE{-bIt_`te%vs+45U>|}%v3Ur8)Kxn z(za~%;sj$lW;$kM@Tj^9B^vE7Sg)_i+6CQ(9dgTYVS8<=J_{fI&{pV*u|Q?n!nfMY z<)kE-B%cxd91=Y~1~Aj|L*++oK2ETQB|u`^+!S<_8-gbOm_PrBQXr1(A3@#@$Cu4( z@8_iNx0!CwyI#D{!TRaGJ`Yn%2p$rysFcF}nIHnG3Aql@Pr)E{Ux#32v|M6TLcDin z6afmEV7klA2$5$xmXuvQe#%mgI(IjjT6i43f`XUrZv_Hij7UwlLo*uCPW*=|LPRpu zHeIyb==kCWh>h|2U&Tmdy?fG=I3k+^9M2@Bn>9pdO~#{dVkpCd4|CgYJ995DgCJR1 z;MCDEI_xuSs5zX`SwIZEw&T3tb2;~c>*%u@?ZZ+SSEu5M3I ztGey<&{$Thl&hv3Y$#FESr>S$LTd^+P9afCsl&GR(^vZ$>#ed4%|8F|l*eGn!hHgK zqCQeHQx$rTB@8Py*fNCeTcUQgv&vl~2-Gk)-LLWuyqenD%4#tjnB8 z`47WslafK0CXZy7P6H{**%po*jRlTsW5;pATC@m^7gmMEy?9Yat!7Wfi8QZVI~47{ z+)DQ(>luR;Gu`A=v(tFSDYv9#zPwIN1~+!)k6(ynwRXG05; zNnS?&MQOPAy#^L4(pa>X^R50R?t_>{Fbr&vxn*Rn%G}cb5V$V7v^;ixL*TRhF+lij z8u>8z`qUz`u3Oi}Ry_RoSLPa6M88FJVaxZ@xKmG~o#8c0d$J+>8NPd*p#=SJ2!sJ- zkUvEksAX8vHMI)8X5SCaozGj-w~t#M-4dkT?FU!CtDd*salX3U7%-`}8mn{mRmYoU z`mYi<)2_X?1CjiSN}}u)%9hMs)3QtbQ30klwV*r^m?il3b8xr zBC5(x)LgR(-WvMgdzRT8kD38*GGSsay>dtn22!9UtqI~&^Ok%s;+A{l#lJb(#6boQ z?jwzqq^Zb?4czBSzZw5JuYF8~VKH6S4i}u964UTak&ei_!iOEhAOdHi`L_X_rNio>H}At$2c zBNntVyv!wbO_Fbcg{!l%J6(Ud&t{n#v{@s>NW(hU)~Cv#IRI{u`=Y`P}|_QeT`2jZG;ryMFL=>4>`4LC_1jho@F2i8+* z`gr!PHCU$PpQE%L8p`{9hV)|=`=Za;)s})yh39motc={&t@Dv1$va-;iPf#@Z?ataS&smg+-rJ{_bs2`_N5P(6Co- zXOv4+pP_319!y2^xuCt6Z6FNTf99Erm6L~Zjx`LM7h(G%6DU*U3EK0dy z+Id4D%v>Yzg|AULXBKdcbogfZH5MM%{xlU ztJwU^@N|%+gb%HZtYzkOR#B-cMd5^TnrcVCHgj=h^yuEVw$kck3wf5X%&=~)tHYm# zIZWgWo!9w>pYqO*cA}Tk`v#YmT*r`XwPROcbR*=w(;VJvLOAy~-+KeuchEV3b)$_1 z=G7bpa*;q#Yik@ubKAHS=&?eY`HZ_>VPNOycLKa_MkUuB@4~MbJC5I3XNmzvYqi!D z++B)tZ1ad|?dJiA=EPjonFG>&SpSeR16c+b4ireUt%~Z&Ikz+Qj^%scLI+>*vo=C< z2r-z@yVPltIv@?vwhgR^VhxS z?V}&w;@Pj{T%f8CUpHx^+a1CEZPp(GgYmq{*}W3?a9LuncDUujH29nIoRt#^`Zl?o zZGQBAamBuKfi${*+;2ZorO;VA0+-Q-zZBd`Rj59f4H`K)`j7ijcBLBGi8o8$06+xh zq}CbtT}==PYS4arYE}fXl5T+73z(O1Q!VTXdPk;~ZbxgR4?>OR((%!pT%ne_B35eQ zh`Yp>#PU9aZBn-HlS`1`9X6Tx@x(@VPYXf%_2a72@6l%zsjo4r@$g-p>D z`eM1^&m;&9eul`kSph0*p@__zt~`VjcINTjW~9&6ER?n*h{r+ja;~B9aJ)}hP@_9p z00JXFX{wlfeIk`4VXJ&sp~Y!2SlYBQjdAsDQWig61pe~pgC^X0QUx=!?LRtZC9JR^ zV5M5WfT-HhHNyfnS*M`$_UxhXvL&3a<9+st>#ykIxWn54xqlUOi4|NkqX}YuJy*8i zxYuE{8N)$lMcX%t3M!WOG}dU7xco^2!41xcM_Uuy!G73!wsk5|Wbg`uC=T zhKF`$h2YHO)2*9mjS6Kcg_}Pkd*e_#fhrCV(}kmSieO2$k31LX^1?`EW7)Sa{Pc3U+304)#Fh1V3x+G2Cc_bQRsOCI89%071q}Je;YZ zuWy^ZuuV8Dvw+gp50mLvfO)CwL)&cx415-iIw-)3R4yisMeAD^Z&W6u7fG@%F5o%} zTGRA;q6IW9CL1jB3zth51(}xwnV+%P#Fwi+!^PyfjqT$)g(&6|sOA4Ne*;k@-t zGW_p-PNY4F!vTe7Kqv5#-gg|DHTMq!kdFt%^0F&@-sSCmpAv@hLOY!mZ=0!*1%oZl zpWRHoG6ej3s!5vJAlQJKM2%T{DJT_zl=He)?%~mzQ&bF&!;~?-jN7XBWaC4uGTF3> zW!<9Z@Hs=E=22a=wnA>PPt^eRiU^3nRNhh0DQGKV`bg(LZgah;rfX8xQ9juN%Nt=T z+?`(|gxdV}zQG1!ruyU1=55eS#4f@6WyLg zQJY+12pE_`s(1XgP?tuKAR-X#0vaDt+yS@ej7>#ua`*Mz8Kc#Fs-voEVAHPm8jMH% zS!vK3kfli3*BCC8Y6uB{_Qd3_y29jQ`U&Glf+x-TP$-pD&t%#=^xZmSw#PO>1gT3V zh@Bh=qAo%qX36RvE@fUmK5oz{S+q#I?`Z3LVQIn+-+00Cji4h8;129~= z6)%b8s^EV;fxHcSQc$`ebdp|vneZcX|9Qo{OOa(0Ywh~(B<6;dQKAa=Hw}uZ1@0TY z<-47%%jD76ac1_4;N@uL5fsw-1+O7IJ#unJHEVDq5>Ao=D%2cWX978R;*CjXaDzVl zs%EhfkC^7~qOk{k0}9eraU=8+IxxSHmV5Jg<0bH67KFFMlz(%M?2VxP_cn~`FtUcd zZxoSld7fl=$?E5v{TTf3oJ~H|7@F>nk(7jA{8UnNOp>JlwQi2g))~(mr*KEi9?cJ` z_sw$|-?|l%@Jvssx?u{M?#-c63-{Ysd&q`?8YlI8cr??*3d#iu-WjY(+8X8Y*iPGU zP!J@CE2tB{^j0T2(kK5MAU_xa{i`V1lHrj_Z|R@05~x^PlSDl7RxRdXTP!M^qvK(d zolzhfbv+dizs(ba&NC-_I{JKstnBVttG-Pnc3(Y04+naTa@5}Mty3#?zHU5zy9DYS zB3d#H1;Z#{>~&p)dYV)oTkVU{T;B`%*U@w+4~7{gri{Lem++~kDM7snp1$p|KQu)E z=4*N^!fbPaBF${DZ@u+S_|h4IGba3d-AKP#v63c&>|89+cTMU1`fTs2#lYx#>)w3d zUHJJ5=W~5{y3~G}o}5<_>e1`(lF>~brMnDR(4mD;XQ3GBHh{c^Nx?>28&bR8ootzZ zIO#Tl(J$qL$kRdSAreVB;CPHkK^PZ_E(AiXQX-MP3oT|((WB|YP$O&=SzySeRzXWA z?dT8proaX-G;RKd&{8HXpWW^W?#%#*w9l0wjP0a(fIYo;_W=Vd9Z&f?CB<@39P!rPF13Ac*lL&j_$ zjXIm>A>2n5&!&t;ZG6t)cmP)$R7L$7$r<TEWfdsNUH26){LWh1A1^(D&nT(gPOB~YP-iVc>89=6*| zrj5odaKUH4d^c3j7_8(;Pchq@m7vxd7Y1_jbuso$8l%~X7lN760m9ql9nvN=7!P#hb~EVv`G3B28P~IV*JiXTM9dM^2vP*Zxre)~0Q!JDNC6 zJg{;j96~g6ZX}7p(y0*O77^<@RDFcskzo7jf+@>HNaRhOck_6&QFBFC_Ox5dQv_4PuRk9fnqDpGBejz`$|#U}840`4k-E zGjTSg_+owqqw;P2LjV(wZ;_+&dJ(M`DBbnr0L0b)M!#3X%nX!68eRxnH`>j%LmLO$ z%r>CymLBjX+IvRoBgqcbBeObf(4(?CJ;i*lLei?xd!>tF(iq#7dHJNbq-oC97|C3X zdI(zNXYNt&kH4Kh+h(B0DN8eSX>IU|4rw(=bX*avyQ0rBE%TYPTnMk`shL!wr77b= zXl!k4&AgK=*!Y3VEx3C&3eScteh=X^_OBD@1fumSdIY|%dh4B3%7kqF=Mr9y_>&Zl zt#23WkZRK7huBygQJA1uQpQv4q%7kzCY;LX5@;u)K^1)*o@5z4)FaSp6)pN3=Pfi+ zpg)teO{K(k7|D%%i}{|lXGoa^sJGdb4853k0>mQ{Hv0IQAK6)(l^~rB^i^KE`M_3L zdiM((w+hqoPIB;0P&1ErG9^a(6j-Acod3WviE8!q3>k%|a-5%4(f4GcVfiY7rV$ND z)WP<#NH$uxD|{T@YA9ucaFyLbbYe>>BXTZJnsd6s8PVn}Y=yHSc$sDV61>F{?}k?a znAv_dRBwSocUu;{N1@BT93>q##m>uK`93h?*elyDqS>%q!S4ppUI{nT4tT!-;GgXK z^jn-D{PEvpkDp`D+dB3Ex#w*)d{cYRIijky=Zh#(7y3}&VuL(6L);b@JEr^V3SM>M zSyBkNS8PAV8Itu7T#`tLgatY;m6RqT7;{Vwgc%RU1iq5T-j&r+k) z7_iCB=G|g;_<@^y_FT})K7$+j;=SVm-@9IO5R*KI2$sh8K7tVIq+05PcL0b;wt{#V zBj2ITf%=AFFJVN)&uj&MrbqyR6AO2kJ&%0>+-4-(egX;LquW<~$2U*13v`TEpnrk!dEl6DFI0(#d`;zETQI> z4wk}$E?w^icqwyUw$e$3H0a-{91mz7@K(8btg4HxFO?mOC% zS{jNwj@hddopocExR}4;*Ni>busM6!8S0F-FLu>Dtuhw)G8`C?~X01$$HYUM-tlkvR7VVL8jGu_YT5qE)G?tfT_-70T` z*ADF}V6ZBsl5Adghj&LK7lb_8K0O$Tg!M7)k&;-A$5Rt42~<}Hq;{3v&ikz@Q`-=l zW}W8de(bJwzGGwKN1uYGfMoJ&(|2fxg>~^g`v!l4hk1&P-F|c*eQAk*iN19iT(4BJ zEG`EGPF$})!WGgKgEP$Q&wMbmXLZ~p&PUdUzOZ*?1wf-Kd8yQ6yXhN31g`UISr-~y zKmLDWdhxazp~bzL?f_?lH=fx5ySf$|*ZPaiwo8xTI;_6_$iiWYOmlt|Jf=CXNxVsO zUOT0@k``0+ha<@?v-7j(<>G!8-~pXql^^`1z2gqHe9)tU4{`At<9cAb5Ni7kd$z8I zT3f=QmX?sSwAASg1ax~M6iV>_qa|*a-|uplP%9i^&(SuZ6skb#`ZfgbQnZeDg2!I` zU!xtVY2c8NAhU@xbMM?fY75ka-Tu0&^5Px#F$uueDG{U$=DGGj!W3#Q>Ybm^{^+a_|Zqyk}Fd7E5Tn$rPKj?;9N+b4)q* z_UGu=rnyT!=Tw=v8q{Hr6&g(J&e zS{rupkyRAg#z$5NcYvuV(}yFqvC`ba;=)LEERctvoX^mXz}qaJrf*xJ6+VsiD7=bd zJZ3MyXux@#RBO>U!T$qQS+h+5004LarI9tZRY4F%_jmt`6nA%*2$u*ae(u`z!!2eA zgbacqFaid^P}o7?4y)F!v-{NPdUAmkyk(3D#_d4xq=hs#IR`vqGT00Rk6X`zC%(u` ze&J8CiWkAt%JnJ^;)5FixJ<0*=B06OEj^cBx&aa@yb~>`Iz37ukYQ;oXO|*KK-~u{_e&;wNIC8bUYX1`zOr!Bsn{`)4++TCf=kM$%jrys{7D)d$w0} z?$f}WG%%5zHgG>py*m5;3K)MK!yaMu{by& z8AKal{~jgHA;C7mHXOw%oW%u}04g>K~xFe}!rMg&0NT zDA#;b%}li)$x|&-?LHd*uK&iKegSbd_B+|k$8x*8)5ls)_D~+DL|e+_AivZ4$f0tW z=IW;-PLWgPG&x<)kTa$GFNQQ3$e4`FNLI=!SuJa1N~UE-W@T$x zE8EHTvV-g>JIT(ni~1X{ki*auA_#9Fgz%ZXEG4u9pXk$ktn#FP&+>{Sk>BEP`)U3G zIcmx2ES4htBcGrS08pX~jo{K_xz z7kxW_SY32B{vm)*RDB}oIKPPRHTzMuv8pX3d@PYb=Qhos@rkomtH)S0{T7 z#puhXY|Ad_cZ`5y@Mn^h*q={Mot9u79o8_9-E3{wiQL}BJ$Z&6mW)! z5wIJfD4|d^P$J@#iX>$sPK9VoBxM965?RDCVh zrkZs`$hxAM^+d?}qM8jv4I7G5HWFoQEXvtLRIr&SXLC`+R-%DzLVIm(0Gzaak_|crpTcGGDKX+rG#9@m8x7NLar9oT*D2@ZxSIla~oyc zF3Pz>WVln5a~JoJ;9ede#S=WO@i~#t8@!|Z13n?mr+kj_JwFlfGrtkzcg9l8IK~q( z!D56KS{@mTTbyc3Sb_2)tI!x(l`7Si(VDE)DzCF9YOLAXQflq23uV^TR-?jl)`N2E zX}u}3KGui0^|gK^t-tlB(gxT7s%)SQq`(H*APQ};4OTwHhA7v+F0CJBqsbD)zafM~ zbK*TJ=nDQ7v!d3Y@MnmqWx0gw{{R1b+T<7bLHgBtMzt5we+U26uP6HVJK}r)Mf}-} zSp+we^kDcM#}15ROox*RbF$2gr`_w)Y2mhv*JWmAX8f6%>H0I%GV@1)UimGR%Ffi- z@wcd_I-V|PG-|axl6n*Kv{r!GabB!`BPU$udUO4f&VI~8P4xj z(GBy0|AXA`0RBf8D6CH-n3(QbnUyGSEL_+ z=t3m3QK86cro`H!%-W)hwS{AC(Jh(v5cD90VcwrdK?qP3GAMz%gibi2gl-{=9-+*- zEQLOyfPNulb=JY^EXV4sNDEqNK|l-gw4j9+WN1N}xjkTR&oH;QFt?|f+uNDj+nC$i zncLf#+XEq2`CkCjn30jj6b3MkG4!DqBbY-s`mrPcg8ZC&d~{N{3P=wuWTX=gG22oWVIFW0XwPkX1r5 zNhG3#c`RTE!yMBBg|VN??uImwy(0HVjIg5oNo~K)+TtZ-{CE9Z{!jj^$OsW8DjAnm z%2x$B6XF?0gPO{3(tKodJz;s*-hCN_S_^E7z2`D@b-&6AiYFhOzvRD#Q09LJ{D)FG zE+Zq)`yK3bN7$l$?uuWoms9+I5YN)In7aS!S7RqfT%})5sx?2EYP9KG5&9?SWj;~Q z^0c0jU6hJleNS)jnwgyIi=7y;l8fktS->jSTX@Hr+^vY;M66}6@>c)fvgV-Q^me_R}&D?Vm?{7E9=L;p*zhg#V^K5Vss zgOg9z@3!B|+}i}<+3{CJ>l5<#5T2qrSd7Z*&l3$2f$Ed*%j|dTS^p3CU(3~3q@rRg?a!x!-c-gtwkv_Rq_3JU9M>-Lp~cA0SCG)Lp2&0qFmKA*1`e+Dy$B zukb6m0Px??ccT;f{a=|G1iL4Gr)y+4y_#VZ&?jnl%It3B_o%^1|0ecs+($&TyY9Jy zN`G?SmfVj~&nHrq`z?$hr*?y@SZ-){<4%uvQQLi{Arzl1H`QIzCk>w+GQOMa*OH&> z-{qOp_24!4(rH|`3yp-)ko152_4PAOoLo^i(1h;B6@Y87x;L;rQn0F-!rAuse#`{x zvO`pLp5s_kz00@wvQ2=U+Mw$$ojqRkv2uZevP9>)dkvy@!kKR_`l)T_D zY&hMA5;!+c6qFv)Ym^WPu7-Z4j|`AH!si4?2Zv#HU5&EDJ#y%kk!BCSsAmET?BnOy zwE}%;MF+r0gMD@ausO#la>ye<0WBzEj8AX?N|->Ibjz49ri8MNL3W}MM%YV^t9_`l zdD5J{sJ&=?}aHH;_`87GdhJBc3jV}Qf53%b$$5&JlZ^8k1O zty4wT+b|Gb|2f4xb!#e9pv;VGMHvVfNCKvc}lwLG8C z=f~omU%r-6gxYZE%1!+*l0e>e+&dV|#6^Q+)!P-Bz`m*AvBxozHSSPZZ2;NSW41u|SRw&?;0j@5zw|9grLbYtFt5--(~_!Ww7V9`|0P-smd9lA=3hw|Yvr3I znMuXp%YJ*agijygTygdi9KB?C{rQw^1~;vR6)*r{K$@Y>_Wbru`b<(|Eou5a$2XM; eu(=_0wS+AmeEEIv{OoO2nRO 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%0D`!6;*E2Brt*Hp6>q%9N)^8SKvsl*v?y?nix* zXM-?WB;&htza=RdmF1@UU&?xNl*}52#~=#j3kzrMLN|!$dBqDRmQqVE5ez71axls2 z5Q?}Ok(8#uNIrSO`>FT2kE&hC_%w1p%2jQ@SEchGB1^OM*d@J8pUul%_n`H#l;1XT z@Nkmzr~dD}Bm2@i|>{cuieRbKgIFa-=OE>X+g0um1+5Z3P4ImT0Gj z6e-1myv~S5Td%1+%gTd&6dEWuS99yPaK~IO{;ELpfzx7+3wpgX!+_nP?yGKQ{*US9 z$WvSWMPBYrY`SK#`tt-fcM9t)kUFR(%4u|`JI;^Jb(4xEmL>YAUT@JW>j``0*fG&R zha49k>&JZ8$X1vLtr8M(te+_MdZp=IDk&%b6Kn=$@ut-1H5_tCTJlWdPm>tMt ztM8_uQ<1r;AF(wf{sf&F+R0?Bu}kx8Wi|$*x6F#kNkl9Ho1lHwKs{zrsNH&9Z8#BY zjvhjCPQMiJ(pUC9WoyuE;(FW&enkRGt@=2tw4=H*16NM@B2H^Qf&{_msv>TK5+W_WE^Lb z!Qj1?xTwa9&}WS_xG|+bJRSua2CP7*|Iuru_`Eh_FftF`lyghveL3xWXUg@IMpNYN zX!jfFj3Q#+1U5P$J|9pNz-~aq<{)CXKzt@x6=C@OivlnrKlt`%EMnT~V%!@A$M!Yh z!#<5;cLXM?u`OoPOcQi|fJ$JlStE=6)vNHanc~QwKq0b0J;p~$!mn;w7j~XPF=&VQ16b-~KL&ceK`XZ+ckU=L-#a($ru zo2uZKclE!os_~h$c}p?_hVRMRI}}XWRu2Qh~nD$$um5&I12OF9E0&Z^=vr=L6C zXR%cU&Xk}Rd!>QS_{ws$Hi!wIUNqV246Xj#NpxWp*6d#sxo_nmkglP?06 zOQ_1Q7d1c%N@(-ieYVziF7}Oo6Y;uJ7>=jSjEA@!czucYBwH548?=qKH^Xtx!7XRcWdBw2_}YXUhg^3^IY* zA1yU*OKVK7m1(m4HWY*+7ST&ICJ6!}1I(huxF$3*)i42!0K6Jl?2&IC$#<6ZzUu7$ zzN~#U_0>nuye#&&@2Vfb^kf#z_3&$&6!2^n5OtTHNC$YhrkgsdnI@%!($c9cE2Ot&LdRN1O5`#1Rtpok>D*cdgbBKvq&dUSIKk2Qn>0?diR>3p3ptK(-r( z**nVlnTCYY>WV^XdpLyFSJpR?ZBeD9wL7;&Ab_o-$PFw()Pao@n^h78n! zUMLsOWp*_;HB63Sm%av0L;;kL-piS2t-+zR;!oDj;90at-Tp-@Av>MaCvo%B6^X{^E+n)17> zK}{*uSO>K?-sr+!V<`%?&$~*xnD2FDrx%eYDeYg3!H0xgzo<27oAj4n z!=*#v@KYqnN*pR@A@astXrbkUR2}5tI}AdU3!%MbPo;Elx#Eui+qcxr5~t;(bW;5m zLq&+L*zpkO?A+r)1L>c*oHJI(p$UH>IT)n7%ivk?G{rbb68{INEc20_^vJJIx8I9A_!^*?uGvBAgcN zBMU)R@)-T=Aux`Fg@+?ljD=A~YL%nK2aUu0BV%li_)KO4{}xZYZ+tJFW>4>o=N`M@ zL^Yf?*!|<-bo=T}u}8N~bg}G3dtSS}he6YJ5Yf*s@Zfi-BnsEICDv9#g3ei4=~3Bh zLY7-Ztz}cJUD6Qk>E)NhNoB(TJCc%6$t20-f(X-SM6pQ_uQ=!g%Bx0{L|}5u{gntd`HFea*>=e^*^axhv&I0s>?DfRXbmmJ$>S&X~)D*%un#F*PG0R5Y@mM?(?B}(o3bJFc-*u z)F?^6%f6m3;L;o0AR_mV=rYu9y-IyR6UYj9iQZr>vn9IZH7iM|xsDwg4t!xGf?}{E zSUF47p(2CqwM@jmu}#QvgV5s68*WzwG(@)nOJpnD1WqlCPHjnE+X1E^h?$TMSu>A; z%ky(st6woZG|(a}hUyZR?6N~4G7u{j?-__uWrH4Jpza4fztkN${JW+b7fasf0IDu> zdRGN(xtP0{PAaUDN&}@hLPS$&suz+>)c9#Qm@3RGYAf>MC&g!Ck}vS)nF^zF9gPeq ziqg7i)Vx*untf!Tyn8H$>1whQ%b#;8vpF59^A=}E%?nlW)sGKZ>Xhu)Znv3ss5+Gy zrs^+VRjbsKxEe&z11$|fmctPJ^c1F?KKVGUF71=xRh_@Dv>X+(cfmb#b8C9 z>~j%u^R)ucSJ{rUqpFFD8b_T7oaYnPINSX7;QD{)U!i{K1>g=uDk1Y0Sfx)qas$h&a+Yzla!m9aq}UV(3*2J$A1{eSOC=atl~QOE_9+$dgVbUawA+@@w(%4;G1t zSH)M)6T9MWsF;Mx*bU_X_7%#4B{g%{nPWb?<_?XE50&F1PDMevJkkNMp9<`UjY}oq z^fW&yLRr|Vj9^vrv>{sLX!((qgt^FX?NZ`-zv|B9;RHE{3yh=l$Wj$x2anpzZN0P8 zOLx(K<|V38G~qS7W2X-_{M&?8S1-!sWx7VKHAO(Qb3;8lz&5hC0I-$L`0%6JO^esb zriswkdJGR^XDgvVff#j@hy$X!j`GK&C;>jz_FAvuIj8*+w>#lfwan6UE;g-g`=+y! zTr2Kgk#Afcr>&)C|KZ4!C^pHv{WO>1*rR*EY(=USXm6c0RS3jj*nAT&8efQajqe#U-uaIC2d$uv+_!x6LcH%-A2fK8?nm^ap>y|z@CYf8z8uiMbm#kc z%dh#XnpHjvMQFR~I@DUe-}N?2xz_d7kGyw#T@c#;omWD_9(=&^a%LJ-FkLO6#oGMi zlB&?yR>d}Ds*R*cOeGc}g#u4cG)zXKF4h~0a5_rdgc89d39S(n-$EHUU&vC=o1(LVVxF9KBaS zb-<61;1Q{EQE+V%sfcyub?+cwW#W{bhD%$-F81*;Fhz4%M+2OqgVzidNleuRql$`X z#G}HateQTwpe3d9;TTodLUx}{hmLK*QQCbJ5f09@sZ*qSA1gCalYdFRxa>K6?Zb+a zG5`?cdv05`N!h3;$A^lnB#Sf4R0^HKj{B~%XS%jYR=?hvxF(pR?MyLQ9j4E1%P1$ z9JVDHRL!K%uW~CXI7wD=!bO!m$@|EBUUeQFNkMtB#d)AoFNtU-r7l7Rd*=l8i2~WDg7T#!Ei**qf)liEzo;fTfqm`|rJp?UF_{`5vt)VydV+3@=|O zW-EmCc_7)IQ8|B?GV)E(SS!7*S>X5t6@lQ9M z@{fHx*-dNTZf}#5{9SPo-uY63*Ts?=+423~=lC4}ja|+wORgsIcqBK7v{}!|PCv90 z-2>Co+U81zDd^?^VILM9jqzLbTs|{+;&RSMXR?p~^qXfNW``vD_^3^-2#^Bu>i&71 zQ68-I7wf~Ch2-zmW~|zYm5c_oq{pihxV8a~v<{uGL(`29#E8V;7~OzkFFTTTd=t|l z7}b$&3#?#cEfUFEgQ9JUdCf*i=2c0qiIgU}riTLSrg0%-!Q)(0Nzn>knb&BZsfK|=?_cC^}XAr1Cx;donjorR?yK(Iy)w@um2 zY80&84x6!A@k;hJese(~(d$dKDJNZC^Do_zbVN(ExJPm_P|W+vJ9n2QS<}@kdR(?V z_|4kB-7rWn?#qrOrrpX&>|}_Codco!sSni4<`rQG(%7m8*4U&ChTm>BaiyY@&_p3) zz_?BsWLh;L^1`)vEBhLMjqnkxU47~-C&_F6i^puIA}NY?^4gPM_B(S813A-sRn$9Z z*)3s+ngw2a+_?;x38v*GSD`|70OB#5stV#jf+)9sh&wpfaG2`nF5u zU8H%t-8P9*=^>41i-{tQ>M`RzJr)QgUOYrBb+QSSP%|Q9d1XX}NuJ}PLat?x#5gW( z^3swyLvqiwq=<#i!jo%fS07%ipNS1_A|Z4|pq$JMDgLM@7HqGG1CT)xJK7t|C~g5rdzWQR1R zamtvw!qcf^8ke?YU=eb>ubgvth@}hK@<7DsnhAm{q53T|N32*Kf6$_U?hF9<8gy5q^lfd+X6) zZy{9!YiV4)otu7>d zv;5&xZ)HFXva;}l)2_hiu5GvLENbepsLyn5KPwJ*qB;t^3J{(ma}9ZMMLOJ29XE?` zNM^+RF%8|Q26Uo`CC$7qQ!O|mX~meO3N; zUmsV~xlgs0ldk?z{-rw-&J`MMkBGU|$;n(xW;C<^&Lkxb0s_3jkwr}sa-c!&yATL( z?N_Wy;k+~A;l?_3_|?fga8GxV2&@m#lVGANhV@{=_|D>T_aI*k+j&N3`@6(GjJXS2r{(MawGEK@|lqz8M^3PNVYUfn7B;ED!;NTO*hV zL_uQ2SB+>PXee{mQ}Ub2_}aYEC^^@ ztV*TxCd8>Qx26eD3UgtJTq`j@KIU9G8R+Od=>Wm=5XrwSe(;UgRv$mdA~qo~7bMZf})VVS&`6}yjC&CVd; z!eadL$VVb=d+m|;@&{xcy>4wTs*!!rNjUw2(qY410m-y zARs)%S~o0aqfREW!R5${UB!V2AOfPTCtnsh4n0Z{MP&k^N{F5eqa_5-m*wzOWcciyHr?#a;8fY>?aGV* zlU0zicVEYe@Lbx)%8BH0vL*i2R3x04tyyn|#Gl}N<|h`#uu$3QOz;3ngMa`vh;pb- zfq;;6I504}GBTpG1-w(3ZE!hkBWwB;&5Wv`b6AA>FFv# zS^doS*gw@+QK$CK#EE&r6-`8yv6ycYyA>{3NO=fzV8_;xDD*yI;%nEFT2fjl#9(B= zHkPMc-(J}n0_E(!&YtjXmqad{h-Bctc+knkL?E6iEfc%XW=M8qT-s({vljAJGT5^- z0kN7W5mYdO9K_ep6q+1~f3MP_m81DWRW zt^KVth~0Y_q(dS0xjg3v$jFT3qruu&1WUrK;C#%{=zTDe#wVs<=>exMxSYI^rBVzDtv&hg0{tl11FO6gazHc*n;YD zfzN2Pz}hiww!y*paXNj-zCN|q*0OK!yIIV9D6JfMLh8vG4?3KwF^Yt*_ z@8yhWGX(I-#VE|PFhHJ3h2<<=dlOclDBT_^c%(w)LsDd;DBv-!(UW z*^hFxBE7uTQmma7CtAg32O{CCJf$b*$;sMjS&cG?lEWj7?xnI#Yt_n6_m%v#Y~A;R z;_cuhTXJEr_r-%EVUg6>#9Y#HNwG)ReVEv;c+EKERA%7*vC+6i4yW6D=n)O&z-49*EIhm8Dmr*58F>p14TsdZ-m9P-O%IwOq64>*L2nH|80 zfl(>|0o3KdKELP>%dGwlZukY@{oA)4;DaZR`6?STB|7W{%nnL-1o%(k`ji{a;3_@; z6ZTFyBKNf)f7bpBX$VhR4YDmOR_i7S9wuiPmY8QhTV}Bg$rM`Zbg$LE459R(m7uK!5W|a*wWgod$U}1uAxFhr{L+(9 zloXI#6*!ydii_B{AeL@lk;$k)Ug_!Fr{ztRylEX)g0r$^Ln?wUj-IZmE0nzuW~rqh z%U?i!BB?R~BDaA2q&RFT{$cMe=^Q_yl0#Q-donC{kf2pI6ql2xUTz%FNd~z*?f0?V z%7SO<9F6odJ2CtJzw#Dpmyij%&jWRuup@q~8?rd_M7H*bD$H9EVGQnKuZ476jYRds zev!X2I;?QNS3nn_hfg;~;LekBdswQrqnEFUFtT+hohtGf`&#r9O9uPq<-e$~go@B{ zN2a6_{VO-zU0r%F`=QK=e_CpQI~k`p(ZeB@piEebp4dhY>dCVkShhtFhoA`C3?UZ{ z1*Rg>PRROr^g|;`yhu^Y*F@0|lQ>-9n1X{(&2b2bEzAY(UOZ-?PCdmY!49j0(;0pm zUOZ2bfx>sIEfgSTGDqqhlIPLr2+u7y#5pDd&ov2E>ub{dhg=DS@SsvJ-d)mVi|#zx z+(|S;QTA=@3})@2tq=OW_%hUi(knM>7kjZjlMP-0x9RuLy#T7#W1~H`2gBNUHuy}f zw{0-P?Gd@8Cz&1sAwV)J+Qh3&0NeV353n9gZy^=-k*egYG(n?clFQlrJ9yWb1lz5t7*bHMyY}?y2 z2-JG@84(u~5jBXQREshM0z7=eJ*UgCn1QH9;5MR9n|5I>9UuC;+$ydM2Z0jM<#NUb zcCZy~b2`*$tJ$Y`cj>@}-K(cA%2}ekV7i0;K`yiEq;1Qr_K?vX<6B4&ri1Pu)~Zdn zu((dY=z6uccnns!{dCaHD%{|QmvIc(mmfTPG>#U%y4iJU6K*?;o3X!;U=T+vnrU~5 z71DaFdPnq{V-m004l29svO5SpWbi8UO$Z{Ez!z5m%5`1^}cL002l|006&j+sF7t zUYVZhNAB?FvDyy|qLz$IjcpAb004yl^jCfcON*3?tul6VApih=(fr5|{Rg0Uh&wX} zb6Wrasq9A%Qb=jAQd7$o3z=DYU{YewaVJq92;z2PBXwV89l(EV7Z?_>CW9Yv}Pa7oZ;-|I;M^@djqMGqg1Y03?6r z0i^Nc8W&&A_4N%!2kR=E|4KW2LJ{z13>)G3IG88W9rAJ01yDsfBJ^VMxXt? z&_yo{u8a%}j{+9Mov=VLQn0Wxn>IAC26X{}`P+el`M-gngC>NjFav>mGBfPIxW{^X zeWK@d0s{@1B;q8a&ZrWU40eG4kD7>>|IN!a86<&56H)YLVtAFgbEn02M&V=I**!MkKeXUc;4X=@;z(LDVZxVU9(lM@OjjPSDB?+X5LOPgiN*T@@I(uHX@xsNlDlqtzJ1` zPdQ}fiXfO&#SArpAkd!E&@0H`F@h&~iirQ%$*Ye%=-?wV;cn!?>MUXEF%-N192B@d zt`{98s@_Y4C?i}j)M?yJja|lPO5b4zUz0IrAz(ZPF=4ddhbxci`kd)dg6AwkunfsA zq3h{nUzt)wAA`Q}U+8h8J!+nkq&`f^Et7W2n47dXWW72qW~|8M2fP9Bwh!3&fsP^m zRzh#S!o+4WwwV9ZkC|-!$J}@aj*F6z9ijTC1bv&K8p$;27JM-k!Twf<4oRe}^wHtV z&^P~jF$=`2ae6N2*!MorCyHT1+e#xo@b@_#*ts1A)`ZWZs{Yv?*Cfr!%Sp&}4FrxJ z=}a+v1H@Oc8TxOl3Sshd1?!@-PxCK*@^$ui|8iVJe|*k2v^|k-AGF&CkcEM+)P@zM zITQtdwriwvo^@Y|psx(9=hD~(XR9Xn91GlR3w{X4W#Nn6LvYO9ve;i&&g56C7gNEk z-dR3k|Hw`*H3`&R@pgrE7sTTkArj^#7T4F?wq>=Ab5N7kax)h*R2?pG)*4d?v+2p# zu?0Eo+3qopVi4L_@!Mr=2a|NTl#7bi!kvnW{AKA1DZJ|fyUf3+Sdl6+pgaaH?}ML2 za2cj_YgQ6(#?UwU`?@A+B!?ktUM!Q=bZ+3YKT;m&+sW{qeL?L%5f*0&IsaRSDzlQy zs|>KfyCRSY$=6;(+uyLMm;RqjcM{0ycv3NF)mgL(TpRq zR6^@^E=zJK0v3mRC21j+vF`emAsGSzFYl@_vlWN-vxRwq1w*)~e?^K@!_T}Q-c}dE z@k@`B z8mb9Ti%#ft{4LC!#i?inJinDFQzc+I(=#{~x3`^yE42nL__H4KH#us94Obh9xFp6b zov**8z7ayQ@2;~)QX#jtZ)@rPEx%S%O0q7?M%mrOQhQ@bP_N?@IV$hX7ak1P4m8Z3OI0dw3JbSj;C|hu|POY0@AJq!c z@??LHW%Xboz}|N=8ZN?mFt_XsTM^yFin9)btb~p5n6k6wsQI^8*^oe2MZu0jS6mCr z&gLKuY|E?ZEz}7XMIg!9#I* zKYmj-ET=XIhl!#Il0zKUJ}oiO+fRNC#rwiYGN6V#5s)e)ENU?uxZ*&iet1{m6xJ6I zL6ZxVgO!E_Tes}Y==ic)HOSU7tygFswBx0oIh$O7_-wZ6f(UHruuZN8W4s7#0u%eL z8l$(b=souw|B3nJUqOi<0&3yodup6#`^pU8MHIFhHw7e_AZVWIFoA>C;+AOD)t@5~ zu8Qr1Jun=J7dPxXZaG^vybw~!IBu`b1v`qi^@_UN7=q31!}gL8CYHKE(`LqnAcOYA8@;Cs_I6ov z2TPvbxp3-$^*uX)SMhx4D#T)Ye&=%wBe)a0|-ahuKwO$RMg>N{2#NODG_1~u> z3t8?PD*{nw5eX#{re)^n?n<_G{#ilBosM~KMAtnhb7riSxdV9OLL*j$^Q#jx86FB{ zdL;u_SA|avXi)Ps(+t=a7+(RRNR}cE9KGI#eM_@N^cz<-Kl$aPLg90W&77Io8PryG zNC@LUSVScHaU9z~+X-~$M5^*BRKf}fT({ZTzMyg(o1mqG5C1!YCf<{o$l;Dr2Xf9I z=L2af8Xdbj)_iu=cvh#9OI1^zX8uCYv_N~z^vDleq;y9|F3@?0O^(8;Z@F;>9#~p# zcAiSzP)7^0;e9#txYMlBtch*)Mp*t%)FqdRD`Y?kXbc}P4JH8{mfT@|`2ODKGkm#x znpzrF0Gq)Z!UTN_QsPt{btp#QNM4)l&_RB_yrF2lh?E`Xe(8qXS5b1D_JZ^~32G-K zIEX;J^Y1H+Dl3$2i#_pbtQj~mXgU*kiRkI)nb3kZ(nyVHH$h6LWG@HfG^fMu^Ek|e z@p}u(>e%Y6hRK?#+y!j^g*irciQeYc(YM6X=?#=%Rl%e=wwyVJe-km+8(MBmG%|5o zV1+m^2kXciEQLQxk>^O}q<)z&yD@=3a^$VYUj6qVX^`V#dQ%;~X-w3&X>kJsML0YJ zrZVsc9*O4e*=HurvFuT4PN-lPgkFloXg~Jsn#3KcT1FOE5_7#Rk|33E637#cN+b~S z#3h*ESU~fx*CU)4aYRo^MB15j;!y=0qGC#-(qR}@oX|mloUm~&YzSI)jYYwAxCJ~U z`UdVW$n=RNK>kQ2^WQPunkYIiw0sbTF&iZ)7p0FoPcy=h>yW+Tr0nzGI-&#DJH~bt z^d8X|yV^fTW;an(_8lDQi@zOXCy9b25J8NpS6N=JHP7bU*P4k=#H)j7Jn**neXMS`?zAA6g=aOI4X-lu{2(nuoolzYy@65OU7QQPnh?~O2YQbF6=iWn6Y+n z)LlFXLp>(VhJ5WT;NmcZ`D;plmHZD8G(Kt-X$Ebv*Lq+{&NGKMq~D9K$&}}vv}V`D zOItw)T|G&R-5>rpvmD=*T3;yhVQu|NU{=xL&r4s}>XE?U#g&6O#E!ViH6(wRHzg9F2NV*pdlg7+7HRGk0KW{oqrYWd`+98`3wQ()&(d~VcRK# zlRYo*|L9wGmKuAMPvq!ufi6zoz8S?%F^unS$~a>0p3V63BY8T^2Vb;7xo*1{mgmI# z;Dj@@At^bNA1s13ogGO%vOcl84s_y&G0oc>2OO|Fz&K&JOS6`Jb_Wc)Y}iR(Bfe)0 zUu59LB-#@ckUdVOuI+wK)|Ye|1vz3)>zqmM#Q7zGLp%1+A&5MJT3l7TK%U2lIi&D$ z>!Y@a!3L1_XhkW^nIU!IpkC?6gLC{`(KUpa#HszBo?j68%*hes{+Vb4qB+|>Oyutk z>8~x>m|0A?9-jAvVHQd_BQ`gjD_CFi88d#?)>_igth~-qO51JmeBhWM#%a955sI3a zGL6HKUiKeF*K+bn@sn#^-rB?r7Ph3G)d@!To$7o55PUaI#9wep)Ru`fvslE7ERfaB z^3(Kkj95&!zj0{Yis4Ss6Afsf=nryp$SdcqNY&k$a(2imv@AEtdW0=NIZYoFy9+*^&u3(gy^RX|-+LQ_J-7I+pi# zZln2?rT2Jl6Zxg0_bOh~hXw0Dl&_CUYRKF**E`qTbvRaD;6kKt>@AHEFS%QxNGXV7 zYGttV625bY^HRYkybnu5{3)E>Ul4DThJT$x>~Z#C#NV#p|Mf}wIcNXL9(Ic2r+cTL zmdZxTPEU8fcTn`B8y#T{br#LnQG!%Q9X17P%croT`mG(c7q{P5t#M72>n*kW7umDS z1dk%JRO+N&$+Lh3k1~|VREa3u$W*a{O@$)d$yAw4xcRRY!?sSjnx_mS+uJmkN6gb&!zh ztxS$*WiD0~Ay!R3Rtb_n5WIYe*H4V2t01Z+%!nx0%?qS7;lt5%bG2sRIkd&pwt?wT z>Xv4R?#7G3=IWWG-;;qv#Aq#IA?e?t;`F?oV$SLQPogTt4dB+n-}# zjF{nx%p{tdyBKvXl$myRmfR#W7CrFm{y8Nfc!6a-X|Uq&p`$EFtWleoJW{wPp1mWU zy=5-TJ*;{vCdS%6lwaGS^rY1NBi)&)>T=;^n#*!F{7Hql&s~B|{l4!VnKV!`YX&goefeuSY z9t;C)na7oak((+v6>PwmKgqfE;g1hkql)u`fB@h(d)GfDmmtx>LM6-q%P7!-gtKoDRE za0h4t{C{Y;Z$$tcFctt2I0gUUF{+RQeV zQ~sA8aWmdY|ERU8k+SpttNo9cIVy8T$JDijWh3lrM9u`gQJf>XvQKtT_AgE7 zs$FFB(6N!ZW5%nH7I}B##c1?lx|CvZq?3TDY5|+_NqKm)gmroK(%a|0Yv;)BWu@pmI3B;3mEw109XYKz?G-7%-F%tCD}*cd+&zQCDo0l=mL zrVK!nK+%0(b=Wu%>AkuZ1Hd|fm0sLi$S%N5FZ!qm$Sej6$dq{HoLlyY@vRFFLhYdT zt)I{DN#e>;HK&AZ!j&=N`!uxt>N8ERV7`)@vs!TS_`WBnEd+m|L2r` zTvy$hb=4idUw|S}P;3FR9voLFeTqjHAboJ&#`rhjW*7aH_@6X#0tyLCy@W(m0(23) zrC>(_&>^FzFw&ES?0Se@F~lk6e}?Q&5zw;Ust~D1tm;25SwH#<9|n1N$#FdV^rF-c z;5@argAw+Az2f{0vPVjT6h%@LTtuQ3L1z?*L0cFis+YS&79SFI6z6G0)3C0(S|+^6 zcAcm(WN+D6m$b%H(!)epJjDb-jaNoA9!TyouSiq+#)%EhjS-h4O;O zZ|0vGGgl|K^K|9_vk)jLq7Z#TbQwHr0iqXLe*dyLQe;MMu(xfyEvW!6*dj3)7=<3_ zZuFeSZ|Nv5IUs!I_lxEcHM;%DaDJW7+o4p{rk1evJ9G!+Vb?;k3_+kj_F-uRq&{8v zyJRM!{))K>3ww@BKCUPV>R=Xy0c=HO02Y+I{D2chn0*E6bwm$5Rv~#H-d?3An)O>u z4~!bM(@N=T_;oZ3PYdk)tAJ&VpiE1AEtsa|84=^(O=XB@#fYnOd-J^`;&9=x z2w3pgYFgI^>c3|);j{2qaG7$f+yC&dJgz~!K}3E?zHU4%=imqRiAOcn(Kc^ zCmnoJgf&<|enA225CxbYN_9mj2{b|Hl>&TG>A5KKgn0g(7TpruG;`fVcc>Soq1PLBE}l#doDs4-Yz^YO;QE3P-6_~ zXw7K->`=-@0mv z&l)_y7FXqd#(cNCc`q8pKz8h>cNReBtW@lM7P?;NPSyA2hpk8|o7=FE$`#RUUS5LR z?<|Kz z&b0{<>F^VmZ8v$Y6xn7_p^TUI4 z!D1$Fw7Pzy`Ap{a++F?NUj5zNKBto-YhFSh(rA{~)yX00J(mMvnA7QRCj50u#Vb`4 zVrT(X6BrbN6hI0CnGBi7*n~(4u7M`FbDy_nfUbaG0Ku|!|HIX6^zb>3VLu6Jj3x0f zEhwb=EoU}bszx6S9w|6mQGPybfl|r)h4&>B<_t-vuD^R{2`Dr;vN^$<|fy&`HX> z@-+pcxeE_Hc8n?5-9H=mIT~5cZ94EF=vz7V?+B` z!?$sUwves8+*}3&sgvnZz$#^lZ~z`aKPT=I5#c1J%yz~<3GvyQxz%7?@khp5DC!aQ znl00pD{fzL!_#Nx51(h;r`K z6RydTF0qUYKDL$2jc{o9Y%}NTUL6Y%Kb)I@_Fg0F`K};!50sxn+Z6c_J?=SA(hcPy zxHm!;f?OiVHseamXdLdI?KR_#q(>tMW05AEZJsjrF770YC5Q$-m{?2{=ySTKSiRZBfxPmmQRs7J}N+su^Mw1VoN`s2sC{MVtjxwF=~qO|ag0*VdVVE`qh? zCRAA&b43^F#473_9F)VB5NS#0_G@NL?63loS@jFQ@6TiBfNBjq!>5t1Pw}le8vE2W zCc|1_-`T{jkfgO8d?-`(aU7h@*>0~7i`LOvAm^r+i9_wOr*MCr@Ckp zSFi$uqCAtJE$z44f4;XI?c*N=uWW@at*Y}p)wt8{#iKO3fZ5amL-qDYx8(iysDp;U z23sY;c}zvEOI6zi-VMcQ-BS%H$DE`+;5Ka)2LUb`H5`ASvNOJHCEFb>M zsUkV!Em)wXh@-1oGAa*s`;Cpls!p+=A@i1BYT_cY_cv)SarsUhIwGnUI#YS5UwYd^TzO=;+#~0_@N|)c?;@2@QSKYuUDWUDJirj?l$|LG zQUdgJjk1%<;euNn3tC(p#Q9Bi^bF1ndMznB!pqLbD)-Bk=gyxRo1#Qnj8_hDpQb`Y zi<7kVwZrXnM<%!%&VXce1&~KvneP~YB=ed=YCjHCB}QX;Ta|Z)V?A5O9D_17Q)3o* zO?uD1jaj}tGehF=A%cK=Hn)6Kp?VZ>)GQ=Q zY2KdrkDoyJ6&aJWzb{p7pJ|CH$HT0GoS!qZk!16IBE+KJ`*b&8ip#@^iJ2M->0jUJ zQu)XAT3IQ=OEJuLANTuJK%Xj{6<<;Kglsyqrfpacy$|YpJGoIJGiMQ@26?R z8JPEDQ`6ENoJor+zkyzc_G#tSWDUaFiy#m~LTp#+RPf}&fpbJ;zwq@8xKo_>v^gCo zI&1yrsw#=+P-_S{9C4UaMG@uP-x>u}&=;iv|EQ2VDe|I|R!4x$FOasOZqjMm@wArA z?ed{b6F6qa8gl(kJvC~ht>7N%>Yk!iU95if7V%i7nTpeeZz$8MdZocqSO62K$@q~kkqx$9Q;aAJ5tYb zg#!t5_`~1Yxq`OlsWx3jcl`f8?)y!Ox8+LHOm0&ZXoD<*qb#}(2p zXhHuQnp2{xfY@P@stu7eA7b78s+RJF zEH^iK@>h_p@|r~>9@JPMG{HIt|B0#(3(N<#C<|-UV;nFCB^nz;jFtuom|)NfARm&LgV8=VsX6HO8^R@ z*=menD<<5idN*4Phz^Ohram$wqDYwPuSYB?>*~tScx0U}RD62Am>UgUV)XdWhrMX= z31;}-m{Sgp{w9nI!K{F7B z@+1@lAXm}{h+!rMMYh5jScx?td37}*rb~;*JqFx?FIHTl>9ya*e!^XOi}xpEX0h^O zHls34pE#K8fNz}LRoeZA5pCU()8qFxGZu<}A z*S(-(zF0*#V&J^3l5)&Mt1+(FL}S)WAm}sb7cZ5fBAQHkf!(wlV&8_w;N+h@hGBTN+ygD;O~yt zv8?UTp{&u~^Bo`}e+XLuk{Og9m~@^XUW!Qb(s+w&#l9&wb%0l^WE!|j2iI&_Ck?;X z{5CHVw5-d<1a0KcmtkXd-FUWW9}@pSKmpBn$m{Xvw|#k;kxEV0v`{WDb~debh&d7> zOL;?BxBa`PPWH6N=q@5yO3!X5GcHvr`Pz2Q@wqW`I@{cZx_{_tKu9} zmLZ(mCROjP)Sdi{Ms{^rchvZ0J+V+aYR55YO`rD*VeBLDPr2=?_jhQHYOUJrWmc2v z>)QtBzUAqdZuo)^fyt?X$CU+a$2g=&BiRUK`=DQAn!F$m8c@urSb%GcUiQRf7i7(Q zcwGmY?2l|uqVYSHRr3fYOadKi8_KG)Ht)`#g$^@2! zg|W*Xgfw=@vAAPrF}$;Ln}gi4{}c*4>Y4e{;Pe!>IsXf*1pQcj z(!Sd$mPy`(ZCfmi{no4M`Rm^=P!<`H-MjUDxwH9@mAVtW_CcPc4=2G_fJb-@eoPFn zJNG>{-Z+Ia+a?GfQSwZ-MXXn3*|8~OrGwkjEb+NA<^|IfxFQ2GVOimqO2qN|MK~2I=ZsS@O z*o*La<$My|!=tSO_LG-EK!b?d=@a|pF?#z&q@5XmDSR)QSSuJTg)9_#hvPHwh;8go zTykV%BfmaYvak3#yo9hjV$dotDu*%`84Q{eU>?3)awk@z?SxOfg9;4D3M^z!p%UyB z#yal}W#9I`p&uOzU3vv}N7rS?$TBu2KQdmSV~nAVC7V(pkA>+35b0!m3_;(1A+XaY z$kQ@ZzC!*32sPkh;H7Lv?6_X8GOBr#M`JX$Evl^_^wB@FDz%OxFG1>fwy|9n9hsD6 zfA;8dj}0*|E~lbQrs;a%*Rot=IVAwhYycJ{4}BV@p)Z{4Z_$ z=1fk8i@8VEtNDD(!gVG%8yD(NZKeYm?JPK^myiznt-8kzPQY#i_E@)fuA*K(p5SET zBFagz9^i&$!WLV$$+9+9=;$~X&75cDJ(&PJm?nQ0V$lw__4P=SPUK;CivXpdKOq9v zHuaL@#GGmc4!FhQlBh*1MA0Q>j`roG-8|Ppn%rD=i% zSh!FZBiHF4{rv$jDs8nqGSJGo$Sw=?3QkGI(5@ul6qJ>QMGSyEj5Z8;lWUR{XStOt zlLg3s-SS-+)#|2Dkh2Os&V_5Y^o_K5&Hi8EY|oeY52EJs2!E?WY>_HYX9-|`5y`mcdNh&j zY}6K)zl}qyC^6<~HMBun&LDeHEid?mSoHx#MTSMo|wh@GoU%1PF1`U zi>Hl@NV=0;Sm>5nGoN#vL~DKqdJYy*idV^CGa+IxQc)QYDJPsHRye7RLuG_uv?kA1 zWZ27!2_#8zn~Q;8C=;Oi)c2u=MsJLXNW+M3Na;XTCJpeu?)+-u+sO5CC>W`1t0Bz1 zCtF|F`e%E_j5g(NLVN6H&v)8#vA(2eyp8PwcQbMF0@oh24U_*b0KTGed#W=}B#r?K z5N4q%!l6qY>GZwPWtU6CZ8ns;482@tGGSml?joq()oVXJ2ranf)cteD5uvS~n0Q_I zmr!g?q~(Jz_Zyi{Se)U6=C9lVhY+I(dY-g8ftx1Y77YUTxTe$DVGg*PtY^kk|NT8v zyT{klOs!uu22$Vx;X<(@Yne8ml`Y4r23~4!8II1B>BrRYNoRh7aBvLJ{J?n&I zay;Dx`?6z2HEQM6TTDo7oK$9R`h=D$I>Rx!@|$C99WNg4t##T3i(MzLiCwu%gySZD zRQO_4~frJCQm~mt|7ZE0LT8Os zpaqDrtTShw`E?bpPH0#Mw40Se^-^O5bWzl>Qscx(9OhPFbzn-dr&UzfLEqc{icd+X zn)Z75!h?!ggMsCFWa3)CmNYrS?ctbDb#vDyTADlll96k*Wbet_nTrcU+0T7budC+> z%$U6l6PUZ>{gLq&L~Zq+P$}|zKhc<`VHIuT#P(%sL�Zg$j7`nk|=$1wu@3`<+1K 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 literal 0 HcmV?d00001 diff --git a/app/ui/assets/fonts/SuisseIntl-Medium-WebS.woff2 b/app/ui/assets/fonts/SuisseIntl-Medium-WebS.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..5d513472aba782a756aec5255394e0f599c67b0a GIT binary patch literal 17788 zcmV)4K+3;&Pew8T0RR9107ZNN5C8xG0J}5*07V@D0)PMj00000000000000000000 z0000Rzc>b90EHF^A_;gI9hRA$pLsC^n^&nBqUUk z^JG=Ya5}N9+OYQ{andA+TrZGIHwSHRFWopz@xHNdDBKtFu`&{7{7m^h-G_tM@;AfL zUYc}b;5&F5LCt(+Xit6o(@E(C(uf=2=hh?G&|`yDy^Ymdu!8kly;V>n#t7L)^aue(B}Bmtj6B5)V_rO2^}*6%&>Z{Py0Nt{{hAaQ^@XbWRt)&@&F_Vr=iCax2X^$Jcg=^hTy?u z3O{$5_nXwE(p@L%x`mjwk`p&^fpN{SJYe{Vz`*0f1$%)l;b341CkNssHVD7q6~O?9_X6IOr>DF5gJ8&ZBWN3+@ZrHB3Y)BF-?pj-)k z6#yk2g7Bozsm$G6wb>ig_qX2!dDKiq;S-F!)OYVpLZ`&i4UM-UH1+?}{qN6-AbV%H z)ms!jUNm@RGLwnG3mzW9C21!u;+20hO+WKfSw70vFWamN+=@gN9C-&U&YAvBcegB0uY$LKkBjD;K59=KIoq z@3QH=9IXbrkSK&hBFD)jJJ<8CR7J18y20h4B3GV7cqCfkfLbPF;pNAAg1RB(;h}GX$d-l`Xfo+8=CS79)2r9~> zXuSH}{wcE?{*90Sm z%s6r5B|xYcjhc*hTo(mGC>F`Aw^h=hPx}dqp#Wz6_Fcm46DhRs2=8yv46#e5E%U6V zPLnBZW7Sq`mK>ba=^4t{W!7Jw_b-L_yYw*}dtNhZ(W*_m4r{Hmex1v>Z!#6Ljq4K$ zEhr^Q?#u>R-(|Ou(;&BnygrfNQqfnJv{5?7_K9&Y-UJg(GC5P&RMRpYW^Cs*nBT$z z3oWwP(yT((AfMrjuj5+_-{FU!e);W>zyA4O+9d$NNVD*90--4JJqe^Tc?wfXsJiZ( zQ5vo3b?Et$n?Hz*-K<*=5YEOTXWxv5*}_~cieWBD>&!+|He%`ACzfjQgd%k{{M{Tc zT2ov8Z%1by({p`zV?Y473S%eKOw<(<|IE?o3I;C#dP~17S}zDmbg6o)dB&nhR!?jRr3rk#{YkPjp zxcQAXH_&3HkU$tZ%MkDL>q~5J<}XinmUrw6=rW?S>kmJTp@a@#A;}AG=KsIy*<|ku z=K3Bo1j%+a$n}T-!@V&U$g3b>?~77MhSBBA;SVA{DFzj1CQ|6d7P|e1_or$A8~}^q zPfak>p6edUEPwj|v+4244y%n{wb9??w&;&@aAZ$9;t*9&dOd9ctw0Wc6RJr3Nwtua z1tF_#%h~E_K?}kY?MSQ4*WON~TZTX;kaq|`ocL@nmd|*I$U|zM*QHBJR>V~J4q1@I*iz?HuW9Sx!juDrnT8onbEfiHz7^z6(;#( zWib=dZgwh*%eTsmzoAonGMRlFz_XqNVWS3i8g`s3r#g4_5PZ4e%E_WkKPRck`|#1B zY!UADOZ(?{^0W)+m6?&0$oL-KkBy+ZS6f^6Vh$=|33)j$#7v#odTzdc(+Q4T0^(c| z8R{jr8EdO=bZzX|v-$ppMzlpvM#1SOegn z%C1F-yS3szzD%!pueeb6qLq4CeL^KwERiQR){{YA$?`e-__(paQI&eop{D2sdNINk zQ#4y6oGj3Pk{we=719h_w-&%JN|EjaVq zQ8xQUN^SP_zEr)aSc!wHZ~0Xtd%~rNqSpbA4RmPe&T0aP21XzYWc#uO@~?@gxSDnC z#Cpt<@b5TokB8C#xwwG8J0C$su}soYbQ0^M97-h5RCd&=QiOA%0hR^V+gDSYdSk=` zj`YKSh#<1#ZwyZe)tA3%3`TKUYgou!MF4vuM5m>j% z0Lo(7TEhjGojP$zXK$yqQeo==);J4*Z9+mE?vhD({4+WcWW=Je^P*HrlexG(3P7$< z0%%P2@`Wu;=rzC48;lOG>7#SZ{TR+ui)M29{RMzIowcqcdqa#}nxKFMxC9SoNbCZu zM#+dmxgs?rf+P{EqhiCT&L$n+ON^bpTr5}tynC4*g*1(_cy}^R$60m}GjJsyu0bZg zNmyGi=_%LOpsJxLLXj;E>vVjnc5n!U7@y0I+4*D2Vtrt6#*Y$1Dr{ep)xHGI|Ee@kII3&a`~0#G;rOGaBs2(tj>PUAm{ zxC%PYYWKc$8T@w)LtfEXC*hQVOj-044_qJrOP&qT7-`H z5>pv&riZ#u*9`C8jmemzWX1Gb3Cqn&pTbqtBFGkX)(qGz-C`YknT}0Gyd7=KbI92| zpEFUZShHz&vhsO^IiCGcqk+RQ^_AREz|)g)dZ}YL*cse)>8+~@dlBC@`lpia|0iKh zJLi`GjsQHbW`Q9Cz(4^^E{x!>Cc5Upx;P9Ik)luj{uPJ3u0h&1NZ zWw5X%Drvn{zb_iL=!1UNUzV)ORHa%qQBDL0qE)UcD+rHg%{Y}5%v13OL9oV;H%f?^) zJnC)#KN5LA*nfof(fR23>w#~^WCjFGZOvpeZTC_T3}{|(0m6?YGMJNz0{&Y&@x@x7 zt@qUi-)!>3W>csGM*o4j0h<8OlYE81y z{}s?n{DK$kH`g@-Ru!@T))T$`pJA(hGs@~5$6m#O#JRzSGK|{(@#EEFfUkFtlH?Bf z!#Yp8Y@ooF-u4Z5q@=~M|Kd-~%@DjpNc@@!;k&o+4xi}XPfW>Koub(DD_WEEy_mFm z98(JW@`Z~gt&b_@PoFDEcr5>T3#SX*hO7Lfc>;{FC0P{{U>&r)jFNAyV>$=;o`s$-u2c$ImzMrmpn_ z$QhjH*ZIE;dh?8(QUZU6*})>YbE}Ne$X{t85*Vk{ukL5Yzk0KY^lD0&jS=S0igV~4 zvEK%l2^{}w{&JcB&Y5TD&LFw-_B!KjjykH=Pz7lDi-4cxl#Ex@>NNo#+s)0h*iKZG z>z~74cq6_dujVXXYMO(#1(ASo?{k>vWg%|Ob z>+M13a&^fwyIcRHFHTOuQ;R}Fg>xnzb$@vFMh*@v3N*m%cRaM`9fo0p@o;|nGijRh zaw8j@kG$jVbIp{~BM$i|>XG66hx?9HAMBlShpFX5cVZ?!Ff}xcl4u%)j#a7t zTLaLC8*tcLL)4hnA_a@`JB=yl$>P1WAR8tjg%=axO`tPxm})Bx!%d-%)x2* z_v3DVJuh`5dFVtZ!pOY*-)4RZg^0%he$tioa7?@Pu*z~+1m(EBavOqf!HK!d%)4Ig zkp&gCwN*_N7`;h~aS1^(Z5eSp$8WYwzEsW|t;uU6+2Jp{BWrazV#uDsYRLJkON9(g zx4izY3}%EP(}-jC&P4uUDjt{L$U3#ncYalFKhgWUKS~0&?-z!B)J#4>XW7$7c=oGv zbIgai_E$QyIlxMNO;c)Qw~$*L&EWBLJa#C=pdXj?e_RggRQ^Kx-EJx6_+hTeGpUZghG}DP~?P_?4>@@xk-^r}ZwknNB*v1PUg1v!G0&PK`9!Vc$1& z!kwOYWSx#vT~s&O0q{hul4t3uw6!tj0?g-GQ+XXg{B?~ zf44>ummpnt7Eb8pp2!57;DRTD7kSze`Bq5;m*@)yNxj z+9QPT-xw)ctM3_QzXx&X(mq946dl6?a<<2_%JxyK5x|+uYW5nVW)RG=UfpsY4!NoP zy_C-c8~zR%ZEP5aK(CBTBBnZq5n=l}3WV4kj~!{9*Rz~u0$;x+*soqA>%>GtvLl)#?3dj_fS9A&%+vL`3%D?r2wMTb05DkU~{ zChw$KnC&>K)8xz4>TW$(sieeIv9uVkk#Z%w?$~saj)y|6%Cx$m z0JJ%v;OaFSN$QV;zdO4>=9}FePbPta2$~d+Nw2ZiKFXm`0GPY?09YL?{T#X(HSuIx z-$eFatY5863!d)g8P*PajySDYKyujyelMUa!qZE_Oq9cyLF%12xZ%)(GO6P(Y;Q(sXL4xrCB4V-SyWF>m;jE=I)AgKHvwCa1{${c$`FAZfwgP$W=bB1%NCP8W%Wa+sBoO8F%ekhCT z49+*Pb9TLd>bl62b9R=#oqkBc5Rt=m^4YR(cJ1=ks~x1~?24l+cG@3?><|m)BXG$(Eyp=J+5-j&L}UoBVGMi^)XLtCrQKXe)46-qF#1A z_;|q6GOv6Wi5@)#33uqmJBx5|C37S=2#7IlYq9Y0X-oK}j_*D3l|6MS-|ZGoM&JJ( z0ebdKieh?ZK!aC(nk&ZB(ox@~x=R5r!b?E?5{ZPrqj5ZJSepe+cfB8fIi-nA3}E`_ zUzxZOYF@$MQVHghRi52t)Pld0gMoShye#t7?tymMp|GX?-V=ve`eM=5R}bDLcktZJ zSm>*Z`T^9jCTShKSLSrJiM1L}2_vGD^hkx+k1{V_<(dBQHzGbbb@t$S>p$%OYQv@! z;p1Q8`$QAQ;>JJVUnPmopeFA=`<7dDpn&QR3e`#IrjE45v0?vQaeBE<;qz(40tzKK zh}8DMpm_aVi>hH&FRhS(=Vf8@Q0?**dch9b5o^7?~=Sc zW`PU}IXL7Oog3KCg`lQ~wL6ZKz18y;LKcmWsRQ?Idd^HkbCX=S_E>VoIO^!w^78eV zH^RY9at$@tCzns4j;>!;@p0nh{x>msF#8A#&iB*bCwc_k36R!sLb^+FaPzRxqj?20&&8&FUIHUI6hlTWhpTp0UF}%;QYw_yQ$lHZ_4xTCHt+kL zB4LmXGhR7$rLzFy%gs9U1JcZygwG@t`W-UqcKhZyB7u0|;p$W?W|Yy%uW&`i*fpf~ ztkni%)+$oRGuc?g#jjv=V#erqQ?2MR`MhBG1v|1<84X#hN$vB(!4ETflzzQ#1p^0% zX{wSB^#Nr7;;Ks;=vcU((c((LvtLEOy3u^&%jlQa)uPd{ErzWo?S3T=yb(1*$|Iu5=rF#`Q&F`-NDJ&Zi^&@ zk*I@ootEwgJC(J?#_I8mJ9+a{t4_yG*Voj))&2S^XP$$QUtdaNPV5w7MSAwRmPO!} zXTvVkg>9A`0k4RtRWizzN>%_?1&I=M6L`gk3KMrZM1A>WP^Q;b(+W&=LR~p=wabB@(l<3~Tyh_IgkTW$`gB5j zVtiRxuZ@gYwzEzysu^8aB{=Ke+c^5;gsAYgybP=1Q}j%bRf4-G42mRqE|WBbmCI8~ z>6wBA{p`%;|Lmp=WK#6^0$AAw$YD^b9D0aWy-LY0sb0%B$Q6Yqd49f0Rj4qiiZA{Q zJKajP!wtLq{t4pZl5IWotwTcKnKVIKp|So>hn)_>6x7tLamu|nA8vu&uWWqM_~(bw zC()dg+gx9^md()dr~&mGO-+olLug`*LjCNW%}-yRU?a)j@6CmbW_-68@aYSyNTS=F z=}Dxsau;)^JYwbwNu6hMA&`yrh&5J#&wBn-JdbO_-NIxqg?I?Y{xl6s#jG@|>xoK3 zJJ0RmwCXgB29u?Zsp5pPf57TXTTvqCaDGLVXsydtI3BK@l=;l7=v5AB%%db!2sHQZ zx^U0`^narG5R>rE-%rTk%iB}qBkA!8*)$-X9{zgy7flkgIi4w~NQaqhlT5_-J}g~% zV==9e<$^^al$1JHcS^4-kjV66_;B?B*?kFL%a7HA3K45`IzpY)08=@ZkR`(c6C*`ny+d4+k*{5*&Af{*%<(|OA>>F$|vd5*HJb6w98Sjzc#7pKaI$0?-Tpn8`lSpmsYRJ4^78s zhcsoHp_S&8aolgq8Kx(tD9WiSh*ViPXj9t@3siR7+|XPhsZs?6sO-1;MI}2^0Q%V- z-?_sbUnlNR@m8cM`U~T-2QFExD`($0r=8KhF}u=w^l1GH|fkHUsE)3tBQY z?O8*!gLRL)X*X^hU~oQNI`1I=CK8Zt*81!0nm{8&@Y@OVy^egxIADR%rcuIM@KtJO zzU1W%f>ns+{eq#{|G9b)E|BR`Y#8MoGf&BzZw==Cc_K`vow}}BO!xe2K4~SaCh2)1 z&}tV}qlJ=+TsU9oI@#ZfxoN>7myJA!Wxap5B;)@jIK3bbmP!R4m!uvm(MHG)4OkHv z$gotvR?^13Js8}vQ;u!bcO!Qqrg^d%9g<#)pAn|nYBU&;P=l>YvkOlS9VsI9m~1T+ zr=x{rhRLy#6N>fmOR8s8BY(ulp2Q2*9ckjPI~m{6%Z{;kor4GHakR_{7H z|IGZRKLg8qcDg|#|MfIyXyx(1=!c_$ zM{DJY@wJZvV;_zM#OP?_>BdxKd}BpN1!?N<$EE2zr*o*qTpg@49Gl~qQSiHTp82 ztf3+n0#c(1c%VGnuyA)$%*~eYeSjLKtePFL_vH$O5d1`LG6}qE9;yDVMyRcvkNO(kcC#SP(;4oTFtVbh{t9#+pNB>R`VsZ z@O2*2U<2?3v>E}iimV|89RU^scX3WVc5fw3-tI)*>=7sCdPWrqZG>jzxaSgDCwx~%kiFAPxlo}3?tah$kiA{ z^o{rh9`n5xtm^JL+NYoYqhf>NG z@^$!~S=L_wViuFhZ(n`EelXye6CB3coeZ|mh!Px5(umT7Jd~knKTZ?Mu<>U>CE23q zhGBTzEc!~4Kwv)(lf@}AM{V%>Q&Ydr``XM2L?uj-pi1z9F7vT>ich745~iQ;FM^x+ z!ot`)F*cpo)oSVl8m2$R;;w!Dm|0cnu3))E_}@66`nT?`k!`cEFl8YCz_{ z!C=ueKf6&pp$qdOO=IZny(R`gAA&vKX0s_bN|(;Q#LK8(R4ay7e{mngTw$ zzkI+kuzC{R7T#!Y+xD(L9-9V7&TBav2G6fLzvi`!=a+71Kh}M0*uQ;Qeag!R$lQ$dRZ9AY7Y*W%BTc8m(SK4*`?W_ zvWlY7kV_ZGSQb6l+!w6wt)6ktI6Th8+}MUoC6`Mkei+;2xDYj*5oA2v681H>tZ}gP z24BH=ZOxd(;TV-{TEOMq6}p#%M#W8I;@VIxrw8IRJDpLsNmyh znHlE^*~&ZXk*N6z-263;FI&rE>Og97;j0z4oXD#mThpQ~ym@fQW`1f8#ny8LI=vO! zoe%g7ib|5#F&))BEQg`g85k zV>#bMIQ~v`jnvI*%W|FFQ+tq`cT(3#+bq*AD>SsShWU0urYl_&JLw_gG0$rGna*@| z?5Yu)_i$$Fl$>%!bGnZ&Dz_QHVz;mbp{bi%J-fOm@+3mLdGjEH`ziW^;Ij?-#zrVQ zV+iBaLO=3vs*WcYncXMS)!7mXMSo-o^;atncw~@b0eJVlq&2!#kzTlriM!1bEVfnrJW)aMTSE{I>T}>)yjGWE1 zOPyKhSIc(1aT$$?Vg4{SJ3uGcG}%!le-LiIvTLWKUG-g=k!@3(xy9(P$_w=9U6p`L zR!-NMUSxl85T!w7_5wnYmsg+QF~a*yY_kn4SS|Av!s`@u=fE&qo4$8+c=QV~;p@}U z%xE%$&T{AEJ^R*cue|jsP&_uITE?24Cx3#0@=>rn{6NR- zW75mHpV}>#KN)o~EHKZ~J~$V9bUwB=w(u|W0xyXar8Q&jXXDhF(XO}2}Dr(sw zO>}L#U!ELr4z#I}&KjT7XN{)V`MwIJ9lyQH$(AbF2}CY>HJYk(JH}7XIB=(Z-JNycwX*O%)=^ zd-sl`SN8K^?Xv9Tr5Q`w`7oS2aA-rXJV=JsU|B%ml#IZQ{Eh%z42BCp{62!?WX7_xx-&CJ*0~$aK|QC-4V_;QzxA=%ht-okA>mD1B_jE<^mR{>J2j z*6a?qR?>QE&23$==W%y9t4JtwTmP*+aV?D`&0xtX!#SFeTrpW}wzh@t*03r+Jcg ztnsWpo8T8%F4905F{ow>^{m&r;|K{L7oeCu16o21ty_+WOw{*RaX>Bh*sr0Wa@826 ze=z3616By9K8px8LN(#BNo|Ht3|c>k9mEx*K^);QA)M05Vg#|zTobBoyRstZPEgtX z*u&rg4jyGRh(mc!+;5R^hANzmJ?33H`NGD5FeInu~g%nc& zP{jtSS*wk3;Np}_h$o@1xMJBX`m6{qwk-s8(h8%-a1S6a64h*@Tv#uSh=IhzNiLT5 zHe(BdyXNE4r)@7oMoN~)Q_eRaN4|Fce<5UsQscywJf@1x zF&8$d>?|y4Fk;(v#;D=bjHfItRnFp z5v7KOEDK-R>7Q6LCTBfL<6j?lLdZwu#)u z56DN&W-PAb*zlsq%yrL=h*x$JsA}c3Or73dFSGqa{xdRgF_7ae5U448Owc_ObG-vs zh*vw`_KhAs*;$;&v;C8t1NRA)Qqy|WnGNVviy3W>^h$5N9!N4KX>;v$u9op&W5V&} zLhrr!Nxg0_s7j-<`n^-xheS#pj*I3n6d4;~B*ZK9$Q|T`ZA{iIejEu%ac+WI-V5>_ z8b{bdY-yxwW9RzYZHBW?6@1AZdDRQ+@eDZ*Lugf@ec~nbO@t5VwK!ndI}i^sVpKU_ z%!Q7dYQ6La5u0PdYC>##_|gm-D=#8;<4JLElof+VvM)npL*r=%c1$we%pDycJq^FX zlVJ=#fLoMydl{aB%-C*2!QyNBNh$S8wLfKXgaMKyATnGgW!#leQ%`1}`zHS4!X|H= zd)j}F`rj0W{l{1T=I_^KR10ENRU(QO&*R{ zrJJrBfS(@S(IEZstl-_sI$w{af%hHVOV`fkfom+(QS=GCZ{Gc|=ERT1&?%IFY^fdi z767MHlU1{*GKcs`2Ag^sTL60iZYk{6uL0-JMhV&`=@~U=(*-U?$VMP)po{S?Xb#Ce zXQ`RG1p4_>6k#SYa=mtR!LQe~8f+Mr))i&3u}*HdMh*@is>ixdsu4@8UGB%2PISum z1-j13`JTQyV1qvTnUMMxvDg+aTG^rKworti4IC<=<|yao?wXTU!yWn^K43FYfT-=@ zl+e|t3;2B*@cw|IHdeRuJlNL_L+8uK6-ZRD6AKN z2~wc$g8{y%R_(T+yL=J}7dBTcb=v`y<$x~WDUGK~>!hP~bP4FAO?O%c^dA)5`*3aG z?vKH}#pL(NliQn{j zKI8>+7}EXL(o1nI=)rl=85btXN}5b)RoR-2@yW|3eBmngZ(+m!77X%O^x!uYG{M5w z7Vo{R$3r<-=BN4;xG5Uq{yEQrdW>DE?%M;nVr4U9u-*4)!NZ*mgW3W-W0S$0kh4QM z?{VlCn5vvpeY%xCb3D(MLW=guTWR0}$x=^@E0F4Agmq5SnT~}y>mu;eV3hdmB6fjE zDh6dKs}>RpB4A8IiGE@ArVS5-&`e7&^NX5kd}&;xn9V{cZ6sHxnW68qPzzim<8mvdw&Sen`%l zvuL)v&nGhi1~bMSpr3a%r1dimLKmI5S;FkAMY0$v)wZmVctyFMMOYs0CA5sf665&qFQE z-6~gQBPuRqXnoKYA)k%Bb9pFcqUTs-IE4{V|6zSFPC}S#U<`7tt+HyWJk3MGrZ6_w zuCIs6aB6kdlMBesBkq@(!mMPhiU|5SuAML-)|#xaT6sgZ1gr60_0h>ZB;%~2DZMbm z9t)5#fCHdS$v!pBTKD_D82Z+eS4kEf^3hRxsXsV}2DGn^5%~YZ+)zT>tW6_?`lJ*r z_CHKdLNW@T1Mbui8p*A^sA{IZy30oLOam<=^M(r?5ZM$YakUt9ChwU5y`6zR zbMC+v!rJ}gx?w=)P97I(zSjeMiQV-rxB9w7-H*-9|?i4~*494irH;A z>QEjYG<_`k1PjC}(ON$^U-uF9Qkwg9*aG*?ZY@qc^zn9C;ebDA>;tlbUe^Bw%LxnB260HOWr&SGjl#4jL?~lHRQ-snchid z*pAV5toA6agC{uFB&*n@{g2M!IIP;)9lJ7lR!R1|ev>QZM1~`ym+7+KB#fL4r38;$ z+W_8yUIDg53^E;2Mw)>pTd{@MYzR9ea{jMU3_AT}b|>r22&#eRcx1J}Xr=97dV7bb z^J&Qzl3o2MH%tZn!I3VG3IyjN8yG3T9;nu}5FFX&Zc{aPxiOy9uZIx6fv(MQyN1#> znz=*y(q{@KjOn|$H8so*#vFNFKLon^1`$ziu?)&403=tEgEv5vnSfJQ$?Ss?c&6E) zaYxB^FdnqjJVdA9O?3W!8oc21e2vlkU?y5C_|*OFUGVs^ec{$on5x0?B~Iz zRn`(c8N?o4xlK9hVoEO7Xolb;_E506Tx4WulL$QI`39PuSbe63_9Esm^vR)w)Z2lc zB`34e2Yzg-ZsSE8i>38pEi9aFg(x0G-L!C90vi1_;Jgt3YP>^ z9CU8Wr!H8LW4#O5emJ{+us55I5I!$dm{>S(4Ko>)j$&xyU*7)~bTkD`mg? zL|vY}tZt?^AINt2Kv z9PW;(;o5sIy!f1r@iKY76)(jFOw;CsTe0#&5ESjROy%7Lcjy zSf;;^_B{EIVcr5{IUht`zGar~U{NE1W1|og8gA5ucHU-|IpQ!!mP`+XV*qp;Iq)j| zr4o|BZ6*i`!?DB3=SZ-jrrkYodmrBqJy7TgnTBqwbto#&}K2mqnyFCY~hSTeKocHoc zHx=5MV!sb7D(HWhyYbrC3|IASC${eT@w>ONuULm;pu+yZ6Stsu4X&U!~)x^aB$dr;8+cHnx1y0 zE~S?q1bJU6k}}yarN@WRAM088-MBlU)t6@9C2nSzBdzjBxPH(B25Q#>XA<4{3!*bY zd)TUv(J-EEvz9_!eg<&>u63-=*Q;y-1g5}q(sBA#N%9jq)z5DxlHu&i`MN@o1Z7?e z(X1>H$!kQC@pPzyU5e;mnO)8r^g*vg^WC=Q2^D=k7$<~$bk zt}b$b32v#GA={>+0dNph4n_CWO5nGLhFm^qA9Hxulk{g6d^{t1G~{L^qDG8}>xk!) zXwNDH#o2UVhVg<~<55)Y$BNvF>S($;5*+q~4|&>ga`TFZJ{QMYd%7cX2Nw}5Fu?-s z)%9uu_yE1X|FhtADJO{H&)T?Bn=k-^Nn95pQXK`-1;WzNNTf+dWI#Z@{km{@4_UU5 z8CL`t3^F5b1E4TVYkI+@r%)8Yyh1V!jMQCeB1x(eO?Givo|M%0He>RZnmar2VWV_} zcowd>BEV`Gcqf&fYiwNpv;Gl612QtzIusC@Ki7{y0iYJ03B}NmOhST5`&PAnWGv7 zfUmekTH_nk0R*-)nG_+GWQ#U>wo4_+xng93N--u3M-1m-pM8L2@fjICSPY2OO#K&s)axy@*LYEO4H-*=uVfm+g)~fi{+*)J1>hX z)_i>^trp|V4Zc&xYMy$e$z2_)riAeDDmOKARz-?t!PjXS?M^V0h05ylVE3{7NS7Bv zxuIYS3Ix*j$yR(BB>Pm5A;=)rOtl06a%=#HeffGc-E(5=JefF1QWA*3l)+SLac89 z5RcsV1Mxh!LP=nL3nWwKIA&IDWn{PT_$+$gfGmf>?7ubaWBj1q0(_JyL!g}vspw$t z4M80JyC9Iz4kf38yqL{Q-y~qeb?zD2fv7+_DZ>huTpeD2iB3>mJ?DSk0N*Ih(jIu5 zHsGEQ3DY|JE`WQ#RgR(dIp0u6YVJ`UsLld|ufaJU?L5;hL>=tyk1~lHd92o7N)G6H z2LgSi9lss2|BQZZIjKwa={q3i+ZoYh09g**>}O5pauguI7gZGEW4q6W%px7aeIS^t z|7EK>II*RvpnONkJQDDIXrKGpbvh6oUMuNk!pT0{;bndZYazy(}mJdWqH zOn@>sO|4foLsW$yyV^pfGt{~gDMl*YC}k)$B}A1_a0mmPRdg zEo35$aaN;u1ZPd_wKoQ>PDX3SoU#&&VG$CTKlQlc3K!A|@qE=hq!3PG#t5-GjS$)O z5RJFw148^yL^7?wOkqj%jMNdc+N#!zI!BmU16mPmSd%%2E(?-ND_K3H5e+0OP*BLj z>M*3_V-IC6)V8wNWjkg7W@_D8L@xBf#~yFJL!zj1$i>y+(~or`dLX>BxhI0U2&h+d zn^0APJkFbGt@lQUK=on?(L`spH^o}Rc~B1;%}f(gSekSct91GT|2B9xeMKtZLWq@4 zus(cB(fEC%=%SBVV_(7*>DQVOf2NcP7Z5@p3tXULFQQCkhczj68#B!V6jXtE;2vG5 z`VAhWW;#MbEqW=F+Uvcd_*`cq-7!@byGcDWL^Cwsuhs&7R><=Q(SbE2t%pNgt3Ue# zCq5lW-aWz-C3{JcebroN9Xd~(+&l+dBO%`wJmv1d- zwG}lYr-F39P+AG)xIhJ{`|u6N(D?ghG2|HXF7?dKJW@)o%R*tIYl;J+rk8+ALSW*3 z4a!vZuqGw%IqQm`lcWzigSUy!HqfjlK`LPTFr-8*rKM06WCpVgMvQ#`5DQ7$kyKZQ&vEIAs;mC(8J`%vX@AWwEc6a;4TTy)N^^5|vppAI++Ffp@ZLlg z3Db(Zh@o2(#Fy%10NqbkqhNv2i;raHBE(P<1R2ASVWB!cBsj1H43?n)Yd|-9PB$IRElc?_ zQOZFo;m@kKMTj7tb1nsg%6*Llff1R)A<6;}F}i?Osbw>~--# zGQ5$BwN=6w-}(V*-Y8&SI)*S)Yvj)Z^-F7k-a|%f7H#9;8C75&C97F6iL8*05L$y+ zO(m&s^+CjI+e)oe7GMb8yBYmv?l-1$Z5t_}`B}lRi+5Y7L?a9m=~m9nG~~x=QQxJ# zeYsNrJ%klz!_s;rM|nFR{V}$B^tZ9~bq%sK`|H{d2q43It*z;V zHJ&*}CAd3LH}jk=0Y?S6`l1w&(-7hpM+36)JRjnC9D%LTE+KxD3cyv_C{nS-5fJli z4BVHVr)1UV{(8c`wI;!Da`HleH&pRDzEt-Zy}= zO6MGpsr2Vd^|O6;z3eaNzCHHOj(s2|)dum7TQ8#}KkFsPH%0e+)ts;NZUOpk->NrS zfvn{e>NmqThC<_Yy_n|1CbW#ePiail~Q@+Ga%+Q7)s0Nbt$m!bAP{J zKUtIDcbHLo0DMN0J~$d0@1tYup)S!{r7>ND_^-Ss`Lc)8x`Ru|-bYd_o8SBX3=HSh zyY-$(5(z-ytM$@bx+Vi6lqC@0o7KN`)S$*#@f+K57vS+d_f-Pm?S5;us$-)^&0`UjX-%VCKzL0OkoF7{Q0 zbQLyjZ0c!niVs~qQJDvSjj0McKhQ4}DIo5rG}&xSF)Z8U#=*<){g= ziko3dtY~`1zpRRgWBL>o`;shv|C_dk6*3aZ>??qhzDH}nu7BxLRuIWLPQ?*z4u;S< zDy`O0QdNx28Rsy4Co!`}%D;XE`U2~&>Xt6l6-4y7jP*%iQAp$-#0N9Sn9*1rUr^4V zu@xFW<|v085PznJDADgw!~JGpWYstG%=sl^1?m(W(}ZRUQ4T5$?K}xTylNc4@*tw=Gih~mE&_wi~OTp8+nK5R08j~=W}x2aDXTXy-QX5 z%h22oSy2zZgYf&vT#vxhTP8rKsKlrzU!K?RfDxDdM;x1uIv#;SO30ql3Wd2TF(Cu6 zjb=nna3G1)znnrjU3VZuVc>x>I%Xdz*I@C1I@l~P*JTIliwN^&4GuJ51asg`~{iW1$t2HR3_pS>ZP5io}f;mYW)Ha(`e?46A#dA zv&K4)kP;Oc?XJ_V6y2`)n09WWZCblZ`wHf#u;j`bNr7%%QgP1Gs?5zYdJ{PUl~{d2 zSE3dIjV5;bwW)Ww7fQ%|DlsbpuLwO>`07-zF5TaouR3j7A+Y z1ZvHE)JG-iqmy7N&6prB_i2iX07bl~=FM_-IfX^L=S+0HT$uvU053fpo-39WL!h+pYe TblY#I+Cxh>=D+HQJfHCZTXKyM literal 0 HcmV?d00001 diff --git a/app/ui/lib/Button.tsx b/app/ui/lib/Button.tsx index 1ced212a58..d77c893b6d 100644 --- a/app/ui/lib/Button.tsx +++ b/app/ui/lib/Button.tsx @@ -35,7 +35,7 @@ export const buttonStyle = ({ variant = 'primary', }: ButtonStyleProps = {}) => { return cn( - 'ox-button elevation-1 rounded inline-flex items-center justify-center align-top disabled:cursor-not-allowed shrink-0', + 'ox-button elevation-1 rounded inline-flex items-center justify-center align-top disabled:cursor-default shrink-0', `btn-${variant}`, sizeStyle[size], variant === 'danger' @@ -87,7 +87,7 @@ export const Button = forwardRef( return ( } + with={} >
{items.length > 0 && ( {!allowArbitraryValues && filteredItems.length === 0 && ( @@ -149,13 +191,9 @@ export const Combobox = ({ )} {filteredItems.map((item) => ( { - onChange(item.label) - setQuery(item.label) - }} > {({ focus, selected }) => ( // This *could* be done with data-[focus] and data-[selected] instead, but diff --git a/app/ui/lib/CopyableIp.tsx b/app/ui/lib/CopyableIp.tsx index 39841c77c3..291860f2b1 100644 --- a/app/ui/lib/CopyableIp.tsx +++ b/app/ui/lib/CopyableIp.tsx @@ -11,7 +11,7 @@ export const CopyableIp = ({ ip, isLinked = true }: { ip: string; isLinked?: boo {isLinked ? ( ({ const noItems = !isLoading && items.length === 0 const isDisabled = disabled || noItems const zIndex = usePopoverZIndex() + const id = useId() return (
@@ -83,13 +83,20 @@ export const Listbox = ({ <> {label && (
- - + + {label} - {description && {description}} + {description && ( + {description} + )}
)} void variant?: Variant timeout?: number | null @@ -82,7 +84,7 @@ export const Toast = ({ const timeout = timeoutArg === undefined ? defaultTimeout : timeoutArg // TODO: consider assertive announce for error toasts useEffect( - () => announce((title || defaultTitle[variant]) + ' ' + content, 'polite'), + () => announce((title || defaultTitle[variant]) + ' ' + extractText(content), 'polite'), [title, content, variant] ) return ( @@ -95,8 +97,13 @@ export const Toast = ({ >
{icon[variant]}
-
{title || defaultTitle[variant]}
-
{content}
+ {(title || variant !== 'success') && ( +
{title || defaultTitle[variant]}
+ )} + {/* 'group' is necessary for HL color trick to work. see HL.tsx */} +
+ {content} +
{cta && ( {child} diff --git a/app/ui/styles/components/menu-button.css b/app/ui/styles/components/menu-button.css index 2a88a87233..f80a7be044 100644 --- a/app/ui/styles/components/menu-button.css +++ b/app/ui/styles/components/menu-button.css @@ -7,7 +7,8 @@ */ .DropdownMenuContent { - @apply z-popover min-w-36 rounded border p-0 bg-raise border-secondary; + /* we want menu popover to be on top of top bar and pagination bar too */ + @apply z-topBarDropdown min-w-36 rounded border p-0 bg-raise border-secondary; & .DropdownMenuItem { @apply block w-full cursor-pointer select-none border-b py-2 pl-3 pr-6 text-left text-sans-md text-secondary border-secondary last:border-b-0; diff --git a/app/ui/styles/components/menu-list.css b/app/ui/styles/components/menu-list.css index 4241dec636..3aa0d0ff10 100644 --- a/app/ui/styles/components/menu-list.css +++ b/app/ui/styles/components/menu-list.css @@ -28,6 +28,9 @@ .ox-menu-item.is-selected { @apply border-0 text-accent bg-accent-secondary hover:bg-accent-secondary-hover; + .ox-badge { + @apply ring-0 text-inverse bg-accent; + } } /* beautiful ring */ diff --git a/app/ui/styles/components/mini-table.css b/app/ui/styles/components/mini-table.css index 0da91522b9..5741a795f4 100644 --- a/app/ui/styles/components/mini-table.css +++ b/app/ui/styles/components/mini-table.css @@ -29,7 +29,7 @@ } & td > div { - @apply flex h-11 items-center border-y py-3 pl-3 text-accent bg-accent-secondary border-accent-tertiary; + @apply flex h-11 items-center border-y py-3 pl-3 pr-6 text-accent bg-accent-secondary border-accent-tertiary; } & td:last-child > div { diff --git a/app/ui/styles/fonts.css b/app/ui/styles/fonts.css index ea1d311a29..ba3be2319c 100644 --- a/app/ui/styles/fonts.css +++ b/app/ui/styles/fonts.css @@ -58,8 +58,8 @@ @font-face { font-family: 'SuisseIntl'; src: - url('../assets/fonts/SuisseIntl-Book-WebS.woff2') format('woff2'), - url('../assets/fonts/SuisseIntl-Book-WebS.woff') format('woff'); - font-weight: 600; + url('../assets/fonts/SuisseIntl-Medium-WebS.woff2') format('woff2'), + url('../assets/fonts/SuisseIntl-Medium-WebS.woff') format('woff'); + font-weight: 500; font-style: normal; } diff --git a/app/util/str.spec.ts b/app/util/str.spec.tsx similarity index 78% rename from app/util/str.spec.ts rename to app/util/str.spec.tsx index 3a4a8a28e2..1be63c62d8 100644 --- a/app/util/str.spec.ts +++ b/app/util/str.spec.tsx @@ -7,7 +7,14 @@ */ import { describe, expect, it } from 'vitest' -import { camelCase, capitalize, commaSeries, kebabCase, titleCase } from './str' +import { + camelCase, + capitalize, + commaSeries, + extractText, + kebabCase, + titleCase, +} from './str' describe('capitalize', () => { it('capitalizes the first letter', () => { @@ -76,3 +83,30 @@ describe('titleCase', () => { expect(titleCase('123 abc')).toBe('123 Abc') }) }) + +describe('extractText', () => { + it('extracts strings from React components', () => { + expect( + extractText( + <> + This is my text + + ) + ).toBe('This is my text') + }) + it('extracts strings from nested elements', () => { + expect( + extractText( +

+ This is my{' '} + + nested text + +

+ ) + ).toBe('This is my nested text') + }) + it('can handle regular strings', () => { + expect(extractText('Some more text')).toBe('Some more text') + }) +}) diff --git a/app/util/str.ts b/app/util/str.ts index 934530917c..a7620050c9 100644 --- a/app/util/str.ts +++ b/app/util/str.ts @@ -6,6 +6,8 @@ * Copyright Oxide Computer Company */ +import React from 'react' + export const capitalize = (s: string) => s && s.charAt(0).toUpperCase() + s.slice(1) export const pluralize = (s: string, n: number) => `${n} ${s}${n === 1 ? '' : 's'}` @@ -55,3 +57,19 @@ export const titleCase = (text: string): string => { * it look like `AAAAAAAAAAAAAAAA==`? */ export const isAllZeros = (base64Data: string) => /^A*=*$/.test(base64Data) + +/** + * Extract the string contents of a ReactNode, so <>This highlighted text becomes "This highlighted text" + */ +export const extractText = (children: React.ReactNode): string => + React.Children.toArray(children) + .map((child) => + typeof child === 'string' + ? child + : React.isValidElement(child) + ? extractText(child.props.children) + : '' + ) + .join(' ') + .trim() + .replace(/\s+/g, ' ') diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 0bcbf0433b..9848fb1282 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1523,20 +1523,20 @@ export const handlers = makeHandlers({ certificateView: NotImplemented, instanceSerialConsoleStream: NotImplemented, instanceSshPublicKeyList: NotImplemented, - ipPoolServiceRangeAdd: NotImplemented, - ipPoolServiceRangeList: NotImplemented, - ipPoolServiceRangeRemove: NotImplemented, - ipPoolServiceView: NotImplemented, - internetGatewayIpAddressList: NotImplemented, + internetGatewayCreate: NotImplemented, + internetGatewayDelete: NotImplemented, internetGatewayIpAddressCreate: NotImplemented, internetGatewayIpAddressDelete: NotImplemented, - internetGatewayIpPoolList: NotImplemented, + internetGatewayIpAddressList: NotImplemented, + internetGatewayIpPoolCreate: NotImplemented, internetGatewayIpPoolDelete: NotImplemented, + internetGatewayIpPoolList: NotImplemented, internetGatewayList: NotImplemented, - internetGatewayCreate: NotImplemented, - internetGatewayIpPoolCreate: NotImplemented, internetGatewayView: NotImplemented, - internetGatewayDelete: NotImplemented, + ipPoolServiceRangeAdd: NotImplemented, + ipPoolServiceRangeList: NotImplemented, + ipPoolServiceRangeRemove: NotImplemented, + ipPoolServiceView: NotImplemented, localIdpUserCreate: NotImplemented, localIdpUserDelete: NotImplemented, localIdpUserSetPassword: NotImplemented, diff --git a/mock-api/silo.ts b/mock-api/silo.ts index fb14ccd4c3..c764e0852d 100644 --- a/mock-api/silo.ts +++ b/mock-api/silo.ts @@ -85,6 +85,7 @@ export const samlIdp: Json = { slo_url: '', sp_client_id: '', technical_contact_email: '', + group_attribute_name: 'groups', } // This works differently from Nexus, but the result is the same. In Nexus, diff --git a/package-lock.json b/package-lock.json index 5abd81619f..93f82c9c84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1968,17 +1968,16 @@ "license": "MIT" }, "node_modules/@oxide/design-system": { - "version": "1.4.6", - "resolved": "https://registry.npmjs.org/@oxide/design-system/-/design-system-1.4.6.tgz", - "integrity": "sha512-arhKAI6sS/QUs1z5H30RQ+/bWPgKifE7KrwkVnDqjBRI4jirmAfa/td1X3hh8fN3ef3yUrkcECPjxygyQjikZg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@oxide/design-system/-/design-system-1.5.0.tgz", + "integrity": "sha512-kzfHaSSl7aCUYQllCkgRW5ncYTcDYYIbaiE+HseyQ5S5JgWfkeOXrnEc525M8m697CT/S9jM7OxTFevX+VqEDw==", "license": "MPL 2.0", "dependencies": { "@figma-export/output-components-as-svgr": "^4.7.0", "@floating-ui/react": "^0.25.1", "@headlessui/react": "^1.7.17", "@radix-ui/react-tabs": "^1.0.4", - "html-entities": "^2.4.0", - "react-router-dom": "^6.15.0" + "html-entities": "^2.4.0" }, "peerDependencies": { "@oxide/react-asciidoc": "^0.2.8", @@ -18435,9 +18434,9 @@ } }, "node_modules/typescript": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz", - "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==", + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/tailwind.config.js b/tailwind.config.js index be83d8b78e..9868c46e9e 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -48,7 +48,7 @@ module.exports = { modal: '40', sideModalDropdown: '40', sideModal: '30', - topBarPopover: '25', + topBarDropdown: '25', topBar: '20', popover: '10', contentDropdown: '10', diff --git a/test/e2e/disks.e2e.ts b/test/e2e/disks.e2e.ts index 03398c4cd6..e735f7dad6 100644 --- a/test/e2e/disks.e2e.ts +++ b/test/e2e/disks.e2e.ts @@ -5,7 +5,15 @@ * * Copyright Oxide Computer Company */ -import { clickRowAction, expect, expectRowVisible, expectVisible, test } from './utils' +import { + clickRowAction, + expect, + expectNoToast, + expectRowVisible, + expectToast, + expectVisible, + test, +} from './utils' test('List disks and snapshot', async ({ page }) => { await page.goto('/projects/mock-project/disks') @@ -28,8 +36,11 @@ test('List disks and snapshot', async ({ page }) => { }) await clickRowAction(page, 'disk-1 db1', 'Snapshot') - await expect(page.getByText("Creating snapshot of disk 'disk-1'").nth(0)).toBeVisible() - await expect(page.getByText('Snapshot successfully created').nth(0)).toBeVisible() + await expectToast(page, 'Creating snapshot of disk disk-1') + // expectToast should have closed the toast already, but verify + await expectNoToast(page, 'Creating snapshot of disk disk-1') + // Next line is a little awkward, but we don't actually know what the snapshot name will be + await expectToast(page, /Snapshot disk-1-[a-z0-9]{6} created/) }) test('Disk snapshot error', async ({ page }) => { @@ -37,11 +48,13 @@ test('Disk snapshot error', async ({ page }) => { // special disk that triggers snapshot error await clickRowAction(page, 'disk-snapshot-error', 'Snapshot') - await expect( - page.getByText("Creating snapshot of disk 'disk-snapshot-error'").nth(0) - ).toBeVisible() - await expect(page.getByText('Failed to create snapshot').nth(0)).toBeVisible() - await expect(page.getByText('Cannot snapshot disk').nth(0)).toBeVisible() + await expectToast(page, 'Creating snapshot of disk disk-snapshot-error') + // just including an actual expect to satisfy the linter + await expect(page.getByRole('cell', { name: 'disk-snapshot-error' })).toBeVisible() + // expectToast should have closed the toast already, but let's just verify … + await expectNoToast(page, 'Creating snapshot of disk disk-snapshot-error') + // … before we can check for the error toast + await expectToast(page, 'Failed to create snapshotCannot snapshot disk') }) test.describe('Disk create', () => { @@ -53,7 +66,7 @@ test.describe('Disk create', () => { test.afterEach(async ({ page }) => { await page.getByRole('button', { name: 'Create disk' }).click() - await expectVisible(page, ['text="Your disk has been created"']) + await expectToast(page, 'Disk a-new-disk created') await expectVisible(page, ['role=cell[name="a-new-disk"]']) }) diff --git a/test/e2e/floating-ip-create.e2e.ts b/test/e2e/floating-ip-create.e2e.ts index 62ba9d2d8c..4bedc596ce 100644 --- a/test/e2e/floating-ip-create.e2e.ts +++ b/test/e2e/floating-ip-create.e2e.ts @@ -28,19 +28,19 @@ test('can create a floating IP', async ({ page }) => { .getByRole('textbox', { name: 'Description' }) .fill('A description for this Floating IP') - const poolListbox = page.getByRole('button', { name: 'IP pool' }) + const label = page.getByLabel('IP pool') // accordion content should be hidden - await expect(poolListbox).toBeHidden() + await expect(label).toBeHidden() // open accordion await page.getByRole('button', { name: 'Advanced' }).click() // accordion content should be visible - await expect(poolListbox).toBeVisible() + await expect(label).toBeVisible() // choose pool and submit - await poolListbox.click() + await label.click() await page.getByRole('option', { name: 'ip-pool-1' }).click() await page.getByRole('button', { name: 'Create floating IP' }).click() @@ -83,7 +83,7 @@ test('can detach and attach a floating IP', async ({ page }) => { // Now click back to floating IPs and reattach it to db1 await page.getByRole('link', { name: 'Floating IPs' }).click() await clickRowAction(page, 'cola-float', 'Attach') - await page.getByRole('button', { name: 'Select an instance' }).click() + await page.getByLabel('Instance').click() await page.getByRole('option', { name: 'db1' }).click() await page.getByRole('button', { name: 'Attach' }).click() diff --git a/test/e2e/floating-ip-update.e2e.ts b/test/e2e/floating-ip-update.e2e.ts index 68bcf0d05d..4ce1179c90 100644 --- a/test/e2e/floating-ip-update.e2e.ts +++ b/test/e2e/floating-ip-update.e2e.ts @@ -6,7 +6,14 @@ * Copyright Oxide Computer Company */ -import { clickRowAction, expect, expectRowVisible, expectVisible, test } from './utils' +import { + clickRowAction, + expect, + expectRowVisible, + expectToast, + expectVisible, + test, +} from './utils' const floatingIpsPage = '/projects/mock-project/floating-ips' const originalName = 'cola-float' @@ -32,6 +39,7 @@ test('can update a floating IP', async ({ page }) => { name: updatedName, description: updatedDescription, }) + await expectToast(page, `Floating IP ${updatedName} updated`) }) // Make sure that it still works even if the name doesn't change @@ -47,4 +55,5 @@ test('can update *just* the floating IP description', async ({ page }) => { name: originalName, description: updatedDescription, }) + await expectToast(page, `Floating IP ${originalName} updated`) }) diff --git a/test/e2e/images.e2e.ts b/test/e2e/images.e2e.ts index 0c15a42811..d78f83ca0a 100644 --- a/test/e2e/images.e2e.ts +++ b/test/e2e/images.e2e.ts @@ -12,8 +12,10 @@ import { clipboardText, expect, expectNotVisible, + expectToast, expectVisible, getPageAsUser, + selectOption, } from './utils' test('can promote an image from silo', async ({ page }) => { @@ -25,33 +27,33 @@ test('can promote an image from silo', async ({ page }) => { // Listboxes are visible await expect(page.getByPlaceholder('Select a project')).toBeVisible() - await expect(page.locator(`text="Select an image"`)).toBeVisible() + // have to use a locator here because the disabled button needs to be handled differently + await expect(page.locator(`text="Select an image"`)).toBeDisabled() // Notice is visible await expect(page.getByText('visible to all projects')).toBeVisible() // Select a project - await page.locator('role=button[name*="Project"]').click() - await page.locator('role=option[name="other-project"]').click() + await selectOption(page, 'Project', 'other-project') - // Should have no items - // and buttons should be disabled - await expect(page.locator(`text="No items"`)).toBeVisible() - await expect(page.locator('role=button[name*="Image"]')).toBeDisabled() + // Should have no items and dropdown should be disabled + await expect(page.locator(`text="No items"`)).toBeDisabled() // Select the other project - await page.locator('role=button[name*="Project"]').click() - await page.locator('role=option[name="mock-project"]').click() + // this blurring should not be necessary, but it's blocking the test otherwise + await page.getByRole('combobox', { name: 'Project' }).blur() + await page.getByRole('combobox', { name: 'Project' }).click() + await page.getByRole('option', { name: 'mock-project' }).click() // Select an image in that project const imageListbox = page.locator('role=button[name*="Image"]') - await expect(imageListbox).toBeEnabled({ timeout: 5000 }) + await expect(imageListbox).toBeEnabled() await imageListbox.click() await page.locator('role=option >> text="image-1"').click() await page.locator('role=button[name="Promote"]').click() // Check it was promoted successfully - await expectVisible(page, ['text="image-1 has been promoted"']) + await expect(page.getByText('Image image-1 promoted', { exact: true })).toBeVisible() await expectVisible(page, ['role=cell[name="image-1"]']) }) @@ -67,7 +69,7 @@ test('can promote an image from project', async ({ page }) => { // Promote image and check it was successful await page.locator('role=button[name="Promote"]').click() - await expectVisible(page, ['text="image-2 has been promoted"']) + await expect(page.getByText('Image image-2 promoted', { exact: true })).toBeVisible() await expectNotVisible(page, ['role=cell[name="image-2"]']) await page.click('role=link[name="View silo images"]') @@ -102,20 +104,18 @@ test('can demote an image from silo', async ({ page }) => { await expect(page.getByText('Demoting: arch-2022-06-01')).toBeVisible() // Cannot demote without first selecting a project - await page.locator('role=button[name="Demote"]').click() + await page.getByRole('button', { name: 'Demote' }).click() await expect( page.getByRole('dialog', { name: 'Demote' }).getByText('Project is required') ).toBeVisible() - // Select an project to demote it - const imageListbox = page.locator('role=button[name*="Project"]') - await expect(imageListbox).toBeEnabled({ timeout: 5000 }) - await imageListbox.click() - await page.locator('role=option >> text="mock-project"').click() - await page.locator('role=button[name="Demote"]').click() + await selectOption(page, 'Project', 'mock-project') + await page.getByRole('button', { name: 'Demote' }).click() - // Promote image and check it was successful - await expectVisible(page, ['text="arch-2022-06-01 has been demoted"']) + // Demote image and check it was successful + await expect( + page.getByText('Image arch-2022-06-01 demoted', { exact: true }) + ).toBeVisible() await expectNotVisible(page, ['role=cell[name="arch-2022-06-01"]']) await page.click('role=link[name="View images in mock-project"]') @@ -135,7 +135,7 @@ test('can delete an image from a project', async ({ page }) => { await expect(spinner).toBeVisible() // Check deletion was successful - await expect(page.getByText('image-3 has been deleted', { exact: true })).toBeVisible() + await expectToast(page, 'Image image-3 deleted') await expect(cell).toBeHidden() await expect(spinner).toBeHidden() }) @@ -153,9 +153,7 @@ test('can delete an image from a silo', async ({ page }) => { await expect(spinner).toBeVisible() // Check deletion was successful - await expect( - page.getByText('ubuntu-20-04 has been deleted', { exact: true }) - ).toBeVisible() + await expectToast(page, 'Image ubuntu-20-04 deleted') await expect(cell).toBeHidden() await expect(spinner).toBeHidden() }) diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index 90c6e8dd12..e2e2125100 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -12,19 +12,20 @@ import { expectNotVisible, expectRowVisible, expectVisible, + selectOption, test, type Page, } from './utils' const selectASiloImage = async (page: Page, name: string) => { await page.getByRole('tab', { name: 'Silo images' }).click() - await page.getByLabel('Image', { exact: true }).click() + await page.getByPlaceholder('Select a silo image', { exact: true }).click() await page.getByRole('option', { name }).click() } const selectAProjectImage = async (page: Page, name: string) => { await page.getByRole('tab', { name: 'Project images' }).click() - await page.getByLabel('Image', { exact: true }).click() + await page.getByPlaceholder('Select a project image', { exact: true }).click() await page.getByRole('option', { name }).click() } @@ -69,27 +70,23 @@ test('can create an instance', async ({ page }) => { await page.getByRole('button', { name: 'Networking' }).click() await page.getByRole('button', { name: 'Configuration' }).click() - const assignEphemeralIpCheckbox = page.getByRole('checkbox', { + const checkbox = page.getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address', }) - const assignEphemeralIpButton = page.getByRole('button', { - name: 'IP pool for ephemeral IP', - }) + const label = page.getByLabel('IP pool for ephemeral IP') // verify that the ip pool selector is visible and default is selected - await expect(assignEphemeralIpCheckbox).toBeChecked() - await assignEphemeralIpButton.click() + await expect(checkbox).toBeChecked() + await label.click() await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeEnabled() - await assignEphemeralIpButton.click() // click closes the listbox so we can do more stuff // unchecking the box should disable the selector - await assignEphemeralIpCheckbox.uncheck() - await expect(assignEphemeralIpButton).toBeHidden() + await checkbox.uncheck() + await expect(label).toBeHidden() // re-checking the box should re-enable the selector, and other options should be selectable - await assignEphemeralIpCheckbox.check() - await assignEphemeralIpButton.click() - await page.getByRole('option', { name: 'ip-pool-2' }).click() + await checkbox.check() + await selectOption(page, 'IP pool for ephemeral IP', 'ip-pool-2 VPN IPs') // should be visible in accordion await expect(page.getByRole('radiogroup', { name: 'Network interface' })).toBeVisible() @@ -356,21 +353,24 @@ test('additional disks do not list committed disks as available', async ({ page test('maintains selected values even when changing tabs', async ({ page }) => { const instanceName = 'arch-based-instance' + const arch = 'arch-2022-06-01' await page.goto('/projects/mock-project/instances-new') await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) - await page.getByRole('button', { name: 'Image' }).click() - // select the arch option - await page.getByRole('option', { name: 'arch-2022-06-01' }).click() + const imageSelectCombobox = page.getByRole('combobox', { name: 'Image' }) + // Filter the combobox for a particular silo image + await imageSelectCombobox.fill('arch') + // select the image + await page.getByRole('option', { name: arch }).click() // expect to find name of the image on page - await expect(page.getByText('arch-2022-06-01')).toBeVisible() + await expect(imageSelectCombobox).toHaveValue(arch) // change to a different tab await page.getByRole('tab', { name: 'Existing disks' }).click() // the image should no longer be visible - await expect(page.getByText('arch-2022-06-01')).toBeHidden() + await expect(imageSelectCombobox).toBeHidden() // change back to the tab with the image await page.getByRole('tab', { name: 'Silo images' }).click() // arch should still be selected - await expect(page.getByText('arch-2022-06-01')).toBeVisible() + await expect(imageSelectCombobox).toHaveValue(arch) 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']) @@ -394,9 +394,10 @@ test('does not attach an ephemeral IP when the checkbox is unchecked', async ({ test('attaches a floating IP; disables button when no IPs available', async ({ page }) => { const attachFloatingIpButton = page.getByRole('button', { name: 'Attach floating IP' }) - const selectFloatingIpButton = page.getByRole('button', { name: 'Select a floating ip' }) + const dialog = page.getByRole('dialog') + const selectFloatingIpButton = dialog.getByRole('button', { name: 'Floating IP' }) const rootbeerFloatOption = page.getByRole('option', { name: 'rootbeer-float' }) - const attachButton = page.getByRole('button', { name: 'Attach', exact: true }) + const attachButton = dialog.getByRole('button', { name: 'Attach', exact: true }) const instanceName = 'with-floating-ip' await page.goto('/projects/mock-project/instances-new') @@ -491,7 +492,7 @@ test('attaching additional disks allows for combobox filtering', async ({ page } // now options hidden and only the selected one is visible in the button/input await expect(page.getByRole('option')).toBeHidden() - await expect(page.getByRole('button', { name: 'disk-0102' })).toBeVisible() + await expect(page.getByRole('combobox', { name: 'Disk name' })).toHaveValue('disk-0102') // a random string should give a disabled option await selectADisk.click() @@ -518,8 +519,7 @@ test('create instance with additional disks', async ({ page }) => { // Attach an existing disk await page.getByRole('button', { name: 'Attach existing disk' }).click() - await page.getByRole('button', { name: 'Disk name' }).click() - await page.getByRole('option', { name: 'disk-3' }).click() + await selectOption(page, 'Disk name', 'disk-3') await page.getByRole('button', { name: 'Attach disk' }).click() await expectRowVisible(disksTable, { Name: 'disk-3', Type: 'attach', Size: '—' }) diff --git a/test/e2e/instance-disks.e2e.ts b/test/e2e/instance-disks.e2e.ts index c0c0cfeafa..b53e872e4d 100644 --- a/test/e2e/instance-disks.e2e.ts +++ b/test/e2e/instance-disks.e2e.ts @@ -8,8 +8,10 @@ import { clickRowAction, expect, + expectNoToast, expectNotVisible, expectRowVisible, + expectToast, expectVisible, stopInstance, test, @@ -86,7 +88,7 @@ test('Attach disk', async ({ page }) => { await page.getByRole('button', { name: 'Attach disk' }).click() await expectVisible(page, ['role=dialog >> text="Disk name is required"']) - await page.click('role=button[name*="Disk name"]') + await page.getByRole('combobox', { name: 'Disk name' }).click() // disk-1 is already attached, so should not be visible in the list await expectNotVisible(page, ['role=option[name="disk-1"]']) await expectVisible(page, ['role=option[name="disk-3"]', 'role=option[name="disk-4"]']) @@ -130,7 +132,7 @@ test('Detach disk', async ({ page }) => { // Have to stop instance to edit disks await stopInstance(page) - const successMsg = page.getByText('Disk detached').nth(0) + const successMsg = page.getByText('Disk disk-2 detached').first() const row = page.getByRole('row', { name: 'disk-2' }) await expect(row).toBeVisible() await expect(successMsg).toBeHidden() @@ -143,13 +145,13 @@ test('Detach disk', async ({ page }) => { test('Snapshot disk', async ({ page }) => { await page.goto('/projects/mock-project/instances/db1') - // have to use nth with toasts because the text shows up in multiple spots - const successMsg = page.getByText('Snapshot created').nth(0) - await expect(successMsg).toBeHidden() + // we don't know the full name of the disk, but this will work to find the toast + const toastMessage = /Snapshot disk-1-[a-z0-9]{6} created/ + await expectNoToast(page, toastMessage) await clickRowAction(page, 'disk-1', 'Snapshot') - await expect(successMsg).toBeVisible() // we see the toast! + await expectToast(page, toastMessage) // we see the toast! // now go see the snapshot on the snapshots page await page.getByRole('link', { name: 'Snapshots' }).click() diff --git a/test/e2e/instance-networking.e2e.ts b/test/e2e/instance-networking.e2e.ts index 26b868a751..f74b1b91b3 100644 --- a/test/e2e/instance-networking.e2e.ts +++ b/test/e2e/instance-networking.e2e.ts @@ -129,7 +129,7 @@ test('Instance networking tab — floating IPs', async ({ page }) => { // Select the 'rootbeer-float' option const dialog = page.getByRole('dialog') // TODO: this "select the option" syntax is awkward; it's working, but I suspect there's a better way - await dialog.getByRole('button', { name: 'Select a floating IP' }).click() + await dialog.getByLabel('Floating IP').click() await page.keyboard.press('ArrowDown') await page.keyboard.press('Enter') // await dialog.getByRole('button', { name: 'rootbeer-float' }).click() diff --git a/test/e2e/instance-serial.e2e.ts b/test/e2e/instance-serial.e2e.ts index 450dcfdd06..417ad1044c 100644 --- a/test/e2e/instance-serial.e2e.ts +++ b/test/e2e/instance-serial.e2e.ts @@ -11,7 +11,7 @@ test('serial console can connect while starting', async ({ page }) => { // create an instance await page.goto('/projects/mock-project/instances-new') await page.getByRole('textbox', { name: 'Name', exact: true }).fill('abc') - await page.getByLabel('Image', { exact: true }).click() + await page.getByPlaceholder('Select a silo image').click() await page.getByRole('option', { name: 'ubuntu-22-04' }).click() await page.getByRole('button', { name: 'Create instance' }).click() diff --git a/test/e2e/instance.e2e.ts b/test/e2e/instance.e2e.ts index 6f967f46c9..75629efbb7 100644 --- a/test/e2e/instance.e2e.ts +++ b/test/e2e/instance.e2e.ts @@ -46,6 +46,7 @@ test('can start a failed instance', async ({ page }) => { // now start the failed one await expectInstanceState(page, 'you-fail', 'failed') await clickRowAction(page, 'you-fail', 'Start') + await page.getByRole('button', { name: 'Confirm' }).click() await expectInstanceState(page, 'you-fail', 'starting') }) @@ -93,6 +94,7 @@ test('can stop a starting instance, then start it again', async ({ page }) => { await expectInstanceState(page, 'not-there-yet', 'stopped') await clickRowAction(page, 'not-there-yet', 'Start') + await page.getByRole('button', { name: 'Confirm' }).click() await expectInstanceState(page, 'not-there-yet', 'starting') await expectInstanceState(page, 'not-there-yet', 'running') }) diff --git a/test/e2e/ip-pools.e2e.ts b/test/e2e/ip-pools.e2e.ts index df0de16b04..ea17e815dc 100644 --- a/test/e2e/ip-pools.e2e.ts +++ b/test/e2e/ip-pools.e2e.ts @@ -8,7 +8,7 @@ import { expect, test } from '@playwright/test' -import { clickRowAction, expectRowVisible } from './utils' +import { clickRowAction, expectRowVisible, expectToast } from './utils' test('IP pool list', async ({ page }) => { await page.goto('/system/networking/ip-pools') @@ -118,10 +118,10 @@ test('IP pool delete from IP Pools list page', async ({ page }) => { await expect(page.getByRole('dialog', { name: 'Confirm delete' })).toBeVisible() await page.getByRole('button', { name: 'Confirm' }).click() - await expect(page.getByText('Could not delete resource').first()).toBeVisible() - await expect( - page.getByText('IP pool cannot be deleted while it contains IP ranges').first() - ).toBeVisible() + await expectToast( + page, + 'Could not delete resourceIP pool cannot be deleted while it contains IP ranges' + ) await expect(page.getByRole('cell', { name: 'ip-pool-3' })).toBeVisible() diff --git a/test/e2e/project-create.e2e.ts b/test/e2e/project-create.e2e.ts index 6db69b6682..944c57a607 100644 --- a/test/e2e/project-create.e2e.ts +++ b/test/e2e/project-create.e2e.ts @@ -30,13 +30,15 @@ test.describe('Project create', () => { }) test('shows field-level validation error and does not POST', async ({ page }) => { - await page.fill('role=textbox[name="Name"]', 'Invalid name') - + const input = page.getByRole('textbox', { name: 'Name' }) + await input.pressSequentially('no sPoNgEbOb_CaSe or spaces') + await expect(input).toHaveValue('no-spongebob-case-or-spaces') + await input.fill('no-ending-dash-') // submit to trigger validation await page.getByRole('button', { name: 'Create project' }).click() await expect( - page.getByText('Can only contain lower-case letters, numbers, and dashes').nth(0) + page.getByText('Must end with a letter or number', { exact: true }).nth(0) ).toBeVisible() }) diff --git a/test/e2e/silos.e2e.ts b/test/e2e/silos.e2e.ts index b37b2e862a..3be8297cf1 100644 --- a/test/e2e/silos.e2e.ts +++ b/test/e2e/silos.e2e.ts @@ -186,6 +186,10 @@ test('Identity providers', async ({ page }) => { 'text="Single Logout (SLO) URL"', ]) + await expect(page.getByRole('textbox', { name: 'Group attribute name' })).toHaveValue( + 'groups' + ) + await page.getByRole('button', { name: 'Cancel' }).click() await expectNotVisible(page, ['role=dialog[name="Identity provider"]']) }) diff --git a/test/e2e/utils.ts b/test/e2e/utils.ts index 3d779c3122..9cb7864751 100644 --- a/test/e2e/utils.ts +++ b/test/e2e/utils.ts @@ -107,14 +107,28 @@ export async function expectRowVisible( } export async function stopInstance(page: Page) { - await page.getByRole('button', { name: 'Instance actions' }).click() - await page.getByRole('menuitem', { name: 'Stop' }).click() + await page.getByRole('button', { name: 'Stop' }).click() await page.getByRole('button', { name: 'Confirm' }).click() await closeToast(page) // don't need to manually refresh because of polling await expect(page.getByText('statestopped')).toBeVisible() } +/** + * Assert that a toast with text matching `expectedText` is visible. + */ +export async function expectToast(page: Page, expectedText: string | RegExp) { + await expect(page.getByTestId('Toasts')).toHaveText(expectedText) + await closeToast(page) +} + +/** + * Assert that a toast with text matching `expectedText` is not visible. + */ +export async function expectNoToast(page: Page, expectedText: string | RegExp) { + await expect(page.getByTestId('Toasts')).not.toHaveText(expectedText) +} + /** * Close toast and wait for it to fade out. For some reason it prevents things * from working, but only in tests as far as we can tell. @@ -142,18 +156,18 @@ export async function clickRowAction(page: Page, rowText: string, actionName: st /** * Select an option from a dropdown - * buttonLocator can either be the drodown's label text or a more elaborate Locator - * optionLocator can either be the drodown's label text or a more elaborate Locator + * labelLocator can either be the dropdown's label text or a more elaborate Locator + * optionLocator can either be the dropdown's option text or a more elaborate Locator * */ export async function selectOption( page: Page, - buttonLocator: string | Locator, + labelLocator: string | Locator, optionLocator: string | Locator ) { - if (typeof buttonLocator === 'string') { - await page.getByRole('button', { name: buttonLocator }).click() + if (typeof labelLocator === 'string') { + await page.getByLabel(labelLocator, { exact: true }).click() } else { - await buttonLocator.click() + await labelLocator.click() } if (typeof optionLocator === 'string') { await page.getByRole('option', { name: optionLocator, exact: true }).click() From 057a421e847390331f341c28370bcb6e10fef658 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 18 Nov 2024 18:51:24 -0600 Subject: [PATCH 06/14] bump API to latest main (resize got merged) --- OMICRON_VERSION | 2 +- app/api/__generated__/Api.ts | 73 ++++++++++++++------------- app/api/__generated__/OMICRON_VERSION | 2 +- app/api/__generated__/msw-handlers.ts | 44 ++++++++-------- app/api/__generated__/validate.ts | 42 +++++++-------- 5 files changed, 85 insertions(+), 78 deletions(-) diff --git a/OMICRON_VERSION b/OMICRON_VERSION index 4b7f82c891..b7a49a9e40 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -055e19ef71aa42dda9d6415b883c7e015c773353 +9c8aa5372fede528bff8e69fd88cabda0e92fac4 diff --git a/app/api/__generated__/Api.ts b/app/api/__generated__/Api.ts index 47563b090c..1f8c44ea0a 100644 --- a/app/api/__generated__/Api.ts +++ b/app/api/__generated__/Api.ts @@ -2323,8 +2323,8 @@ export type TxEqConfig = { export type LinkConfigCreate = { /** Whether or not to set autonegotiation */ autoneg: boolean - /** The forward error correction mode of the link. */ - fec: LinkFec + /** The requested forward-error correction method. If this is not specified, the standard FEC for the underlying media will be applied if it can be determined. */ + fec?: LinkFec /** The link-layer discovery protocol (LLDP) configuration for the link. */ lldp: LldpLinkConfigCreate /** Maximum transmission unit for the link. */ @@ -3508,8 +3508,8 @@ export type SwitchPortConfigCreate = { export type SwitchPortLinkConfig = { /** Whether or not the link has autonegotiation enabled. */ autoneg: boolean - /** The forward error correction mode of the link. */ - fec: LinkFec + /** The requested forward-error correction method. If this is not specified, the standard FEC for the underlying media will be applied if it can be determined. */ + fec?: LinkFec /** The name of this link. */ linkName: string /** The link-layer discovery protocol service configuration id for this link. */ @@ -5094,6 +5094,11 @@ export interface SiloQuotasUpdatePathParams { silo: NameOrId } +export interface SystemTimeseriesSchemaListQueryParams { + limit?: number + pageToken?: string +} + export interface SiloUserListQueryParams { limit?: number pageToken?: string @@ -5129,11 +5134,6 @@ export interface SiloUtilizationViewPathParams { silo: NameOrId } -export interface TimeseriesSchemaListQueryParams { - limit?: number - pageToken?: string -} - export interface UserListQueryParams { group?: string limit?: number @@ -5367,10 +5367,10 @@ export type ApiListMethods = Pick< | 'systemQuotasList' | 'siloList' | 'siloIpPoolList' + | 'systemTimeseriesSchemaList' | 'siloUserList' | 'userBuiltinList' | 'siloUtilizationList' - | 'timeseriesSchemaList' | 'userList' | 'vpcRouterRouteList' | 'vpcRouterList' @@ -7975,6 +7975,34 @@ export class Api extends HttpClient { ...params, }) }, + /** + * Run timeseries query + */ + systemTimeseriesQuery: ( + { body }: { body: TimeseriesQuery }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/timeseries/query`, + method: 'POST', + body, + ...params, + }) + }, + /** + * List timeseries schemas + */ + systemTimeseriesSchemaList: ( + { query = {} }: { query?: SystemTimeseriesSchemaListQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/timeseries/schemas`, + method: 'GET', + query, + ...params, + }) + }, /** * List built-in (system) users in silo */ @@ -8057,31 +8085,6 @@ export class Api extends HttpClient { ...params, }) }, - /** - * Run timeseries query - */ - timeseriesQuery: ({ body }: { body: TimeseriesQuery }, params: FetchParams = {}) => { - return this.request({ - path: `/v1/timeseries/query`, - method: 'POST', - body, - ...params, - }) - }, - /** - * List timeseries schemas - */ - timeseriesSchemaList: ( - { query = {} }: { query?: TimeseriesSchemaListQueryParams }, - params: FetchParams = {} - ) => { - return this.request({ - path: `/v1/timeseries/schema`, - method: 'GET', - query, - ...params, - }) - }, /** * List users */ diff --git a/app/api/__generated__/OMICRON_VERSION b/app/api/__generated__/OMICRON_VERSION index ed6d131367..f7aa2810a6 100644 --- a/app/api/__generated__/OMICRON_VERSION +++ b/app/api/__generated__/OMICRON_VERSION @@ -1,2 +1,2 @@ # generated file. do not update manually. see docs/update-pinned-api.md -055e19ef71aa42dda9d6415b883c7e015c773353 +9c8aa5372fede528bff8e69fd88cabda0e92fac4 diff --git a/app/api/__generated__/msw-handlers.ts b/app/api/__generated__/msw-handlers.ts index 0e455163ac..0e63a74d00 100644 --- a/app/api/__generated__/msw-handlers.ts +++ b/app/api/__generated__/msw-handlers.ts @@ -1173,6 +1173,18 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable> + /** `POST /v1/system/timeseries/query` */ + systemTimeseriesQuery: (params: { + body: Json + req: Request + cookies: Record + }) => Promisable> + /** `GET /v1/system/timeseries/schemas` */ + systemTimeseriesSchemaList: (params: { + query: Api.SystemTimeseriesSchemaListQueryParams + req: Request + cookies: Record + }) => Promisable> /** `GET /v1/system/users` */ siloUserList: (params: { query: Api.SiloUserListQueryParams @@ -1210,18 +1222,6 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable> - /** `POST /v1/timeseries/query` */ - timeseriesQuery: (params: { - body: Json - req: Request - cookies: Record - }) => Promisable> - /** `GET /v1/timeseries/schema` */ - timeseriesSchemaList: (params: { - query: Api.TimeseriesSchemaListQueryParams - req: Request - cookies: Record - }) => Promisable> /** `GET /v1/users` */ userList: (params: { query: Api.UserListQueryParams @@ -2407,6 +2407,18 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { schema.SiloQuotasUpdate ) ), + http.post( + '/v1/system/timeseries/query', + handler(handlers['systemTimeseriesQuery'], null, schema.TimeseriesQuery) + ), + http.get( + '/v1/system/timeseries/schemas', + handler( + handlers['systemTimeseriesSchemaList'], + schema.SystemTimeseriesSchemaListParams, + null + ) + ), http.get( '/v1/system/users', handler(handlers['siloUserList'], schema.SiloUserListParams, null) @@ -2431,14 +2443,6 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { '/v1/system/utilization/silos/:silo', handler(handlers['siloUtilizationView'], schema.SiloUtilizationViewParams, null) ), - http.post( - '/v1/timeseries/query', - handler(handlers['timeseriesQuery'], null, schema.TimeseriesQuery) - ), - http.get( - '/v1/timeseries/schema', - handler(handlers['timeseriesSchemaList'], schema.TimeseriesSchemaListParams, null) - ), http.get('/v1/users', handler(handlers['userList'], schema.UserListParams, null)), http.get('/v1/utilization', handler(handlers['utilizationView'], null, null)), http.get( diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index 5ab3739e8c..f10953280c 100644 --- a/app/api/__generated__/validate.ts +++ b/app/api/__generated__/validate.ts @@ -2162,7 +2162,7 @@ export const LinkConfigCreate = z.preprocess( processResponseBody, z.object({ autoneg: SafeBoolean, - fec: LinkFec, + fec: LinkFec.optional(), lldp: LldpLinkConfigCreate, mtu: z.number().min(0).max(65535), speed: LinkSpeed, @@ -3228,7 +3228,7 @@ export const SwitchPortLinkConfig = z.preprocess( processResponseBody, z.object({ autoneg: SafeBoolean, - fec: LinkFec, + fec: LinkFec.optional(), linkName: z.string(), lldpLinkConfigId: z.string().uuid().optional(), mtu: z.number().min(0).max(65535), @@ -5788,6 +5788,25 @@ export const SiloQuotasUpdateParams = z.preprocess( }) ) +export const SystemTimeseriesQueryParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({}), + }) +) + +export const SystemTimeseriesSchemaListParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({ + limit: z.number().min(1).max(4294967295).optional(), + pageToken: z.string().optional(), + }), + }) +) + export const SiloUserListParams = z.preprocess( processResponseBody, z.object({ @@ -5857,25 +5876,6 @@ export const SiloUtilizationViewParams = z.preprocess( }) ) -export const TimeseriesQueryParams = z.preprocess( - processResponseBody, - z.object({ - path: z.object({}), - query: z.object({}), - }) -) - -export const TimeseriesSchemaListParams = z.preprocess( - processResponseBody, - z.object({ - path: z.object({}), - query: z.object({ - limit: z.number().min(1).max(4294967295).optional(), - pageToken: z.string().optional(), - }), - }) -) - export const UserListParams = z.preprocess( processResponseBody, z.object({ From f1e76e662f53c69f950a25398006bd96f2464324 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 25 Nov 2024 09:31:56 -0800 Subject: [PATCH 07/14] Update toast copy to match other success toasts --- app/pages/project/instances/instance/InstancePage.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/pages/project/instances/instance/InstancePage.tsx b/app/pages/project/instances/instance/InstancePage.tsx index 92ae068360..5cbcc1b55f 100644 --- a/app/pages/project/instances/instance/InstancePage.tsx +++ b/app/pages/project/instances/instance/InstancePage.tsx @@ -28,6 +28,7 @@ import { } from '~/api/util' import { ExternalIps } from '~/components/ExternalIps' import { NumberField } from '~/components/form/fields/NumberField' +import { HL } from '~/components/HL' import { InstanceDocsPopover } from '~/components/InstanceDocsPopover' import { MoreActionsMenu } from '~/components/MoreActionsMenu' import { RefreshButton } from '~/components/RefreshButton' @@ -279,7 +280,11 @@ export function ResizeInstanceModal({ } onDismiss() addToast({ - content: `${instance.name} has been resized`, + content: ( + <> + Instance {instance.name} resized + + ), cta: onListView ? { text: `View instance`, From 0c3ccc3d58509ef9bed35a3794e284ab7df8893c Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 25 Nov 2024 10:03:37 -0800 Subject: [PATCH 08/14] Add a few tests --- test/e2e/instance.e2e.ts | 60 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/test/e2e/instance.e2e.ts b/test/e2e/instance.e2e.ts index e011157bbf..325f314c58 100644 --- a/test/e2e/instance.e2e.ts +++ b/test/e2e/instance.e2e.ts @@ -139,6 +139,66 @@ test('cannot reboot a starting instance, or a stopped instance', async ({ page } await expect(page.getByRole('menuitem', { name: 'Reboot' })).toBeDisabled() }) +test('cannot resize a running or starting instance', async ({ page }) => { + await page.goto('/projects/mock-project/instances') + + await expectInstanceState(page, 'db1', 'running') + await openRowActions(page, 'db1') + await expect(page.getByRole('menuitem', { name: 'Resize' })).toBeDisabled() + + await expectInstanceState(page, 'not-there-yet', 'starting') + await openRowActions(page, 'not-there-yet') + await expect(page.getByRole('menuitem', { name: 'Resize' })).toBeDisabled() +}) + +test('can resize a failed or stopped instance', async ({ page }) => { + await page.goto('/projects/mock-project/instances') + const table = page.getByRole('table') + + // resize 'you-fail', currently in a failed state + await expectRowVisible(table, { + name: 'you-fail', + CPU: '4 vCPU', + Memory: '6 GiB', + state: expect.stringMatching(/^failed\d+s$/), + }) + await clickRowAction(page, 'you-fail', 'Resize') + const resizeModal = page.getByRole('dialog', { name: 'Resize instance' }) + await expect(resizeModal).toBeVisible() + await resizeModal.getByRole('textbox', { name: 'CPU' }).fill('10') + await resizeModal.getByRole('textbox', { name: 'Memory' }).fill('20') + await resizeModal.getByRole('button', { name: 'Resize' }).click() + await expectRowVisible(table, { + name: 'you-fail', + CPU: '10 vCPU', + Memory: '20 GiB', + state: expect.stringMatching(/^failed\d+s$/), + }) + + // resize 'db1', which needs to be stopped first + await expectRowVisible(table, { + name: 'db1', + CPU: '2 vCPU', + Memory: '4 GiB', + state: expect.stringMatching(/^running\d+s$/), + }) + await clickRowAction(page, 'db1', 'Stop') + await page.getByRole('button', { name: 'Confirm' }).click() + await expectInstanceState(page, 'db1', 'stopping') + await expectInstanceState(page, 'db1', 'stopped') + await clickRowAction(page, 'db1', 'Resize') + await expect(resizeModal).toBeVisible() + await resizeModal.getByRole('textbox', { name: 'CPU' }).fill('8') + await resizeModal.getByRole('textbox', { name: 'Memory' }).fill('16') + await resizeModal.getByRole('button', { name: 'Resize' }).click() + await expectRowVisible(table, { + name: 'db1', + CPU: '8 vCPU', + Memory: '16 GiB', + state: expect.stringMatching(/^stopped\d+s$/), + }) +}) + test('delete from instance detail', async ({ page }) => { await page.goto('/projects/mock-project/instances/you-fail') From 847e8de7f6da703bea921121dbda96bbfeb0c374 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 25 Nov 2024 14:22:57 -0800 Subject: [PATCH 09/14] Update info box message to be on one line --- .../project/instances/instance/InstancePage.tsx | 13 +++++-------- test/e2e/instance.e2e.ts | 4 ++-- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/app/pages/project/instances/instance/InstancePage.tsx b/app/pages/project/instances/instance/InstancePage.tsx index 5cbcc1b55f..58fb033aec 100644 --- a/app/pages/project/instances/instance/InstancePage.tsx +++ b/app/pages/project/instances/instance/InstancePage.tsx @@ -329,21 +329,18 @@ export function ResizeInstanceModal({ ) : ( -
- {instance.ncpus}{' '} - vCPUs / {instance.memory / GiB} GiB -
- +
+ Currently ({instance.name}): {instance.ncpus} vCPUs /{' '} + {instance.memory / GiB} GiB +
} /> )}
{ await clickRowAction(page, 'you-fail', 'Resize') const resizeModal = page.getByRole('dialog', { name: 'Resize instance' }) await expect(resizeModal).toBeVisible() - await resizeModal.getByRole('textbox', { name: 'CPU' }).fill('10') + await resizeModal.getByRole('textbox', { name: 'vCPUs' }).fill('10') await resizeModal.getByRole('textbox', { name: 'Memory' }).fill('20') await resizeModal.getByRole('button', { name: 'Resize' }).click() await expectRowVisible(table, { @@ -188,7 +188,7 @@ test('can resize a failed or stopped instance', async ({ page }) => { await expectInstanceState(page, 'db1', 'stopped') await clickRowAction(page, 'db1', 'Resize') await expect(resizeModal).toBeVisible() - await resizeModal.getByRole('textbox', { name: 'CPU' }).fill('8') + await resizeModal.getByRole('textbox', { name: 'vCPUs' }).fill('8') await resizeModal.getByRole('textbox', { name: 'Memory' }).fill('16') await resizeModal.getByRole('button', { name: 'Resize' }).click() await expectRowVisible(table, { From 22742e15847139724b610e2399b0a1b0979914a8 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 25 Nov 2024 18:49:33 -0600 Subject: [PATCH 10/14] currently -> current --- app/pages/project/instances/instance/InstancePage.tsx | 2 +- test/e2e/instance.e2e.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/pages/project/instances/instance/InstancePage.tsx b/app/pages/project/instances/instance/InstancePage.tsx index 58fb033aec..87410282e8 100644 --- a/app/pages/project/instances/instance/InstancePage.tsx +++ b/app/pages/project/instances/instance/InstancePage.tsx @@ -331,7 +331,7 @@ export function ResizeInstanceModal({ variant="info" content={
- Currently ({instance.name}): {instance.ncpus} vCPUs /{' '} + Current ({instance.name}): {instance.ncpus} vCPUs /{' '} {instance.memory / GiB} GiB
} diff --git a/test/e2e/instance.e2e.ts b/test/e2e/instance.e2e.ts index 12913d9866..51220b43df 100644 --- a/test/e2e/instance.e2e.ts +++ b/test/e2e/instance.e2e.ts @@ -182,12 +182,16 @@ test('can resize a failed or stopped instance', async ({ page }) => { Memory: '4 GiB', state: expect.stringMatching(/^running\d+s$/), }) + await clickRowAction(page, 'db1', 'Stop') await page.getByRole('button', { name: 'Confirm' }).click() await expectInstanceState(page, 'db1', 'stopping') await expectInstanceState(page, 'db1', 'stopped') + await clickRowAction(page, 'db1', 'Resize') await expect(resizeModal).toBeVisible() + await expect(resizeModal.getByText('Current (db1): 2 vCPUs / 4 GiB')).toBeVisible() + await resizeModal.getByRole('textbox', { name: 'vCPUs' }).fill('8') await resizeModal.getByRole('textbox', { name: 'Memory' }).fill('16') await resizeModal.getByRole('button', { name: 'Resize' }).click() From 0d4319ac74cfd28461eedef96c608466368bd0ba Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 25 Nov 2024 21:26:58 -0600 Subject: [PATCH 11/14] fix options ref instability causing table row actions to close on poll --- app/pages/project/instances/InstancesPage.tsx | 3 +-- app/pages/project/instances/actions.tsx | 13 +++++++------ .../project/instances/instance/InstancePage.tsx | 10 ++++++---- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/app/pages/project/instances/InstancesPage.tsx b/app/pages/project/instances/InstancesPage.tsx index 883b3a5a5c..e737cc97ee 100644 --- a/app/pages/project/instances/InstancesPage.tsx +++ b/app/pages/project/instances/InstancesPage.tsx @@ -85,7 +85,7 @@ export function InstancesPage() { { onSuccess: refetchInstances, onDelete: refetchInstances, - onResizeClick: (instance) => setResizeInstance(instance), + onResizeClick: setResizeInstance, } ) @@ -231,7 +231,6 @@ export function InstancesPage() { {resizeInstance && ( setResizeInstance(null)} onListView /> diff --git a/app/pages/project/instances/actions.tsx b/app/pages/project/instances/actions.tsx index ebac779925..b9eaf3c1f7 100644 --- a/app/pages/project/instances/actions.tsx +++ b/app/pages/project/instances/actions.tsx @@ -45,6 +45,8 @@ export const useMakeInstanceActions = ( onSuccess: options.onDelete, }) + const { onResizeClick } = options + const makeButtonActions = useCallback( (instance: Instance) => { const instanceParams = { path: { instance: instance.name }, query: { project } } @@ -141,11 +143,7 @@ export const useMakeInstanceActions = ( }, { label: 'Resize', - onActivate: () => { - if (options.onResizeClick) { - options.onResizeClick(instance) - } - }, + onActivate: () => onResizeClick?.(instance), disabled: !instanceCan.update(instance) && ( <>Only {fancifyStates(instanceCan.update.states)} instances can be resized ), @@ -169,7 +167,10 @@ export const useMakeInstanceActions = ( }, ] }, - [project, deleteInstanceAsync, rebootInstanceAsync, options] + // Do not put `options` in here, refer to the property. options is not ref + // stable. Extra renders here cause the row actions menu to close when it + // shouldn't, like during polling on instance list. + [project, deleteInstanceAsync, rebootInstanceAsync, onResizeClick] ) return { makeButtonActions, makeMenuActions } diff --git a/app/pages/project/instances/instance/InstancePage.tsx b/app/pages/project/instances/instance/InstancePage.tsx index 87410282e8..cc56f1e6bf 100644 --- a/app/pages/project/instances/instance/InstancePage.tsx +++ b/app/pages/project/instances/instance/InstancePage.tsx @@ -34,7 +34,11 @@ import { MoreActionsMenu } from '~/components/MoreActionsMenu' import { RefreshButton } from '~/components/RefreshButton' import { RouteTabs, Tab } from '~/components/RouteTabs' import { InstanceStateBadge } from '~/components/StateBadge' -import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params' +import { + getInstanceSelector, + useInstanceSelector, + useProjectSelector, +} from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { EmptyCell } from '~/table/cells/EmptyCell' import { Button } from '~/ui/lib/Button' @@ -252,7 +256,6 @@ export function InstancePage() { {resizeInstance && ( setResizeInstance(false)} /> )} @@ -262,15 +265,14 @@ export function InstancePage() { export function ResizeInstanceModal({ instance, - project, onDismiss, onListView = false, }: { instance: Instance - project: string onDismiss: () => void onListView?: boolean }) { + const { project } = useProjectSelector() const instanceUpdate = useApiMutation('instanceUpdate', { onSuccess(_updatedInstance) { if (onListView) { From 23ce2bebee80c44f608780bade2263c64a5f78fc Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 25 Nov 2024 21:45:02 -0600 Subject: [PATCH 12/14] the 'cannot resize' test relied on the bug fixed in the prev commit --- test/e2e/instance.e2e.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/e2e/instance.e2e.ts b/test/e2e/instance.e2e.ts index 51220b43df..338e2392f2 100644 --- a/test/e2e/instance.e2e.ts +++ b/test/e2e/instance.e2e.ts @@ -146,6 +146,8 @@ test('cannot resize a running or starting instance', async ({ page }) => { await openRowActions(page, 'db1') await expect(page.getByRole('menuitem', { name: 'Resize' })).toBeDisabled() + await page.keyboard.press('Escape') // get out of the menu + await expectInstanceState(page, 'not-there-yet', 'starting') await openRowActions(page, 'not-there-yet') await expect(page.getByRole('menuitem', { name: 'Resize' })).toBeDisabled() From ae726846a70b67fb2481e1311fba444888e89bd5 Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Tue, 26 Nov 2024 19:25:36 +0000 Subject: [PATCH 13/14] Make modals always full width (and a bit narrower) --- app/ui/lib/Modal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/ui/lib/Modal.tsx b/app/ui/lib/Modal.tsx index b1e69531ff..ab00a1a154 100644 --- a/app/ui/lib/Modal.tsx +++ b/app/ui/lib/Modal.tsx @@ -70,8 +70,8 @@ export function Modal({ Date: Tue, 26 Nov 2024 19:44:11 +0000 Subject: [PATCH 14/14] Truncate --- app/pages/project/instances/instance/InstancePage.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/pages/project/instances/instance/InstancePage.tsx b/app/pages/project/instances/instance/InstancePage.tsx index cc56f1e6bf..3766355587 100644 --- a/app/pages/project/instances/instance/InstancePage.tsx +++ b/app/pages/project/instances/instance/InstancePage.tsx @@ -49,7 +49,7 @@ import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { Spinner } from '~/ui/lib/Spinner' import { Tooltip } from '~/ui/lib/Tooltip' -import { Truncate } from '~/ui/lib/Truncate' +import { truncate, Truncate } from '~/ui/lib/Truncate' import { pb } from '~/util/path-builder' import { GiB } from '~/util/units' @@ -333,8 +333,9 @@ export function ResizeInstanceModal({ variant="info" content={
- Current ({instance.name}): {instance.ncpus} vCPUs /{' '} - {instance.memory / GiB} GiB + Current ( + {truncate(instance.name, 20)} + ): {instance.ncpus} vCPUs / {instance.memory / GiB} GiB
} />