From b25eb91531ae66ff8de0cf17f5bdf556da703081 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 18 Feb 2025 23:22:59 -0600 Subject: [PATCH 1/3] get some route modules into framework shape --- .eslintrc.cjs | 5 +- .oxlintrc.json | 21 +++- app/components/TopBar.tsx | 2 +- app/hooks/use-current-user.ts | 34 +++++ app/hooks/use-quick-actions.tsx | 2 +- app/layouts/AuthenticatedLayout.tsx | 31 +---- app/layouts/ProjectLayout.tsx | 111 +---------------- app/layouts/ProjectLayoutBase.tsx | 117 ++++++++++++++++++ app/layouts/RootLayout.tsx | 4 +- app/layouts/SerialConsoleLayout.tsx | 15 +++ app/layouts/SiloLayout.tsx | 2 +- app/layouts/SystemLayout.tsx | 2 +- app/pages/SiloUtilizationPage.tsx | 2 +- app/pages/project/disks/DisksPage.tsx | 4 +- app/pages/project/instances/InstancesPage.tsx | 5 +- app/pages/settings/ProfilePage.tsx | 2 +- app/routes.tsx | 41 +++--- vite.config.ts | 9 ++ 18 files changed, 242 insertions(+), 167 deletions(-) create mode 100644 app/hooks/use-current-user.ts create mode 100644 app/layouts/ProjectLayoutBase.tsx create mode 100644 app/layouts/SerialConsoleLayout.tsx diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 85776837e5..e30ee4b3c7 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -102,8 +102,9 @@ module.exports = { ignorePatterns: ['dist/', 'node_modules/', 'tools/deno/'], overrides: [ { - // default export is needed in config files - files: ['*.config.ts'], + // default exports are needed in the route modules and the config files, + // but we want to avoid them anywhere else + files: ['app/pages/**/*', 'app/layouts/**/*', '*.config.ts'], rules: { 'import/no-default-export': 'off' }, }, { diff --git a/.oxlintrc.json b/.oxlintrc.json index ad6b95f25e..cd62c9fc06 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,5 +1,13 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": [ + "import", + // defaults + "react", + "unicorn", + "typescript", + "oxc" + ], "rules": { // only worry about console.log "no-console": ["error", { "allow": ["warn", "error", "info", "table"] }], @@ -16,7 +24,18 @@ "react/jsx-boolean-value": "error", "react-hooks/exhaustive-deps": "error", - "react-hooks/rules-of-hooks": "error" + "react-hooks/rules-of-hooks": "error", + "import/no-default-export": "error" }, + "overrides": [ + { + // default exports are needed in the route modules and the config files, + // but we want to avoid them anywhere else + "files": ["app/pages/**/*", "app/layouts/**/*", "*.config.ts", "*.config.mjs"], + "rules": { + "import/no-default-export": "off" + } + } + ], "ignorePatterns": ["dist/", "node_modules/"] } diff --git a/app/components/TopBar.tsx b/app/components/TopBar.tsx index a0115ab386..ac7a449a44 100644 --- a/app/components/TopBar.tsx +++ b/app/components/TopBar.tsx @@ -18,7 +18,7 @@ import { } from '@oxide/design-system/icons/react' import { useCrumbs } from '~/hooks/use-crumbs' -import { useCurrentUser } from '~/layouts/AuthenticatedLayout' +import { useCurrentUser } from '~/hooks/use-current-user' import { buttonStyle } from '~/ui/lib/Button' import * as DropdownMenu from '~/ui/lib/DropdownMenu' import { Identicon } from '~/ui/lib/Identicon' diff --git a/app/hooks/use-current-user.ts b/app/hooks/use-current-user.ts new file mode 100644 index 0000000000..39be910045 --- /dev/null +++ b/app/hooks/use-current-user.ts @@ -0,0 +1,34 @@ +/* + * 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 { useApiQueryErrorsAllowed, usePrefetchedApiQuery } from '~/api/client' +import { invariant } from '~/util/invariant' + +/** + * Access all the data fetched by the loader. Because of the `shouldRevalidate` + * trick, that loader runs on every authenticated page, which means callers do + * not have to worry about hitting these endpoints themselves in their own + * loaders. + */ +export function useCurrentUser() { + const { data: me } = usePrefetchedApiQuery('currentUserView', {}) + const { data: myGroups } = usePrefetchedApiQuery('currentUserGroups', {}) + + // User can only get to system routes if they have viewer perms (at least) on + // the fleet. The natural place to find out whether they have such perms is + // the fleet (system) policy, but if the user doesn't have fleet read, we'll + // get a 403 from that endpoint. So we simply check whether that endpoint 200s + // or not to determine whether the user is a fleet viewer. + const { data: systemPolicy } = useApiQueryErrorsAllowed('systemPolicyView', {}) + // don't use usePrefetchedApiQuery because it's not worth making an errors + // allowed version of that + invariant(systemPolicy, 'System policy must be prefetched') + const isFleetViewer = systemPolicy.type === 'success' + + return { me, myGroups, isFleetViewer } +} diff --git a/app/hooks/use-quick-actions.tsx b/app/hooks/use-quick-actions.tsx index 303c4ec8b5..f9f2051f30 100644 --- a/app/hooks/use-quick-actions.tsx +++ b/app/hooks/use-quick-actions.tsx @@ -9,7 +9,7 @@ import { useEffect, useMemo } from 'react' import { useLocation, useNavigate } from 'react-router' import { create } from 'zustand' -import { useCurrentUser } from '~/layouts/AuthenticatedLayout' +import { useCurrentUser } from '~/hooks/use-current-user' import { ActionMenu, type QuickActionItem } from '~/ui/lib/ActionMenu' import { invariant } from '~/util/invariant' import { pb } from '~/util/path-builder' diff --git a/app/layouts/AuthenticatedLayout.tsx b/app/layouts/AuthenticatedLayout.tsx index aec1399da2..1a9ab00786 100644 --- a/app/layouts/AuthenticatedLayout.tsx +++ b/app/layouts/AuthenticatedLayout.tsx @@ -7,16 +7,15 @@ */ import { Outlet } from 'react-router' -import { apiQueryClient, useApiQueryErrorsAllowed, usePrefetchedApiQuery } from '@oxide/api' +import { apiQueryClient } from '@oxide/api' import { QuickActions } from '~/hooks/use-quick-actions' -import { invariant } from '~/util/invariant' /** * We use `shouldRevalidate={() => true}` to force this to re-run on every nav, * but the longer-than-default `staleTime` avoids fetching too much. */ -AuthenticatedLayout.loader = async () => { +export async function clientLoader() { const staleTime = 60000 await Promise.all([ apiQueryClient.prefetchQuery('currentUserView', {}, { staleTime }), @@ -38,7 +37,7 @@ AuthenticatedLayout.loader = async () => { } /** Wraps all authenticated routes. */ -export function AuthenticatedLayout() { +export default function AuthenticatedLayout() { return ( <> @@ -46,27 +45,3 @@ export function AuthenticatedLayout() { ) } - -/** - * Access all the data fetched by the loader. Because of the `shouldRevalidate` - * trick, that loader runs on every authenticated page, which means callers do - * not have to worry about hitting these endpoints themselves in their own - * loaders. - */ -export function useCurrentUser() { - const { data: me } = usePrefetchedApiQuery('currentUserView', {}) - const { data: myGroups } = usePrefetchedApiQuery('currentUserGroups', {}) - - // User can only get to system routes if they have viewer perms (at least) on - // the fleet. The natural place to find out whether they have such perms is - // the fleet (system) policy, but if the user doesn't have fleet read, we'll - // get a 403 from that endpoint. So we simply check whether that endpoint 200s - // or not to determine whether the user is a fleet viewer. - const { data: systemPolicy } = useApiQueryErrorsAllowed('systemPolicyView', {}) - // don't use usePrefetchedApiQuery because it's not worth making an errors - // allowed version of that - invariant(systemPolicy, 'System policy must be prefetched') - const isFleetViewer = systemPolicy.type === 'success' - - return { me, myGroups, isFleetViewer } -} diff --git a/app/layouts/ProjectLayout.tsx b/app/layouts/ProjectLayout.tsx index 4e365a8613..963fc1fec0 100644 --- a/app/layouts/ProjectLayout.tsx +++ b/app/layouts/ProjectLayout.tsx @@ -5,113 +5,10 @@ * * Copyright Oxide Computer Company */ -import { useMemo, type ReactElement } from 'react' -import { useLocation, useNavigate, type LoaderFunctionArgs } from 'react-router' +import { ProjectLayoutBase, projectLayoutLoader } from './ProjectLayoutBase.tsx' -import { apiq, queryClient, usePrefetchedQuery } from '@oxide/api' -import { - Access16Icon, - Folder16Icon, - Images16Icon, - Instances16Icon, - IpGlobal16Icon, - Networking16Icon, - Snapshots16Icon, - Storage16Icon, -} from '@oxide/design-system/icons/react' +export const clientLoader = projectLayoutLoader -import { TopBar } from '~/components/TopBar' -import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' -import { useQuickActions } from '~/hooks/use-quick-actions' -import { Divider } from '~/ui/lib/Divider' -import { pb } from '~/util/path-builder' -import type * as PP from '~/util/path-params' - -import { DocsLinkItem, NavLinkItem, Sidebar } from '../components/Sidebar' -import { ContentPane, PageContainer } from './helpers' - -type ProjectLayoutProps = { - /** Sometimes we need a different layout for the content pane. Like - * ``, the element passed here should contain an ``. - */ - overrideContentPane?: ReactElement -} - -const projectView = ({ project }: PP.Project) => apiq('projectView', { path: { project } }) - -ProjectLayout.loader = async ({ params }: LoaderFunctionArgs) => { - const { project } = getProjectSelector(params) - await queryClient.prefetchQuery(projectView({ project })) - return null -} - -export function ProjectLayout({ overrideContentPane }: ProjectLayoutProps) { - const navigate = useNavigate() - // project will always be there, instance may not - const projectSelector = useProjectSelector() - const { data: project } = usePrefetchedQuery(projectView(projectSelector)) - - const { pathname } = useLocation() - useQuickActions( - useMemo( - () => - [ - { value: 'Instances', path: pb.instances(projectSelector) }, - { value: 'Disks', path: pb.disks(projectSelector) }, - { value: 'Snapshots', path: pb.snapshots(projectSelector) }, - { value: 'Images', path: pb.projectImages(projectSelector) }, - { value: 'VPCs', path: pb.vpcs(projectSelector) }, - { value: 'Floating IPs', path: pb.floatingIps(projectSelector) }, - { value: 'Access', path: pb.projectAccess(projectSelector) }, - ] - // filter out the entry for the path we're currently on - .filter((i) => i.path !== pathname) - .map((i) => ({ - navGroup: `Project '${project.name}'`, - value: i.value, - onSelect: () => navigate(i.path), - })), - [pathname, navigate, project.name, projectSelector] - ) - ) - - return ( - - - - - - - Projects - - - - - - - Instances - - - Disks - - - Snapshots - - - Images - - - VPCs - - - Floating IPs - - - Access - - - - {overrideContentPane || } - - ) +export default function ProjectLayout() { + return } diff --git a/app/layouts/ProjectLayoutBase.tsx b/app/layouts/ProjectLayoutBase.tsx new file mode 100644 index 0000000000..36cd754d45 --- /dev/null +++ b/app/layouts/ProjectLayoutBase.tsx @@ -0,0 +1,117 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { useMemo, type ReactElement } from 'react' +import { useLocation, useNavigate, type LoaderFunctionArgs } from 'react-router' + +import { apiq, queryClient, usePrefetchedQuery } from '@oxide/api' +import { + Access16Icon, + Folder16Icon, + Images16Icon, + Instances16Icon, + IpGlobal16Icon, + Networking16Icon, + Snapshots16Icon, + Storage16Icon, +} from '@oxide/design-system/icons/react' + +import { TopBar } from '~/components/TopBar' +import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' +import { useQuickActions } from '~/hooks/use-quick-actions' +import { Divider } from '~/ui/lib/Divider' +import { pb } from '~/util/path-builder' +import type * as PP from '~/util/path-params' + +import { DocsLinkItem, NavLinkItem, Sidebar } from '../components/Sidebar' +import { ContentPane, PageContainer } from './helpers' + +type ProjectLayoutProps = { + /** Sometimes we need a different layout for the content pane. Like + * ``, the element passed here should contain an ``. + */ + overrideContentPane?: ReactElement +} + +const projectView = ({ project }: PP.Project) => apiq('projectView', { path: { project } }) + +export async function projectLayoutLoader({ params }: LoaderFunctionArgs) { + const { project } = getProjectSelector(params) + await queryClient.prefetchQuery(projectView({ project })) + return null +} + +export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) { + const navigate = useNavigate() + // project will always be there, instance may not + const projectSelector = useProjectSelector() + const { data: project } = usePrefetchedQuery(projectView(projectSelector)) + + const { pathname } = useLocation() + useQuickActions( + useMemo( + () => + [ + { value: 'Instances', path: pb.instances(projectSelector) }, + { value: 'Disks', path: pb.disks(projectSelector) }, + { value: 'Snapshots', path: pb.snapshots(projectSelector) }, + { value: 'Images', path: pb.projectImages(projectSelector) }, + { value: 'VPCs', path: pb.vpcs(projectSelector) }, + { value: 'Floating IPs', path: pb.floatingIps(projectSelector) }, + { value: 'Access', path: pb.projectAccess(projectSelector) }, + ] + // filter out the entry for the path we're currently on + .filter((i) => i.path !== pathname) + .map((i) => ({ + navGroup: `Project '${project.name}'`, + value: i.value, + onSelect: () => navigate(i.path), + })), + [pathname, navigate, project.name, projectSelector] + ) + ) + + return ( + + + + + + + Projects + + + + + + + Instances + + + Disks + + + Snapshots + + + Images + + + VPCs + + + Floating IPs + + + Access + + + + {overrideContentPane || } + + ) +} diff --git a/app/layouts/RootLayout.tsx b/app/layouts/RootLayout.tsx index d71348e0fe..890ad3c992 100644 --- a/app/layouts/RootLayout.tsx +++ b/app/layouts/RootLayout.tsx @@ -16,7 +16,7 @@ import { useCrumbs } from '~/hooks/use-crumbs' * non top-level route: Instances / mock-project / Projects / maze-war / Oxide Console * top-level route: Oxide Console */ -export const useTitle = () => +const useTitle = () => useCrumbs() .map((c) => c.label) .reverse() @@ -27,7 +27,7 @@ export const useTitle = () => * Root layout that applies to the entire app. Modify sparingly. It's rare for * anything to actually belong here. */ -export function RootLayout() { +export default function RootLayout() { const title = useTitle() useEffect(() => { document.title = title diff --git a/app/layouts/SerialConsoleLayout.tsx b/app/layouts/SerialConsoleLayout.tsx new file mode 100644 index 0000000000..4d70c026c4 --- /dev/null +++ b/app/layouts/SerialConsoleLayout.tsx @@ -0,0 +1,15 @@ +/* + * 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 { SerialConsoleContentPane } from './helpers.tsx' +import { ProjectLayoutBase, projectLayoutLoader } from './ProjectLayoutBase.tsx' + +export const clientLoader = projectLayoutLoader + +export default function ProjectLayout() { + return } /> +} diff --git a/app/layouts/SiloLayout.tsx b/app/layouts/SiloLayout.tsx index 0a89115eac..1a3ff5e0ac 100644 --- a/app/layouts/SiloLayout.tsx +++ b/app/layouts/SiloLayout.tsx @@ -17,11 +17,11 @@ import { import { DocsLinkItem, NavLinkItem, Sidebar } from '~/components/Sidebar' import { TopBar } from '~/components/TopBar' +import { useCurrentUser } from '~/hooks/use-current-user' import { useQuickActions } from '~/hooks/use-quick-actions' import { Divider } from '~/ui/lib/Divider' import { pb } from '~/util/path-builder' -import { useCurrentUser } from './AuthenticatedLayout' import { ContentPane, PageContainer } from './helpers' export function SiloLayout() { diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx index 65f61164f1..ae8909e61a 100644 --- a/app/layouts/SystemLayout.tsx +++ b/app/layouts/SystemLayout.tsx @@ -19,11 +19,11 @@ import { import { trigger404 } from '~/components/ErrorBoundary' import { DocsLinkItem, NavLinkItem, Sidebar } from '~/components/Sidebar' import { TopBar } from '~/components/TopBar' +import { useCurrentUser } from '~/hooks/use-current-user' import { useQuickActions } from '~/hooks/use-quick-actions' import { Divider } from '~/ui/lib/Divider' import { pb } from '~/util/path-builder' -import { useCurrentUser } from './AuthenticatedLayout' import { ContentPane, PageContainer } from './helpers' /** diff --git a/app/pages/SiloUtilizationPage.tsx b/app/pages/SiloUtilizationPage.tsx index 2ab2eaf68d..df95181cd3 100644 --- a/app/pages/SiloUtilizationPage.tsx +++ b/app/pages/SiloUtilizationPage.tsx @@ -17,7 +17,7 @@ import { DocsPopover } from '~/components/DocsPopover' import { useDateTimeRangePicker } from '~/components/form/fields/DateTimeRangePicker' import { useIntervalPicker } from '~/components/RefetchIntervalPicker' import { SiloMetric } from '~/components/SystemMetric' -import { useCurrentUser } from '~/layouts/AuthenticatedLayout' +import { useCurrentUser } from '~/hooks/use-current-user' import { Divider } from '~/ui/lib/Divider' import { Listbox } from '~/ui/lib/Listbox' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' diff --git a/app/pages/project/disks/DisksPage.tsx b/app/pages/project/disks/DisksPage.tsx index 7b51b36118..f3b97bc618 100644 --- a/app/pages/project/disks/DisksPage.tsx +++ b/app/pages/project/disks/DisksPage.tsx @@ -54,7 +54,7 @@ const instanceList = ({ project }: PP.Project) => getListQFn('instanceList', { query: { project, limit: 200 } }) const diskList = (query: PP.Project) => getListQFn('diskList', { query }) -DisksPage.loader = async ({ params }: LoaderFunctionArgs) => { +export async function clientLoader({ params }: LoaderFunctionArgs) { const { project } = getProjectSelector(params) await Promise.all([ queryClient.prefetchQuery(diskList({ project }).optionsFn()), @@ -93,7 +93,7 @@ const staticCols = [ colHelper.accessor('timeCreated', Columns.timeCreated), ] -export function DisksPage() { +export default function DisksPage() { const { project } = useProjectSelector() const { mutateAsync: deleteDisk } = useApiMutation('diskDelete', { diff --git a/app/pages/project/instances/InstancesPage.tsx b/app/pages/project/instances/InstancesPage.tsx index c527b507a7..f3558491f7 100644 --- a/app/pages/project/instances/InstancesPage.tsx +++ b/app/pages/project/instances/InstancesPage.tsx @@ -62,7 +62,7 @@ const instanceList = ( options?: Pick, 'refetchInterval'> ) => getListQFn('instanceList', { query: { project } }, options) -InstancesPage.loader = async ({ params }: LoaderFunctionArgs) => { +export async function loader({ params }: LoaderFunctionArgs) { const { project } = getProjectSelector(params) await queryClient.prefetchQuery(instanceList(project).optionsFn()) return null @@ -76,7 +76,8 @@ const POLL_FAST_TIMEOUT = 30 * sec const POLL_INTERVAL_FAST = 3 * sec const POLL_INTERVAL_SLOW = 60 * sec -export function InstancesPage() { +Component.displayName = 'InstancesPage' +export function Component() { const { project } = useProjectSelector() const [resizeInstance, setResizeInstance] = useState(null) diff --git a/app/pages/settings/ProfilePage.tsx b/app/pages/settings/ProfilePage.tsx index faec440179..da8bbeb95b 100644 --- a/app/pages/settings/ProfilePage.tsx +++ b/app/pages/settings/ProfilePage.tsx @@ -10,7 +10,7 @@ import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/re import type { Group } from '@oxide/api' import { Settings24Icon } from '@oxide/design-system/icons/react' -import { useCurrentUser } from '~/layouts/AuthenticatedLayout' +import { useCurrentUser } from '~/hooks/use-current-user' import { getActionsCol } from '~/table/columns/action-col' import { Table } from '~/table/Table' import { CopyToClipboard } from '~/ui/lib/CopyToClipboard' diff --git a/app/routes.tsx b/app/routes.tsx index d71ffea989..90488fefa0 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -5,7 +5,13 @@ * * Copyright Oxide Computer Company */ -import { createRoutesFromElements, Navigate, Route } from 'react-router' +import type { ReactElement } from 'react' +import { + createRoutesFromElements, + Navigate, + Route, + type LoaderFunctionArgs, +} from 'react-router' import { RouterDataErrorBoundary } from './components/ErrorBoundary' import { NotFound } from './components/ErrorPage' @@ -39,12 +45,8 @@ import { CreateRouterRouteSideModalForm } from './forms/vpc-router-route-create' import { EditRouterRouteSideModalForm } from './forms/vpc-router-route-edit' import { makeCrumb, titleCrumb } from './hooks/use-crumbs' import { getInstanceSelector, getProjectSelector, getVpcSelector } from './hooks/use-params' -import { AuthenticatedLayout } from './layouts/AuthenticatedLayout' import { AuthLayout } from './layouts/AuthLayout' -import { SerialConsoleContentPane } from './layouts/helpers' import { LoginLayout } from './layouts/LoginLayout' -import { ProjectLayout } from './layouts/ProjectLayout' -import { RootLayout } from './layouts/RootLayout' import { SettingsLayout } from './layouts/SettingsLayout' import { SiloLayout } from './layouts/SiloLayout' import * as SystemLayout from './layouts/SystemLayout' @@ -54,7 +56,6 @@ import { LoginPage } from './pages/LoginPage' import { LoginPageSaml } from './pages/LoginPageSaml' import { instanceLookupLoader } from './pages/lookups' import * as ProjectAccess from './pages/project/access/ProjectAccessPage' -import { DisksPage } from './pages/project/disks/DisksPage' import { FloatingIpsPage } from './pages/project/floating-ips/FloatingIpsPage' import { ImagesPage } from './pages/project/images/ImagesPage' import { InstancePage } from './pages/project/instances/instance/InstancePage' @@ -64,7 +65,6 @@ import * as MetricsTab from './pages/project/instances/instance/tabs/MetricsTab' import * as NetworkingTab from './pages/project/instances/instance/tabs/NetworkingTab' import * as SettingsTab from './pages/project/instances/instance/tabs/SettingsTab' import * as StorageTab from './pages/project/instances/instance/tabs/StorageTab' -import { InstancesPage } from './pages/project/instances/InstancesPage' import { SnapshotsPage } from './pages/project/snapshots/SnapshotsPage' import { EditInternetGatewayForm } from './pages/project/vpcs/internet-gateway-edit' import * as RouterPage from './pages/project/vpcs/RouterPage' @@ -93,8 +93,19 @@ import * as SystemUtilization from './pages/system/UtilizationPage' import { truncate } from './ui/lib/Truncate' import { pb } from './util/path-builder' +type RouteModule = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + clientLoader?: (a: LoaderFunctionArgs) => any + default: () => ReactElement +} + +function convert(m: RouteModule) { + const { clientLoader, default: Component, ...rest } = m + return { ...rest, loader: clientLoader, Component } +} + export const routes = createRoutesFromElements( - }> + import('./layouts/RootLayout').then(convert)}> } /> }> } /> @@ -108,8 +119,7 @@ export const routes = createRoutesFromElements( {/* This wraps all routes that are supposed to be authenticated */} } - loader={AuthenticatedLayout.loader} + lazy={() => import('./layouts/AuthenticatedLayout').then(convert)} errorElement={} // very important. see `currentUserLoader` and `useCurrentUser` shouldRevalidate={() => true} @@ -243,8 +253,7 @@ export const routes = createRoutesFromElements( cannot use the normal .*/} } />} - loader={ProjectLayout.loader} + lazy={() => import('./layouts/SerialConsoleLayout').then(convert)} handle={makeCrumb( (p) => p.project!, (p) => pb.project(getProjectSelector(p)) @@ -263,8 +272,7 @@ export const routes = createRoutesFromElements( } - loader={ProjectLayout.loader} + lazy={() => import('./layouts/ProjectLayout').then(convert)} handle={makeCrumb( (p) => p.project!, (p) => pb.project(getProjectSelector(p)) @@ -278,7 +286,7 @@ export const routes = createRoutesFromElements( handle={{ crumb: 'New instance' }} /> - } loader={InstancesPage.loader} /> + import('./pages/project/instances/InstancesPage')} /> } + lazy={() => import('./pages/project/disks/DisksPage').then(convert)} handle={makeCrumb('Disks', (p) => pb.disks(getProjectSelector(p)))} - loader={DisksPage.loader} > ({ input: { app: 'index.html', }, + output: { + // React Router automatically splits any route module into its own file, + // but some end up being like 300 bytes. It feels silly to have several + // hundred of those, so we set a minimum size to end up with fewer. + // https://rollupjs.org/configuration-options/#output-experimentalminchunksize + experimentalMinChunkSize: 5 * KiB, + }, }, // prevent inlining assets as `data:`, which is not permitted by our Content-Security-Policy assetsInlineLimit: 0, From 84cb05653ed2b03216bb8326ddc25be27cc6070f Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 19 Feb 2025 12:11:46 -0600 Subject: [PATCH 2/3] move all route props inside route modules --- app/hooks/use-crumbs.ts | 2 +- app/layouts/AuthenticatedLayout.tsx | 8 +++++ app/layouts/ProjectLayout.tsx | 9 ++++- app/layouts/ProjectLayoutBase.tsx | 6 ++++ app/layouts/SerialConsoleLayout.tsx | 10 ++++-- app/pages/project/disks/DisksPage.tsx | 3 ++ app/routes.tsx | 33 +++++-------------- app/util/path-builder.spec.ts | 47 ++++++++++++++++----------- 8 files changed, 70 insertions(+), 48 deletions(-) diff --git a/app/hooks/use-crumbs.ts b/app/hooks/use-crumbs.ts index c8ecd6bc1b..c453697a7a 100644 --- a/app/hooks/use-crumbs.ts +++ b/app/hooks/use-crumbs.ts @@ -9,7 +9,7 @@ import { useMatches, type Params, type UIMatch } from 'react-router' import { invariant } from '~/util/invariant' -type Crumb = { +export type Crumb = { crumb: MakeStr /** * Side modal forms have their own routes and their own crumbs that we want diff --git a/app/layouts/AuthenticatedLayout.tsx b/app/layouts/AuthenticatedLayout.tsx index 1a9ab00786..e4dcdcba97 100644 --- a/app/layouts/AuthenticatedLayout.tsx +++ b/app/layouts/AuthenticatedLayout.tsx @@ -9,8 +9,16 @@ import { Outlet } from 'react-router' import { apiQueryClient } from '@oxide/api' +import { RouterDataErrorBoundary } from '~/components/ErrorBoundary' import { QuickActions } from '~/hooks/use-quick-actions' +/** very important. see `currentUserLoader` and `useCurrentUser` */ +export const shouldRevalidate = () => true + +export function ErrorBoundary() { + return +} + /** * We use `shouldRevalidate={() => true}` to force this to re-run on every nav, * but the longer-than-default `staleTime` avoids fetching too much. diff --git a/app/layouts/ProjectLayout.tsx b/app/layouts/ProjectLayout.tsx index 963fc1fec0..2d2680f3b8 100644 --- a/app/layouts/ProjectLayout.tsx +++ b/app/layouts/ProjectLayout.tsx @@ -5,10 +5,17 @@ * * Copyright Oxide Computer Company */ -import { ProjectLayoutBase, projectLayoutLoader } from './ProjectLayoutBase.tsx' + +import { + ProjectLayoutBase, + projectLayoutHandle, + projectLayoutLoader, +} from './ProjectLayoutBase.tsx' export const clientLoader = projectLayoutLoader +export const handle = projectLayoutHandle + export default function ProjectLayout() { return } diff --git a/app/layouts/ProjectLayoutBase.tsx b/app/layouts/ProjectLayoutBase.tsx index 36cd754d45..efe9b3645b 100644 --- a/app/layouts/ProjectLayoutBase.tsx +++ b/app/layouts/ProjectLayoutBase.tsx @@ -21,6 +21,7 @@ import { } from '@oxide/design-system/icons/react' import { TopBar } from '~/components/TopBar' +import { makeCrumb } from '~/hooks/use-crumbs' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { useQuickActions } from '~/hooks/use-quick-actions' import { Divider } from '~/ui/lib/Divider' @@ -30,6 +31,11 @@ import type * as PP from '~/util/path-params' import { DocsLinkItem, NavLinkItem, Sidebar } from '../components/Sidebar' import { ContentPane, PageContainer } from './helpers' +export const projectLayoutHandle = makeCrumb( + (p) => p.project!, + (p) => pb.project(getProjectSelector(p)) +) + type ProjectLayoutProps = { /** Sometimes we need a different layout for the content pane. Like * ``, the element passed here should contain an ``. diff --git a/app/layouts/SerialConsoleLayout.tsx b/app/layouts/SerialConsoleLayout.tsx index 4d70c026c4..abea280c5a 100644 --- a/app/layouts/SerialConsoleLayout.tsx +++ b/app/layouts/SerialConsoleLayout.tsx @@ -6,10 +6,16 @@ * Copyright Oxide Computer Company */ import { SerialConsoleContentPane } from './helpers.tsx' -import { ProjectLayoutBase, projectLayoutLoader } from './ProjectLayoutBase.tsx' +import { + ProjectLayoutBase, + projectLayoutHandle, + projectLayoutLoader, +} from './ProjectLayoutBase.tsx' export const clientLoader = projectLayoutLoader -export default function ProjectLayout() { +export const handle = projectLayoutHandle + +export default function SerialConsoleLayout() { return } /> } diff --git a/app/pages/project/disks/DisksPage.tsx b/app/pages/project/disks/DisksPage.tsx index f3b97bc618..acf8262e10 100644 --- a/app/pages/project/disks/DisksPage.tsx +++ b/app/pages/project/disks/DisksPage.tsx @@ -23,6 +23,7 @@ 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 { makeCrumb } from '~/hooks/use-crumbs' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' @@ -40,6 +41,8 @@ import type * as PP from '~/util/path-params' import { fancifyStates } from '../instances/instance/tabs/common' +export const handle = makeCrumb('Disks', (p) => pb.disks(getProjectSelector(p))) + const EmptyState = () => ( } diff --git a/app/routes.tsx b/app/routes.tsx index 90488fefa0..151e2c593a 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -13,7 +13,6 @@ import { type LoaderFunctionArgs, } from 'react-router' -import { RouterDataErrorBoundary } from './components/ErrorBoundary' import { NotFound } from './components/ErrorPage' import { CreateDiskSideModalForm } from './forms/disk-create' import { CreateFirewallRuleForm } from './forms/firewall-rules-create' @@ -43,7 +42,7 @@ import * as RouterCreate from './forms/vpc-router-create' import { EditRouterSideModalForm } from './forms/vpc-router-edit' import { CreateRouterRouteSideModalForm } from './forms/vpc-router-route-create' import { EditRouterRouteSideModalForm } from './forms/vpc-router-route-edit' -import { makeCrumb, titleCrumb } from './hooks/use-crumbs' +import { makeCrumb, titleCrumb, type Crumb } from './hooks/use-crumbs' import { getInstanceSelector, getProjectSelector, getVpcSelector } from './hooks/use-params' import { AuthLayout } from './layouts/AuthLayout' import { LoginLayout } from './layouts/LoginLayout' @@ -95,8 +94,11 @@ import { pb } from './util/path-builder' type RouteModule = { // eslint-disable-next-line @typescript-eslint/no-explicit-any - clientLoader?: (a: LoaderFunctionArgs) => any + clientLoader?: (a: LoaderFunctionArgs) => Promise default: () => ReactElement + shouldRevalidate?: () => boolean + ErrorBoundary?: () => ReactElement + handle?: Crumb } function convert(m: RouteModule) { @@ -118,12 +120,7 @@ export const routes = createRoutesFromElements( {/* This wraps all routes that are supposed to be authenticated */} - import('./layouts/AuthenticatedLayout').then(convert)} - errorElement={} - // very important. see `currentUserLoader` and `useCurrentUser` - shouldRevalidate={() => true} - > + import('./layouts/AuthenticatedLayout').then(convert)}> import('./layouts/SerialConsoleLayout').then(convert)} - handle={makeCrumb( - (p) => p.project!, - (p) => pb.project(getProjectSelector(p)) - )} > p.instance!)}> @@ -270,14 +263,7 @@ export const routes = createRoutesFromElements( - import('./layouts/ProjectLayout').then(convert)} - handle={makeCrumb( - (p) => p.project!, - (p) => pb.project(getProjectSelector(p)) - )} - > + import('./layouts/ProjectLayout').then(convert)}> } /> - import('./pages/project/disks/DisksPage').then(convert)} - handle={makeCrumb('Disks', (p) => pb.disks(getProjectSelector(p)))} - > + import('./pages/project/disks/DisksPage').then(convert)}> { // matchRoutes returns something slightly different from UIMatch const getMatches = (pathname: string) => - matchRoutes(routes, pathname)!.map((m) => ({ - pathname: m.pathname, - params: m.params, - handle: m.route.handle, - // not used - id: '', - data: undefined, - })) + Promise.all( + matchRoutes(routes, pathname)!.map(async (m) => { + // As we convert route modules to RR framework mode with lazy imports, + // more and more of the routes will have their handles defined inside the + // route module. We need to call the lazy function to import the module + // contents and fill out the route object with it. + const route = { ...m.route, ...(await m.route.lazy?.()) } + return { + pathname: m.pathname, + params: m.params, + handle: route.handle, + // not used + id: '', + data: undefined, + } + }) + ) // run every route in the path builder through the crumbs logic -test('breadcrumbs', () => { - const pairs = Object.entries(pb).map(([key, fn]) => { - const pathname = fn(params) - return [ - `${key} (${pathname})`, - matchesToCrumbs(getMatches(pathname)) - .filter((c) => !c.titleOnly) - // omit titleOnly because of noise in the snapshot - .map(R.omit(['titleOnly'])), - ] as const - }) +test('breadcrumbs', async () => { + const pairs = await Promise.all( + Object.entries(pb).map(async ([key, fn]) => { + const pathname = fn(params) + const matches = await getMatches(pathname) + const crumbs = matchesToCrumbs(matches) + .filter(({ titleOnly }) => !titleOnly) + .map(R.omit(['titleOnly'])) + return [`${key} (${pathname})`, crumbs] as const + }) + ) const zeroCrumbKeys = pairs .filter(([_, crumbs]) => crumbs.length === 0) From 940f4cccc4d942a5c3e9d78b779aa935588dcbc4 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 19 Feb 2025 15:18:13 -0600 Subject: [PATCH 3/3] serial console --- app/components/Terminal.tsx | 4 +--- .../instances/instance/SerialConsolePage.tsx | 14 +++++++------- app/routes.tsx | 8 +++++--- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/app/components/Terminal.tsx b/app/components/Terminal.tsx index bd39a5a9a8..3d66c486fa 100644 --- a/app/components/Terminal.tsx +++ b/app/components/Terminal.tsx @@ -61,9 +61,7 @@ interface TerminalProps { ws: WebSocket } -// default export is most convenient for dynamic import -// eslint-disable-next-line import/no-default-export -export default function Terminal({ ws }: TerminalProps) { +export function Terminal({ ws }: TerminalProps) { const [term, setTerm] = useState(null) const terminalRef = useRef(null) diff --git a/app/pages/project/instances/instance/SerialConsolePage.tsx b/app/pages/project/instances/instance/SerialConsolePage.tsx index c66f0b50d4..229c69f5d5 100644 --- a/app/pages/project/instances/instance/SerialConsolePage.tsx +++ b/app/pages/project/instances/instance/SerialConsolePage.tsx @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ import cn from 'classnames' -import { lazy, Suspense, useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Link, type LoaderFunctionArgs } from 'react-router' import { @@ -20,13 +20,12 @@ import { PrevArrow12Icon } from '@oxide/design-system/icons/react' import { EquivalentCliCommand } from '~/components/CopyCode' import { InstanceStateBadge } from '~/components/StateBadge' +import { Terminal } from '~/components/Terminal' import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params' import { Badge, type BadgeColor } from '~/ui/lib/Badge' import { Spinner } from '~/ui/lib/Spinner' import { pb } from '~/util/path-builder' -const Terminal = lazy(() => import('~/components/Terminal')) - type WsState = 'connecting' | 'open' | 'closed' | 'error' const statusColor: Record = { @@ -43,7 +42,7 @@ const statusMessage: Record = { error: 'error', } -export async function loader({ params }: LoaderFunctionArgs) { +export async function clientLoader({ params }: LoaderFunctionArgs) { const { project, instance } = getInstanceSelector(params) await apiQueryClient.prefetchQuery('instanceView', { path: { instance }, @@ -56,8 +55,9 @@ function isStarting(i: Instance | undefined) { return i?.runState === 'creating' || i?.runState === 'starting' } -Component.displayName = 'SerialConsolePage' -export function Component() { +export const handle = { crumb: 'Serial Console' } + +export default function SerialConsolePage() { const instanceSelector = useInstanceSelector() const { project, instance } = instanceSelector @@ -153,7 +153,7 @@ export function Component() { )} {/* closed && canConnect shouldn't be possible because there's no way to * close an open connection other than leaving the page */} - {ws.current && } + {ws.current && }
diff --git a/app/routes.tsx b/app/routes.tsx index 151e2c593a..9165face2f 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -58,7 +58,6 @@ import * as ProjectAccess from './pages/project/access/ProjectAccessPage' import { FloatingIpsPage } from './pages/project/floating-ips/FloatingIpsPage' import { ImagesPage } from './pages/project/images/ImagesPage' import { InstancePage } from './pages/project/instances/instance/InstancePage' -import * as SerialConsole from './pages/project/instances/instance/SerialConsolePage' import * as ConnectTab from './pages/project/instances/instance/tabs/ConnectTab' import * as MetricsTab from './pages/project/instances/instance/tabs/MetricsTab' import * as NetworkingTab from './pages/project/instances/instance/tabs/NetworkingTab' @@ -256,8 +255,11 @@ export const routes = createRoutesFromElements( p.instance!)}> + import('./pages/project/instances/instance/SerialConsolePage').then( + convert + ) + } />