diff --git a/app/components/RouteTabs.tsx b/app/components/RouteTabs.tsx new file mode 100644 index 0000000000..2fe4760879 --- /dev/null +++ b/app/components/RouteTabs.tsx @@ -0,0 +1,66 @@ +import cn from 'classnames' +import type { ReactNode } from 'react' +import { Link, Outlet } from 'react-router-dom' + +import { useIsActivePath } from 'app/hooks/use-is-active-path' + +const selectTab = (e: React.KeyboardEvent) => { + const target = e.target as HTMLDivElement + if (e.key === 'ArrowLeft') { + e.stopPropagation() + e.preventDefault() + + const sibling = (target.previousSibling ?? + target.parentElement!.lastChild!) as HTMLDivElement + + sibling.focus() + sibling.click() + } else if (e.key === 'ArrowRight') { + e.stopPropagation() + e.preventDefault() + + const sibling = (target.nextSibling ?? + target.parentElement!.firstChild!) as HTMLDivElement + + sibling.focus() + sibling.click() + } +} + +export interface RouteTabsProps { + children: ReactNode + fullWidth?: boolean +} +export function RouteTabs({ children, fullWidth }: RouteTabsProps) { + return ( +
+ {/* eslint-disable-next-line jsx-a11y/interactive-supports-focus */} +
+ {children} +
+ {/* TODO: Add aria-describedby for active tab */} +
+ +
+
+ ) +} + +export interface TabProps { + to: string + children: ReactNode +} +export const Tab = ({ to, children }: TabProps) => { + const isActive = useIsActivePath({ to }) + return ( + +
{children}
+ + ) +} diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index c532ea200c..7ea48200e7 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -93,7 +93,7 @@ export function CreateInstanceForm() { title: 'Success!', content: 'Your instance has been created.', }) - navigate(pb.instance({ ...pageParams, instanceName: instance.name })) + navigate(pb.instancePage({ ...pageParams, instanceName: instance.name })) }, }) diff --git a/app/hooks/use-is-active-path.ts b/app/hooks/use-is-active-path.ts new file mode 100644 index 0000000000..34380fb529 --- /dev/null +++ b/app/hooks/use-is-active-path.ts @@ -0,0 +1,30 @@ +import { useLocation, useResolvedPath } from 'react-router-dom' + +interface ActivePathOptions { + to: string + end?: boolean +} +/** + * Returns true if the provided path is currently active. + * + * This implementation is based on logic from React Router's NavLink component. + * + * @see https://github.com/remix-run/react-router/blob/67f16e73603765158c63a27afb70d3a4b3e823d3/packages/react-router-dom/index.tsx#L448-L467 + * + * @param to The path to check + * @param options.end Ensure this path isn't matched as "active" when its descendant paths are matched. + */ +export const useIsActivePath = ({ to, end }: ActivePathOptions) => { + const path = useResolvedPath(to) + const location = useLocation() + + const toPathname = path.pathname + const locationPathname = location.pathname + + return ( + locationPathname === toPathname || + (!end && + locationPathname.startsWith(toPathname) && + locationPathname.charAt(toPathname.length) === '/') + ) +} diff --git a/app/pages/__tests__/click-everything.e2e.ts b/app/pages/__tests__/click-everything.e2e.ts index ac3217b874..2710dbe5d2 100644 --- a/app/pages/__tests__/click-everything.e2e.ts +++ b/app/pages/__tests__/click-everything.e2e.ts @@ -17,7 +17,7 @@ test("Click through everything and make it's all there", async ({ page }) => { 'role=heading[name*=db1]', 'role=tab[name="Storage"]', 'role=tab[name="Metrics"]', - 'role=tab[name="Networking"]', + 'role=tab[name="Network Interfaces"]', 'role=table[name="Boot disk"] >> role=cell[name="disk-1"]', 'role=table[name="Attached disks"] >> role=cell[name="disk-2"]', // buttons disabled while instance is running diff --git a/app/pages/__tests__/instance/networking.e2e.ts b/app/pages/__tests__/instance/networking.e2e.ts index b8a77d34f9..3c19433b34 100644 --- a/app/pages/__tests__/instance/networking.e2e.ts +++ b/app/pages/__tests__/instance/networking.e2e.ts @@ -8,7 +8,7 @@ test('Instance networking tab', async ({ page }) => { await page.goto('/orgs/maze-war/projects/mock-project/instances/db1') // Instance networking tab - await page.click('role=tab[name="Networking"]') + await page.click('role=tab[name="Network Interfaces"]') const table = page.locator('table') await expectRowVisible(table, { name: 'my-nic', primary: 'primary' }) diff --git a/app/pages/project/disks/DisksPage.tsx b/app/pages/project/disks/DisksPage.tsx index ea65a1b7a3..f7aa37379a 100644 --- a/app/pages/project/disks/DisksPage.tsx +++ b/app/pages/project/disks/DisksPage.tsx @@ -43,7 +43,7 @@ function AttachedInstance({ return instance ? ( {instance.name} diff --git a/app/pages/project/instances/InstancesPage.tsx b/app/pages/project/instances/InstancesPage.tsx index 2c981a7e84..e69bd77562 100644 --- a/app/pages/project/instances/InstancesPage.tsx +++ b/app/pages/project/instances/InstancesPage.tsx @@ -67,7 +67,8 @@ export function InstancesPage() { { value: 'New instance', onSelect: () => navigate(pb.instanceNew(projectParams)) }, ...(instances?.items || []).map((i) => ({ value: i.name, - onSelect: () => navigate(pb.instance({ ...projectParams, instanceName: i.name })), + onSelect: () => + navigate(pb.instancePage({ ...projectParams, instanceName: i.name })), navGroup: 'Go to instance', })), ], @@ -103,7 +104,7 @@ export function InstancesPage() { - pb.instance({ orgName, projectName, instanceName }) + pb.instancePage({ orgName, projectName, instanceName }) )} /> import('./tabs/MetricsTab')) - -const InstanceTabs = memo(() => ( - - Storage - - - - Metrics - - - - - - Networking - - - - Serial Console - - - - -)) InstancePage.loader = async ({ params }: LoaderFunctionArgs) => { await apiQueryClient.prefetchQuery('instanceView', { @@ -111,7 +83,12 @@ export function InstancePage() { - + + Storage + Metrics + Network Interfaces + Serial Console + ) } diff --git a/app/pages/project/instances/instance/SerialConsolePage.tsx b/app/pages/project/instances/instance/SerialConsolePage.tsx deleted file mode 100644 index c392017baf..0000000000 --- a/app/pages/project/instances/instance/SerialConsolePage.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Suspense } from 'react' -import React from 'react' - -import { useApiQuery } from '@oxide/api' -import { Button, Divider, PageHeader, PageTitle, Terminal24Icon } from '@oxide/ui' -import { MiB } from '@oxide/util' - -import { PageActions } from 'app/components/PageActions' -import { useRequiredParams } from 'app/hooks' - -const Terminal = React.lazy(() => import('app/components/Terminal')) - -export function SerialConsolePage() { - const instanceParams = useRequiredParams('orgName', 'projectName', 'instanceName') - - const { data, refetch } = useApiQuery( - 'instanceSerialConsole', - { path: instanceParams, query: { maxBytes: 10 * MiB, fromStart: 0 } }, - { refetchOnWindowFocus: false } - ) - - return ( - <> - - }>Serial Console - - - Loading}> - - - -
- -
-
- - ) -} diff --git a/app/pages/project/instances/instance/tabs/MetricsTab.tsx b/app/pages/project/instances/instance/tabs/MetricsTab.tsx index e71adb8b2b..feb3753858 100644 --- a/app/pages/project/instances/instance/tabs/MetricsTab.tsx +++ b/app/pages/project/instances/instance/tabs/MetricsTab.tsx @@ -80,7 +80,7 @@ function DiskMetrics({ disks }: { disks: Disk[] }) { return ( <> -
+
+ <>

Boot disk

@@ -175,6 +175,6 @@ export function StorageTab() { {showDiskAttach && ( setShowDiskAttach(false)} /> )} -
+ ) } diff --git a/app/routes.tsx b/app/routes.tsx index 2542e59d5f..7906fcf0ab 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { Suspense } from 'react' import { Navigate, Route, createRoutesFromElements } from 'react-router-dom' import { RouterDataErrorBoundary } from './components/ErrorBoundary' @@ -40,12 +40,18 @@ import { VpcPage, VpcsPage, } from './pages/project' -import { SerialConsolePage } from './pages/project/instances/instance/SerialConsolePage' +import { NetworkingTab } from './pages/project/instances/instance/tabs/NetworkingTab' +import { SerialConsoleTab } from './pages/project/instances/instance/tabs/SerialConsoleTab' +import { StorageTab } from './pages/project/instances/instance/tabs/StorageTab' import { ProfilePage } from './pages/settings/ProfilePage' import { SSHKeysPage } from './pages/settings/SSHKeysPage' import SilosPage from './pages/system/SilosPage' import { pb } from './util/path-builder' +const MetricsTab = React.lazy( + () => import('./pages/project/instances/instance/tabs/MetricsTab') +) + const orgCrumb: CrumbFunc = (m) => m.params.orgName! const projectCrumb: CrumbFunc = (m) => m.params.projectName! const instanceCrumb: CrumbFunc = (m) => m.params.instanceName! @@ -162,15 +168,39 @@ export const routes = createRoutesFromElements( loader={CreateInstanceForm.loader} handle={{ crumb: 'New instance' }} /> + } + /> } loader={InstancesPage.loader} /> - } loader={InstancePage.loader} /> - } - handle={{ crumb: 'serial-console' }} - /> + } loader={InstancePage.loader}> + } + handle={{ crumb: 'storage' }} + /> + } + handle={{ crumb: 'network-interfaces' }} + /> + + + + } + handle={{ crumb: 'metrics' }} + /> + } + handle={{ crumb: 'serial-console' }} + /> + diff --git a/app/test/instance-create.e2e.ts b/app/test/instance-create.e2e.ts index 8c90364dfc..7c87d9ca6f 100644 --- a/app/test/instance-create.e2e.ts +++ b/app/test/instance-create.e2e.ts @@ -1,6 +1,7 @@ import { globalImages } from '@oxide/api-mocks' import { expectVisible, test } from 'app/test/e2e' +import { pb } from 'app/util/path-builder' test.beforeEach(async ({ createProject, orgName, projectName }) => { await createProject(orgName, projectName) @@ -12,7 +13,7 @@ test('can invoke instance create form from instances page', async ({ projectName, genName, }) => { - await page.goto(`/orgs/${orgName}/projects/${projectName}/instances`) + await page.goto(pb.instances({ orgName, projectName })) await page.locator('text="New Instance"').click() await expectVisible(page, [ @@ -41,9 +42,7 @@ test('can invoke instance create form from instances page', async ({ await page.locator('button:has-text("Create instance")').click() - await page.waitForURL( - `/orgs/${orgName}/projects/${projectName}/instances/${instanceName}` - ) + await page.waitForURL(pb.instancePage({ orgName, projectName, instanceName })) await expectVisible(page, [ `h1:has-text("${instanceName}")`, diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 9ef6db123d..c4735aa77d 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -17,8 +17,12 @@ test('path builder', () => { "diskNew": "/orgs/a/projects/b/disks-new", "disks": "/orgs/a/projects/b/disks", "instance": "/orgs/a/projects/b/instances/c", + "instanceMetrics": "/orgs/a/projects/b/instances/c/metrics", "instanceNew": "/orgs/a/projects/b/instances-new", + "instancePage": "/orgs/a/projects/b/instances/c/storage", + "instanceStorage": "/orgs/a/projects/b/instances/c/storage", "instances": "/orgs/a/projects/b/instances", + "nics": "/orgs/a/projects/b/instances/c/network-interfaces", "org": "/orgs/a", "orgAccess": "/orgs/a/access", "orgEdit": "/orgs/a/edit", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 1f995d1c31..1c49939b91 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -19,6 +19,21 @@ export const pb = { instances: (params: PP.Project) => `${pb.project(params)}/instances`, instanceNew: (params: PP.Project) => `${pb.project(params)}/instances-new`, instance: (params: PP.Instance) => `${pb.instances(params)}/${params.instanceName}`, + + /** + * This route exists as a direct link to the default tab of the instance page. Unfortunately + * we don't currently have a good mechanism at the moment to handle a redirect to the default + * tab in a seemless way so we need all in-app links to go directly to the default tab. + * + * @see https://github.com/oxidecomputer/console/pull/1267#discussion_r1016766205 + */ + instancePage: (params: PP.Instance) => pb.instanceStorage(params), + + instanceMetrics: (params: PP.Instance) => `${pb.instance(params)}/metrics`, + instanceStorage: (params: PP.Instance) => `${pb.instance(params)}/storage`, + + nics: (params: PP.Instance) => `${pb.instance(params)}/network-interfaces`, + serialConsole: (params: PP.Instance) => `${pb.instance(params)}/serial-console`, diskNew: (params: PP.Project) => `${pb.project(params)}/disks-new`, diff --git a/libs/ui/lib/tabs/Tabs.css b/libs/ui/lib/tabs/Tabs.css index 3c52902520..b4a33e5f75 100644 --- a/libs/ui/lib/tabs/Tabs.css +++ b/libs/ui/lib/tabs/Tabs.css @@ -1,34 +1,58 @@ -[data-reach-tabs] { +.ox-tabs.full-width { + @apply !mx-0 !w-full; } -[data-reach-tab-list] { +.ox-tabs.full-width .ox-tabs-panel { + @apply mx-[var(--content-gutter)]; +} +.ox-tabs.full-width .ox-tabs-panel > * { + @apply w-[calc(100%-var(--content-gutter)*2)]; +} + +.ox-tabs-list { @apply mb-8 bg-transparent; } -[data-reach-tab-panels] { +.ox-tabs-list:after { + @apply block w-full border-b border-secondary; + content: ' '; +} +.ox-tabs.full-width .ox-tabs-list:before { + @apply block w-10 min-w-max flex-shrink-0 border-b border-secondary; + content: ' '; } -[data-reach-tab] { - @apply h-10 space-x-2 whitespace-nowrap px-[6px] - uppercase text-mono-sm text-tertiary border-secondary; +.ox-tabs-panel:focus-visible { + @apply outline outline-2 outline-offset-[1rem] outline-accent-secondary; } -[data-reach-tab][data-selected] > div { - @apply hover:bg-secondary; +.ox-tab { + @apply h-10 space-x-2 whitespace-nowrap border-b px-1.5 pb-1 pt-2 + uppercase !no-underline text-mono-sm text-tertiary border-secondary; } -[data-reach-tab][data-selected] { +.ox-tab[data-selected], +.ox-tab.is-selected { @apply text-accent border-accent; } -[data-reach-tab] > .ox-badge { +.ox-tab > * { + @apply rounded bg-transparent px-1.5 py-1; +} +.ox-tab:hover > * { + @apply bg-secondary; +} + +.ox-tab > .ox-badge { @apply -mt-1 select-none text-current; } -[data-reach-tab]:not([data-selected]) > .ox-badge { +.ox-tab:not([data-selected]) > .ox-badge, +.ox-tab:not(.is-selected) > .ox-badge { @apply bg-disabled; } -[data-reach-tab][data-selected] > .ox-badge { +.ox-tab[data-selected] > .ox-badge, +.ox-tab.is-selected > .ox-badge { @apply bg-accent-secondary; } diff --git a/libs/ui/lib/tabs/Tabs.tsx b/libs/ui/lib/tabs/Tabs.tsx index cde3f80662..1eff005db7 100644 --- a/libs/ui/lib/tabs/Tabs.tsx +++ b/libs/ui/lib/tabs/Tabs.tsx @@ -41,37 +41,25 @@ export function Tabs({ addProps((i, panelProps) => ({ key: `${id}-panel-${i}`, index: i, - className: cn( - fullWidth && - 'children:mx-[var(--content-gutter)] children:w-[calc(100%-var(--content-gutter)*2)]', - panelProps.className - ), + className: cn('ox-tabs-panel', panelProps.className), })) ) return [tabs, panels] - }, [children, fullWidth, id]) + }, [children, id]) invariant( tabs.length === panels.length, 'Expected there to be exactly one Tab for every Tab.Panel' ) - const after = 'after:block after:w-full after:border-b after:border-secondary' - const before = - 'before:block before:min-w-max before:w-10 before:border-b before:flex-shrink-0 before:border-secondary' - return ( - + {tabs} {panels} @@ -85,10 +73,8 @@ export type TabProps = Assign & { } export function Tab({ className, ...props }: TabProps) { return ( - -
- {props.children} -
+ +
{props.children}
) }