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
2 changes: 2 additions & 0 deletions app/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type { ErrorResult } from '@oxide/api'

import NotFound from 'app/pages/NotFound'

export const trigger404 = { type: 'error', statusCode: 404 }

type Props = { error: Error | ErrorResult }

function ErrorFallback({ error }: Props) {
Expand Down
40 changes: 8 additions & 32 deletions app/components/TopBar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Menu, MenuButton, MenuItem, MenuList } from '@reach/menu-button'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import React from 'react'
import { useNavigate } from 'react-router-dom'

import { navToLogin, useApiMutation, useApiQuery } from '@oxide/api'
import {
Expand All @@ -9,19 +10,10 @@ import {
Notifications16Icon,
Profile16Icon,
} from '@oxide/ui'
import { isTruthy } from '@oxide/util'

import { pb } from 'app/util/path-builder'

import { OrgPicker, ProjectPicker, SiloSystemPicker } from './TopBarPicker'

/**
* TODO: This is a temporary flag to disable the silo picker until we have
* the an API endpoint to support it.
*/
const hasSiloPerms = true

export function TopBar() {
export function TopBar({ children }: { children: React.ReactNode }) {
const navigate = useNavigate()
const logout = useApiMutation('logout', {
onSuccess: () => {
Expand All @@ -31,29 +23,13 @@ export function TopBar() {
navToLogin({ includeCurrent: false })
},
})
const { data: user, error } = useApiQuery(
'sessionMe',
{},
{ cacheTime: 0, refetchOnWindowFocus: false }
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved refetchOnWindowFocus: false to the global config. I am less nervous about making a move on #1178 now that the big demo is done.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which is a reminder @benjaminleonard that we should think more holistically about this from a design perspective. Having things pop out from under you is a bad experience but having to refresh over and over to see changes is also not great. I think the key here is giving explicit control in a way that's intuitive.

I think it'll be a big enough part of the experience that we need to communicate how to tweak this to users during on boarding.

)

const loggedIn = user && !error
const { data: user } = useApiQuery('sessionMe', {}, { cacheTime: 0 })

const isSystem = useLocation().pathname.startsWith(pb.system()) // lol
const { projectName } = useParams()
const loggedIn = !!user

const [cornerPicker, ...otherPickers] = [
hasSiloPerms && <SiloSystemPicker isSystem={isSystem} key={0} />,
// TODO: This works ok in most situations, but when an operator user is on
// the orgs page with no org selected, they see this picker, which is
// redundant with the list of orgs. Overall this logic is starting to feel
// silly, which points to a non-centralized approach handled in the layouts
// like we were doing before. That way, for example, we know whether we're
// on a system situation because we're in SystemLayout. Seems pretty obvious
// in hindsight.
!isSystem && <OrgPicker key={1} />,
projectName && <ProjectPicker key={2} />,
].filter(isTruthy)
// toArray filters out nulls, which is essential because the silo/system
// picker is going to come in null when the user isn't supposed to see it
const [cornerPicker, ...otherPickers] = React.Children.toArray(children)
Comment on lines +30 to +32
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love to see this cleaned up!


// The height of this component is governed by the `PageContainer`
// It's important that this component returns two distinct elements (wrapped in a fragment).
Expand Down
28 changes: 24 additions & 4 deletions app/components/TopBarPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,29 @@ const NoOrgLogo = () => (
</div>
)

export function SiloSystemPicker({ isSystem }: { isSystem: boolean }) {
/**
* Return `null` instead of the picker when the user doesn't have fleet viewer
* perms so it can get filtered out of the children array in `<TopBar>`. Having
* `<SiloSystemPicker>` itself return `null` from render doesn't work because
* `<TopBar>` can't see that.
*/
export function useSiloSystemPicker(value: 'silo' | 'system') {
// User is only shown 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 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.
//
// Note that we are able to ignore the possibility of `systemPolicy` being
// undefined due to the request being in flight because we have prefetched
// this request in the loader. If that prefetch were removed, fleet viewers
// would see the silo picker pop in when the request resolves, which would be
// bad.
const { data: systemPolicy } = useApiQuery('systemPolicyView', {})
return systemPolicy ? <SiloSystemPicker value={value} /> : null
}

export function SiloSystemPicker({ value }: { value: 'silo' | 'system' }) {
const commonProps = {
items: [
{ label: 'System', to: pb.silos() },
Expand All @@ -113,7 +135,7 @@ export function SiloSystemPicker({ isSystem }: { isSystem: boolean }) {
'aria-label': 'Switch between system and silo',
}

return isSystem ? (
return value === 'system' ? (
<TopBarPicker
{...commonProps}
category="System"
Expand All @@ -127,8 +149,6 @@ export function SiloSystemPicker({ isSystem }: { isSystem: boolean }) {
)
}

// TODO: maybe don't filter out the currently selected one

export function OrgPicker() {
const { orgName } = useParams()
const { data } = useApiQuery('organizationList', { limit: 20 })
Expand Down
6 changes: 5 additions & 1 deletion app/layouts/OrgLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Access16Icon, Divider, Folder16Icon, Organization16Icon } from '@oxide/ui'

import { TopBar } from 'app/components/TopBar'
import { OrgPicker, useSiloSystemPicker } from 'app/components/TopBarPicker'
import { useRequiredParams } from 'app/hooks'
import { pb } from 'app/util/path-builder'

Expand All @@ -12,7 +13,10 @@ const OrgLayout = () => {

return (
<PageContainer>
<TopBar />
<TopBar>
{useSiloSystemPicker('silo')}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A nit but it seems strange to me to see a hook inlined in the JSX.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I had it pulled out initially but I moved it inline because I could. I do like that it feels like calling a component, which is what I wish it was.

<OrgPicker />
</TopBar>
<Sidebar>
<Sidebar.Nav>
<NavLinkItem to={pb.orgs()} end>
Expand Down
7 changes: 6 additions & 1 deletion app/layouts/ProjectLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from '@oxide/ui'

import { TopBar } from 'app/components/TopBar'
import { OrgPicker, ProjectPicker, useSiloSystemPicker } from 'app/components/TopBarPicker'
import { useQuickActions, useRequiredParams } from 'app/hooks'
import { pb } from 'app/util/path-builder'

Expand Down Expand Up @@ -49,7 +50,11 @@ const ProjectLayout = () => {

return (
<PageContainer>
<TopBar />
<TopBar>
{useSiloSystemPicker('silo')}
<OrgPicker />
<ProjectPicker />
</TopBar>
<Sidebar>
<Sidebar.Nav>
<NavLinkItem to={pb.projects({ orgName })} end>
Expand Down
6 changes: 5 additions & 1 deletion app/layouts/SettingsLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { matchPath, useLocation, useNavigate } from 'react-router-dom'
import { Divider, Key16Icon, Profile16Icon, Show16Icon } from '@oxide/ui'

import { TopBar } from 'app/components/TopBar'
import { OrgPicker, useSiloSystemPicker } from 'app/components/TopBarPicker'
import { useQuickActions } from 'app/hooks'
import { pb } from 'app/util/path-builder'

Expand Down Expand Up @@ -37,7 +38,10 @@ const SettingsLayout = () => {

return (
<PageContainer>
<TopBar />
<TopBar>
{useSiloSystemPicker('silo')}
<OrgPicker />
</TopBar>
<Sidebar>
<Sidebar.Nav>
{/* TODO: what to link here? anything? */}
Expand Down
6 changes: 5 additions & 1 deletion app/layouts/SiloLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@ import { Divider, Organization16Icon, Snapshots16Icon } from '@oxide/ui'

import { DocsLinkItem, NavLinkItem, Sidebar } from 'app/components/Sidebar'
import { TopBar } from 'app/components/TopBar'
import { OrgPicker, useSiloSystemPicker } from 'app/components/TopBarPicker'
import { pb } from 'app/util/path-builder'

import { ContentPane, PageContainer } from './helpers'

export default function SiloLayout() {
return (
<PageContainer>
<TopBar />
<TopBar>
{useSiloSystemPicker('silo')}
<OrgPicker />
</TopBar>
<Sidebar>
<Sidebar.Nav>
<DocsLinkItem />
Expand Down
29 changes: 27 additions & 2 deletions app/layouts/SystemLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { apiQueryClient } from '@oxide/api'
import {
Cloud16Icon,
Divider,
Expand All @@ -10,16 +11,40 @@ import {
Storage16Icon,
} from '@oxide/ui'

import { trigger404 } from 'app/components/ErrorBoundary'
import { DocsLinkItem, NavLinkItem, Sidebar } from 'app/components/Sidebar'
import { TopBar } from 'app/components/TopBar'
import { SiloSystemPicker } from 'app/components/TopBarPicker'
import { pb } from 'app/util/path-builder'

import { ContentPane, PageContainer } from './helpers'

export default function SiloLayout() {
/**
* If we can see the policy, we're a fleet viewer, and we need to be a fleet
* viewer in order to see any of the routes under this layout. We need to
* `fetchQuery` instead of `prefetchQuery` because the latter doesn't return the
* result, and then we need to `.catch()` because `fetchQuery` throws on request
* error. We're being a little cavalier here with the error. If it's something
* other than a 403, that would be strange and we would want to know.
Comment on lines +27 to +28
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps add a TODO?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Problem is I have no idea how we’d track it, but we could totally have an API endpoint for analytics.

*/
SystemLayout.loader = async () => {
const isFleetViewer = await apiQueryClient
.fetchQuery('systemPolicyView', {})
.then(() => true)
.catch(() => false)

// TODO: make sure 404 is the desired behavior. This situation should be
// pretty unlikely.
if (!isFleetViewer) throw trigger404
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doing this here is awesome because it means we don't have to do any janky "is this a system route?" detection. This layout wraps all the system routes in the route config. If we ever want to change the scope of this check to include some other routes, all we have to do is add another <Route> that wraps this one and the other one and move this loader to there.


export default function SystemLayout() {
return (
<PageContainer>
<TopBar />
<TopBar>
{/* don't use the hook bc if we're here, this needs to show up */}
<SiloSystemPicker value="system" />
</TopBar>
<Sidebar>
<Sidebar.Nav>
<DocsLinkItem />
Expand Down
11 changes: 9 additions & 2 deletions app/layouts/helpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ export const ContentPane = () => (
</div>
)

export async function prefetchSessionMe() {
await apiQueryClient.prefetchQuery('sessionMe', {})
/** Loader for the `<Route>` that wraps all authenticated routes. */
export const userLoader = async () => {
await Promise.all([
apiQueryClient.prefetchQuery('sessionMe', {}),
// Need to prefetch this because every layout hits it when deciding whether
// to show the silo/system picker. It's also fetched by the SystemLayout
// loader to figure out whether to 404, but RQ dedupes the request.
apiQueryClient.prefetchQuery('systemPolicyView', {}),
Comment on lines +31 to +34
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the comments. The query being spread across multiple loaders and a hook doesn't feel great to me, but I get why it's needed and the benefit of this approach. Prefetched here, fetched in the system loader (which could be from cache), and queried via useSystemSiloPicker which should also be from cache. From my understanding if we had it on /session/me all that would do is remove it from the SystemLayout loader. I still suspect that may be the better way to approach it long term, but this seems good enough for where we're at.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, it feels like a lot but also necessary. I’m thinking about how to test that these things are hooked up correctly, but it’s pretty tough to be thorough. Like we can obviously assert that the silo/system picker is there or not depending on the current user (at least, we can when we add some form of authz to the mock server) but testing whether there’s pop-in is pretty hard because the timings are tight. Working on it.

])
}
4 changes: 3 additions & 1 deletion app/pages/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { useNavigate, useSearchParams } from 'react-router-dom'
import { useApiMutation } from '@oxide/api'
import { Button, Success16Icon, Warning12Icon } from '@oxide/ui'

import { pb } from 'app/util/path-builder'

import { useToast } from '../hooks'

/**
Expand All @@ -28,7 +30,7 @@ export default function LoginPage() {
title: 'Logged in',
icon: <Success16Icon />,
})
navigate(searchParams.get('state') || '/')
navigate(searchParams.get('state') || pb.orgs())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Need a lint rule for this.

},
onError: () => {
addToast({
Expand Down
16 changes: 16 additions & 0 deletions app/pages/__tests__/top-bar.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { test } from '@playwright/test'

import { expectSimultaneous } from 'app/test/e2e'
import { pb } from 'app/util/path-builder'

test('Silo/system picker does not pop in', async ({ page }) => {
await page.goto(pb.orgs())

// make sure the system policy call is prefetched properly so that the
// silo/system picker doesn't pop in. if this turns out to be flaky, just
// throw it out. it's extra as hell
await expectSimultaneous(page, [
'role=button[name="Switch between system and silo"]',
'role=button[name="Switch organization"]',
])
})
Copy link
Collaborator Author

@david-crespo david-crespo Oct 16, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It works surprisingly well. First 3 times are with the prefetch commented out, second 3 are with it in (the expect is commented out so it wouldn't print 100 lines of failure stuff).

Screen Shot 2022-10-15 at 10 30 20 PM

6 changes: 3 additions & 3 deletions app/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import RootLayout from './layouts/RootLayout'
import SettingsLayout from './layouts/SettingsLayout'
import SiloLayout from './layouts/SiloLayout'
import SystemLayout from './layouts/SystemLayout'
import { prefetchSessionMe } from './layouts/helpers'
import { userLoader } from './layouts/helpers'
import DeviceAuthSuccessPage from './pages/DeviceAuthSuccessPage'
import DeviceAuthVerifyPage from './pages/DeviceAuthVerifyPage'
import LoginPage from './pages/LoginPage'
Expand Down Expand Up @@ -55,7 +55,7 @@ export const routes = createRoutesFromElements(
</Route>

{/* This wraps all routes that are supposed to be authenticated */}
<Route loader={prefetchSessionMe} errorElement={<RouterDataErrorBoundary />}>
<Route loader={userLoader} errorElement={<RouterDataErrorBoundary />}>
<Route path="settings" handle={{ crumb: 'settings' }} element={<SettingsLayout />}>
<Route index element={<Navigate to="profile" replace />} />
<Route path="profile" element={<ProfilePage />} handle={{ crumb: 'Profile' }} />
Expand All @@ -73,7 +73,7 @@ export const routes = createRoutesFromElements(
<Route path="hotkeys" element={<HotkeysPage />} handle={{ crumb: 'Hotkeys' }} />
</Route>

<Route path="sys" element={<SystemLayout />}>
<Route path="sys" element={<SystemLayout />} loader={SystemLayout.loader}>
<Route path="silos" element={<SilosPage />} loader={SilosPage.loader} />
<Route
path="silos-new"
Expand Down
14 changes: 14 additions & 0 deletions app/test/e2e/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,17 @@ export async function expectRowVisible(
.poll(getRows)
.toEqual(expect.arrayContaining([expect.objectContaining(expectedRow)]))
}

async function timeToAppear(page: Page, selector: string): Promise<number> {
const start = Date.now()
await page.locator(selector).waitFor()
return Date.now() - start
}

/**
* Assert two elements appeared within 20ms of each other
*/
export async function expectSimultaneous(page: Page, selectors: [string, string]) {
const [t1, t2] = await Promise.all(selectors.map((sel) => timeToAppear(page, sel)))
expect(Math.abs(t1 - t2)).toBeLessThan(20)
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is my greatest creation

22 changes: 20 additions & 2 deletions libs/api-mocks/msw/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { pick, sortBy } from '@oxide/util'

import type { Json } from '../json-type'
import { genCumulativeI64Data, genI64Data } from '../metrics'
import { FLEET_ID } from '../role-assignment'
import { serial } from '../serial'
import { sessionMe } from '../session'
import { defaultSilo } from '../silo'
Expand Down Expand Up @@ -53,6 +54,10 @@ const unavailableBody = { error_code: 'ServiceUnavailable' } as const
type Unavailable = typeof unavailableBody
const unavailableErr = json(unavailableBody, { status: 503 })

const forbiddenBody = { error_code: 'Forbidden' } as const
type Forbidden = typeof forbiddenBody
// const forbiddenErr = json(forbiddenBody, { status: 403 })

const badRequest = (msg: string) =>
compose(
context.status(400),
Expand All @@ -63,8 +68,8 @@ const badRequest = (msg: string) =>
})
)

type GetErr = NotFound | Unavailable
type PostErr = AlreadyExists | NotFound
type GetErr = NotFound | Unavailable | Forbidden
type PostErr = AlreadyExists | NotFound | Forbidden

export const handlers = [
rest.get('/session/me', (req, res) => res(json(sessionMe))),
Expand Down Expand Up @@ -1139,6 +1144,19 @@ export const handlers = [
}
),

rest.get<never, never, Json<Api.FleetRolePolicy> | GetErr>(
'/system/policy',
(_req, res) => {
// manually uncomment to test non-operator user
// TODO: figure out authz in the mock server ugh
// return res(forbiddenErr)
const role_assignments = db.roleAssignments
.filter((r) => r.resource_type === 'fleet' && r.resource_id === FLEET_ID)
.map((r) => pick(r, 'identity_id', 'identity_type', 'role_name'))
return res(json({ role_assignments }))
}
),

rest.get<never, never, Json<Api.UserResultsPage> | GetErr>('/users', (req, res) => {
return res(json(paginated(req.url.search, db.users)))
}),
Expand Down
Loading