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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
},
{
Expand Down
21 changes: 20 additions & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
@@ -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"] }],
Expand All @@ -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/"]
}
4 changes: 1 addition & 3 deletions app/components/Terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<XTerm | null>(null)
const terminalRef = useRef<HTMLDivElement>(null)

Expand Down
2 changes: 1 addition & 1 deletion app/components/TopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion app/hooks/use-crumbs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions app/hooks/use-current-user.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
2 changes: 1 addition & 1 deletion app/hooks/use-quick-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
39 changes: 11 additions & 28 deletions app/layouts/AuthenticatedLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,23 @@
*/
import { Outlet } from 'react-router'

import { apiQueryClient, useApiQueryErrorsAllowed, usePrefetchedApiQuery } from '@oxide/api'
import { apiQueryClient } from '@oxide/api'

import { RouterDataErrorBoundary } from '~/components/ErrorBoundary'
import { QuickActions } from '~/hooks/use-quick-actions'
import { invariant } from '~/util/invariant'

/** very important. see `currentUserLoader` and `useCurrentUser` */
export const shouldRevalidate = () => true

export function ErrorBoundary() {
return <RouterDataErrorBoundary />
}

/**
* 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 }),
Expand All @@ -38,35 +45,11 @@ AuthenticatedLayout.loader = async () => {
}

/** Wraps all authenticated routes. */
export function AuthenticatedLayout() {
export default function AuthenticatedLayout() {
return (
<>
<QuickActions />
<Outlet />
</>
)
}

/**
* 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 }
}
112 changes: 8 additions & 104 deletions app/layouts/ProjectLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,113 +5,17 @@
*
* 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'
ProjectLayoutBase,
projectLayoutHandle,
projectLayoutLoader,
} from './ProjectLayoutBase.tsx'

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'
export const clientLoader = projectLayoutLoader

import { DocsLinkItem, NavLinkItem, Sidebar } from '../components/Sidebar'
import { ContentPane, PageContainer } from './helpers'
export const handle = projectLayoutHandle

type ProjectLayoutProps = {
/** Sometimes we need a different layout for the content pane. Like
* `<ContentPane />`, the element passed here should contain an `<Outlet />`.
*/
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 (
<PageContainer>
<TopBar systemOrSilo="silo" />
<Sidebar>
<Sidebar.Nav>
<NavLinkItem to={pb.projects()} end>
<Folder16Icon />
Projects
</NavLinkItem>
<DocsLinkItem />
</Sidebar.Nav>
<Divider />
<Sidebar.Nav heading={project.name}>
<NavLinkItem to={pb.instances(projectSelector)}>
<Instances16Icon /> Instances
</NavLinkItem>
<NavLinkItem to={pb.disks(projectSelector)}>
<Storage16Icon /> Disks
</NavLinkItem>
<NavLinkItem to={pb.snapshots(projectSelector)}>
<Snapshots16Icon /> Snapshots
</NavLinkItem>
<NavLinkItem to={pb.projectImages(projectSelector)}>
<Images16Icon title="images" /> Images
</NavLinkItem>
<NavLinkItem to={pb.vpcs(projectSelector)}>
<Networking16Icon /> VPCs
</NavLinkItem>
<NavLinkItem to={pb.floatingIps(projectSelector)}>
<IpGlobal16Icon /> Floating IPs
</NavLinkItem>
<NavLinkItem to={pb.projectAccess(projectSelector)}>
<Access16Icon title="Access" /> Access
</NavLinkItem>
</Sidebar.Nav>
</Sidebar>
{overrideContentPane || <ContentPane />}
</PageContainer>
)
export default function ProjectLayout() {
return <ProjectLayoutBase />
}
Loading
Loading