diff --git a/src/ui/cypress/integration/live/keyboard-shortcuts.spec.ts b/src/ui/cypress/integration/live/keyboard-shortcuts.spec.ts index 15a82404b07..d327bacc5fe 100644 --- a/src/ui/cypress/integration/live/keyboard-shortcuts.spec.ts +++ b/src/ui/cypress/integration/live/keyboard-shortcuts.spec.ts @@ -96,6 +96,8 @@ describe('Live view keyboard shortcuts', () => { }); it('Re-runs the current script', () => { + // Give the page a moment to stabilize, so the run button can be enabled again (hard to find, so just wait on time). + cy.wait(500); stubExecuteScript().as('repeat-exec'); // Not the original run (already waited on) const hotkey = `${useCmdKey ? '{cmd}' : '{ctrl}'}{enter}`; cy.get('body').type(hotkey); diff --git a/src/ui/cypress/integration/live/navbars.spec.ts b/src/ui/cypress/integration/live/navbars.spec.ts index 1c006e8a05e..cb3d9849649 100644 --- a/src/ui/cypress/integration/live/navbars.spec.ts +++ b/src/ui/cypress/integration/live/navbars.spec.ts @@ -68,8 +68,8 @@ describe('Live View navbars', () => { describe('Sidebar', () => { beforeEach(() => { cy.get('.MuiToolbar-root [aria-label="menu"]').as('sidebar-toggle'); - cy.get('header + .MuiDrawer-root').as('sidebar').within(() => { - cy.get('ul:nth-child(2)').as('sidebar-scripts'); + cy.get('header + .MuiDrawer-root > .MuiPaper-root').as('sidebar').within(() => { + cy.get('> a').as('sidebar-scripts'); cy.get('ul:last-child').as('sidebar-footer'); }); }); diff --git a/src/ui/src/api/api-client.ts b/src/ui/src/api/api-client.ts index 6d58c2a445f..785de9a2ba1 100644 --- a/src/ui/src/api/api-client.ts +++ b/src/ui/src/api/api-client.ts @@ -22,6 +22,7 @@ import fetch from 'cross-fetch'; import { Observable, from } from 'rxjs'; import { switchMap } from 'rxjs/operators'; +import { GetCSRFCookie } from 'app/pages/auth/utils'; import { Status } from 'app/types/generated/vizierapi_pb'; import { containsMutation } from 'app/utils/pxl'; @@ -34,7 +35,6 @@ import { VizierQueryFunc, ExecuteScriptOptions, } from './vizier-grpc-client'; -import { GetCSRFCookie } from '../pages/auth/utils'; /** * When calling `PixieAPIClient.create`, this specifies which clusters to connect to, and any special configuration for diff --git a/src/ui/src/api/cloud-gql-client.ts b/src/ui/src/api/cloud-gql-client.ts index 7357967cf6b..95f1a7c09ed 100644 --- a/src/ui/src/api/cloud-gql-client.ts +++ b/src/ui/src/api/cloud-gql-client.ts @@ -32,9 +32,9 @@ import { CachePersistor } from 'apollo3-cache-persist'; import fetch from 'cross-fetch'; import { isPixieEmbedded } from 'app/common/embed-context'; +import { GetCSRFCookie } from 'app/pages/auth/utils'; import { PixieAPIClientOptions } from './api-options'; -import { GetCSRFCookie } from '../pages/auth/utils'; // Apollo link that adds cookies in the request. const makeCloudAuthLink = (opts: PixieAPIClientOptions) => setContext((_, { headers }) => ({ diff --git a/src/ui/src/containers/App/sidebar.tsx b/src/ui/src/containers/App/sidebar.tsx index 96e8ec95e2b..3371e1b5373 100644 --- a/src/ui/src/containers/App/sidebar.tsx +++ b/src/ui/src/containers/App/sidebar.tsx @@ -18,9 +18,16 @@ import * as React from 'react'; +import { useQuery, gql } from '@apollo/client'; import { - Help as HelpIcon, + AccountTree as OrgIcon, Campaign as CampaignIcon, + Extension as PluginIcon, + Group as UsersIcon, + Help as HelpIcon, + Person as UserIcon, + Send as InviteIcon, + VpnKey as KeyIcon, } from '@mui/icons-material'; import { Divider, @@ -34,18 +41,26 @@ import { import { Theme } from '@mui/material/styles'; import { createStyles, makeStyles } from '@mui/styles'; import AnnounceKit from 'announcekit-react'; -import { Link } from 'react-router-dom'; +import { Link, useLocation } from 'react-router-dom'; import { ClusterContext } from 'app/common/cluster-context'; +import { isPixieEmbedded } from 'app/common/embed-context'; import UserContext from 'app/common/user-context'; -import { buildClass, DataDisksIcon, DocsIcon } from 'app/components'; +import { buildClass, ClusterIcon, DataDisksIcon, DocsIcon, NamespaceIcon } from 'app/components'; import { DOMAIN_NAME, ANNOUNCEMENT_ENABLED, ANNOUNCE_WIDGET_URL, } from 'app/containers/constants'; +import { deepLinkURLFromScript } from 'app/containers/live-widgets/utils/live-view-params'; +import { ScriptContext } from 'app/context/script-context'; +import { GetOAuthProvider } from 'app/pages/auth/utils'; +import { GQLClusterInfo } from 'app/types/schema'; import { showIntercomTrigger, triggerID } from 'app/utils/intercom'; import { SidebarFooter } from 'configurable/sidebar-footer'; +import { selectClusterName } from './cluster-info'; +import { LiveRouteContext } from './live-routing'; + const useStyles = makeStyles(({ spacing, palette, @@ -188,19 +203,134 @@ const SideBarExternalLinkItem = React.memo(({ }); SideBarExternalLinkItem.displayName = 'SideBarExternalLinkItem'; +type ClusterRowInfo = Pick; + +const useSelectedOrDefaultClusterName = () => { + const { data } = useQuery<{ + clusters: ClusterRowInfo[] + }>( + gql` + query listClustersForSidebar { + clusters { + id + clusterName + status + } + } + `, + { pollInterval: 60000, fetchPolicy: 'cache-and-network' }, + ); + + const [clusters, setClusters] = React.useState([]); + React.useEffect(() => { + if (data?.clusters) { + setClusters(data.clusters); + } + }, [data?.clusters]); + + + const defaultClusterName = selectClusterName(clusters ?? []) ?? ''; + const selectedClusterName = React.useContext(ClusterContext)?.selectedClusterName ?? ''; + + return selectedClusterName || defaultClusterName; +}; + export const SideBar: React.FC<{ open: boolean, buttons?: LinkItemProps[] }> = React.memo(({ open, buttons = [] }) => { const classes = useStyles(); - const selectedClusterName = React.useContext(ClusterContext)?.selectedClusterName ?? ''; + + // Most sidebar items need some state or are conditional, so here are all of the contexts they need at once. + const selectedClusterName = useSelectedOrDefaultClusterName(); + const embedState = React.useContext(LiveRouteContext)?.embedState; + const authClient = React.useMemo(() => GetOAuthProvider(), []); + const showInvitations = authClient.isInvitationEnabled(); + const scriptId = React.useContext(ScriptContext)?.script?.id; + const { pathname } = useLocation(); + + const isEmbedded = isPixieEmbedded(); + const defaultEmbedState = React.useMemo(() => ({ + isEmbedded, + disableTimePicker: false, + widget: null, + }), [isEmbedded]); const { user } = React.useContext(UserContext); - const pluginItems = React.useMemo(() => { - return [{ + const liveItems: LinkItemProps[] = React.useMemo(() => { + if (!selectedClusterName) { + return []; + } + return [ + { + icon: , + link: deepLinkURLFromScript('px/cluster', selectedClusterName, embedState ?? defaultEmbedState, {}), + text: 'Cluster', + active: scriptId === 'px/cluster', + }, + { + icon: , + link: deepLinkURLFromScript('px/namespaces', selectedClusterName, embedState ?? defaultEmbedState, {}), + text: 'Namespaces', + active: scriptId === 'px/namespaces', + }, + ]; + }, [defaultEmbedState, embedState, scriptId, selectedClusterName]); + + const allowAdmin = React.useMemo(() => !pathname.includes('/live'), [pathname]); + const adminItems: LinkItemProps[] = React.useMemo(() => allowAdmin ? [ + { + icon: , + link: '/admin/clusters', + text: 'Clusters', + active: pathname.endsWith('/admin/clusters'), + }, + { + icon: , + link: '/admin/keys/api', + text: 'Keys', + active: pathname.includes('/admin/keys'), + }, + { + icon: , + link: '/admin/plugins', + text: 'Plugins', + active: pathname.endsWith('/admin/plugins'), + }, + { + icon: , + link: '/admin/users', + text: 'Users', + active: pathname.endsWith('/admin/users'), + }, + { + icon: , + link: '/admin/org', + text: 'Org Settings', + active: pathname.endsWith('/admin/org'), + }, + { + icon: , + link: '/admin/user', + text: 'User Settings', + active: pathname.endsWith('/admin/user'), + }, + ...(showInvitations ? [{ + icon: , + link: '/admin/invite', + text: 'Invitations', + active: pathname.endsWith('/admin/invite'), + }] : []), + ] : [], [pathname, allowAdmin, showInvitations]); + + const pluginItems = React.useMemo(() => [ + { icon: , link: '/configure-data-export', text: 'Data Retention', - }]; - }, []); + active: pathname.includes('/configure-data-export'), + }, + ], [pathname]); + + const groupedItems = [liveItems, adminItems, pluginItems, buttons].filter(g => g?.length > 0); const drawerClasses = React.useMemo( () => ({ paper: open ? classes.drawerOpen : classes.drawerClose }), @@ -222,19 +352,14 @@ export const SideBar: React.FC<{ open: boolean, buttons?: LinkItemProps[] }> = R - {buttons.length > 0 && ( - - {buttons.map((props) => ( + {groupedItems.map((g, i) => ( + + {i > 0 && ()} + {g.map((props) => ( ))} - - )} - {buttons.length > 0 && } - - {pluginItems.map((props) => ( - - ))} - + + ))}
{ diff --git a/src/ui/src/pages/admin/admin.tsx b/src/ui/src/pages/admin/admin.tsx index 17c2d4c6b5a..2814224d947 100644 --- a/src/ui/src/pages/admin/admin.tsx +++ b/src/ui/src/pages/admin/admin.tsx @@ -18,26 +18,16 @@ import * as React from 'react'; -import { - AccountTree as OrgIcon, - Extension as PluginIcon, - Group as UsersIcon, - Person as UserIcon, - Send as InviteIcon, - VpnKey as KeyIcon, -} from '@mui/icons-material'; import { alpha } from '@mui/material'; import { Theme } from '@mui/material/styles'; import { createStyles, makeStyles } from '@mui/styles'; import { Route, Switch, Redirect, useLocation } from 'react-router-dom'; -import { ClusterIcon, scrollbarStyles, Footer } from 'app/components'; +import { scrollbarStyles, Footer } from 'app/components'; import { AdminOverview } from 'app/containers/admin/admin-overview'; import { LiveViewButton } from 'app/containers/admin/utils'; import NavBars from 'app/containers/App/nav-bars'; -import { LinkItemProps } from 'app/containers/App/sidebar'; import { SidebarContext } from 'app/context/sidebar-context'; -import { GetOAuthProvider } from 'app/pages/auth/utils'; import { WithChildren } from 'app/utils/react-boilerplate'; import { Copyright } from 'configurable/copyright'; @@ -88,75 +78,49 @@ const useStyles = makeStyles((theme: Theme) => createStyles({ export const AdminPage: React.FC = React.memo(({ children }) => { const classes = useStyles(); - // To determine if every tab is actually allowed - const authClient = React.useMemo(() => GetOAuthProvider(), []); - const showInvitations = authClient.isInvitationEnabled(); - const { pathname } = useLocation(); - const sidebarButtons: LinkItemProps[] = React.useMemo(() => [ - { - icon: , - link: '/admin/clusters', - text: 'Clusters', - active: pathname.endsWith('/admin/clusters'), - }, - { - icon: , - link: '/admin/keys/api', - text: 'Keys', - active: pathname.includes('/admin/keys'), - }, - { - icon: , - link: '/admin/plugins', - text: 'Plugins', - active: pathname.endsWith('/admin/plugins'), - }, - { - icon: , - link: '/admin/users', - text: 'Users', - active: pathname.endsWith('/admin/users'), - }, - { - icon: , - link: '/admin/org', - text: 'Org Settings', - active: pathname.endsWith('/admin/org'), - }, - { - icon: , - link: '/admin/user', - text: 'User Settings', - active: pathname.endsWith('/admin/user'), - }, - ...(showInvitations ? [{ - icon: , - link: '/admin/invite', - text: 'Invitations', - active: pathname.endsWith('/admin/invite'), - }] : []), - ], [pathname, showInvitations]); + + const titleLabel = React.useMemo(() => { + const tabSlugs = pathname.split('/'); + const tabSlug = tabSlugs[tabSlugs.indexOf('admin') + 1]; + switch (tabSlug) { + case 'clusters': + return 'Clusters'; + case 'keys': + return 'Keys'; + case 'plugins': + return 'Plugins'; + case 'users': + return 'Users'; + case 'org': + return 'Org Settings'; + case 'user': + return 'User Settings'; + case 'invite': + return 'Invitations'; + default: + return ''; + } + }, [pathname]); const titleText = React.useMemo(() => { - const active = sidebarButtons.find(b => b.active); - return active?.text ? ( + return titleLabel ? (
Admin - {active.text} + {titleLabel}
) : (
Admin
); - }, [classes.titleText, classes.titleDivider, sidebarButtons]); + }, [classes.titleText, classes.titleDivider, titleLabel]); return (
- +
{titleText}
diff --git a/src/ui/src/pages/auth/logout.tsx b/src/ui/src/pages/auth/logout.tsx index 776755de08b..412968338a8 100644 --- a/src/ui/src/pages/auth/logout.tsx +++ b/src/ui/src/pages/auth/logout.tsx @@ -20,11 +20,11 @@ import * as React from 'react'; import Axios from 'axios'; +import { GetCSRFCookie } from 'app/pages/auth/utils'; import pixieAnalytics from 'app/utils/analytics'; import * as RedirectUtils from 'app/utils/redirect-utils'; import { BasePage } from './base'; -import { GetCSRFCookie } from './utils'; // eslint-disable-next-line react-memo/require-memo export const LogoutPage: React.FC = () => { diff --git a/src/ui/src/pages/auth/mock-oauth-provider.ts b/src/ui/src/pages/auth/mock-oauth-provider.ts new file mode 100644 index 00000000000..f7c31060b39 --- /dev/null +++ b/src/ui/src/pages/auth/mock-oauth-provider.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2018- The Pixie Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { FormStructure } from 'app/components'; + +import { OAuthProviderClient } from './oauth-provider'; + +const mockForm: FormStructure = { + submitBtnText: 'Mock Submit', + action: 'https://mock.form.local', + fields: [], + method: 'POST', +}; + +export const MockOAuthClient: OAuthProviderClient = { + refetchToken() {}, + + handleToken: () => Promise.resolve({ + redirectArgs: {}, + token: {}, + }), + + getPasswordLoginFlow: () => Promise.resolve(mockForm), + + getResetPasswordFlow: () => Promise.resolve(mockForm), + + getError: () => Promise.resolve(mockForm), + + isInvitationEnabled: () => true, + + getInvitationComponent: () => undefined, + + getLoginButtons: () => null, + + getSignupButtons: () => null, +}; diff --git a/src/ui/src/pages/auth/oauth-provider.ts b/src/ui/src/pages/auth/oauth-provider.ts index 0b8020d136c..7fc6a4dc794 100644 --- a/src/ui/src/pages/auth/oauth-provider.ts +++ b/src/ui/src/pages/auth/oauth-provider.ts @@ -46,8 +46,8 @@ export interface OAuthProviderClient { getInvitationComponent(): React.FC | undefined; /** Gets the login buttons for this OAuthProvider. */ - getLoginButtons(): React.ReactElement; + getLoginButtons(): React.ReactNode; /** Gets the signup buttons for this OAuthProvider. */ - getSignupButtons(): React.ReactElement; + getSignupButtons(): React.ReactNode; } diff --git a/src/ui/src/pages/live/live.tsx b/src/ui/src/pages/live/live.tsx index 34aff629b2f..048e22e27ad 100644 --- a/src/ui/src/pages/live/live.tsx +++ b/src/ui/src/pages/live/live.tsx @@ -29,7 +29,7 @@ import { useFlags } from 'launchdarkly-react-client-sdk'; import { PixieAPIClient, PixieAPIContext } from 'app/api'; import { ClusterContext } from 'app/common/cluster-context'; import { isPixieEmbedded } from 'app/common/embed-context'; -import { ClusterIcon, EditIcon, Footer, NamespaceIcon, scrollbarStyles } from 'app/components'; +import { EditIcon, Footer, scrollbarStyles } from 'app/components'; import { CommandPalette } from 'app/components/command-palette'; import { CommandPaletteContext, @@ -41,7 +41,6 @@ import { LiveRouteContext } from 'app/containers/App/live-routing'; import { LiveTourContextProvider } from 'app/containers/App/live-tour'; import NavBars from 'app/containers/App/nav-bars'; import { SCRATCH_SCRIPT, ScriptsContext } from 'app/containers/App/scripts-context'; -import { LinkItemProps } from 'app/containers/App/sidebar'; import { DataDrawerSplitPanel } from 'app/containers/data-drawer/data-drawer'; import { EditorSplitPanel } from 'app/containers/editor/editor'; import { LiveViewBreadcrumbs } from 'app/containers/live/breadcrumbs'; @@ -51,7 +50,6 @@ import ExecuteScriptButton from 'app/containers/live/execute-button'; import { ScriptLoader } from 'app/containers/live/script-loader'; import ShareButton from 'app/containers/live/share-button'; import LiveViewShortcutsProvider from 'app/containers/live/shortcuts'; -import { deepLinkURLFromScript } from 'app/containers/live-widgets/utils/live-view-params'; import { SetStateFunc } from 'app/context/common'; import { DataDrawerContextProvider } from 'app/context/data-drawer-context'; import { EditorContext, EditorContextProvider } from 'app/context/editor-context'; @@ -297,33 +295,12 @@ const Nav: React.FC<{ const classes = useStyles(); const { showCommandPalette } = useFlags(); - const selectedClusterName = React.useContext(ClusterContext)?.selectedClusterName ?? ''; - const embedState = React.useContext(LiveRouteContext)?.embedState ?? null; - - const sidebarButtons: LinkItemProps[] = React.useMemo(() => { - if (!selectedClusterName || !embedState) { - return []; - } - return [ - { - icon: , - link: deepLinkURLFromScript('px/cluster', selectedClusterName, embedState, {}), - text: 'Cluster', - }, - { - icon: , - link: deepLinkURLFromScript('px/namespaces', selectedClusterName, embedState, {}), - text: 'Namespaces', - }, - ]; - }, [embedState, selectedClusterName]); - if (isPixieEmbedded()) { return <>; } return <> - +
{showCommandPalette && } diff --git a/src/ui/src/pages/setup/__snapshots__/setup-test.tsx.snap b/src/ui/src/pages/setup/__snapshots__/setup-test.tsx.snap index 94090f18687..e3b65479e06 100644 --- a/src/ui/src/pages/setup/__snapshots__/setup-test.tsx.snap +++ b/src/ui/src/pages/setup/__snapshots__/setup-test.tsx.snap @@ -101,82 +101,450 @@ exports[`setup page renders 1`] = ` />
-
+
- - + class="MuiTypography-root MuiTypography-body1 MuiListItemText-primary css-10hburv-MuiTypography-root" + > + Data Retention + +
+ +