Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Upcoming Features
---

Update Firewall Landing table to account for Linode Interface devices and Default Firewalls ([#11920](https://github.com/linode/manager/pull/11920))
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Upcoming Features
---

Add Default Firewall chips to Firewall Detail page ([#11920](https://github.com/linode/manager/pull/11920))
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ describe('NetworkInterfaces', () => {
HttpResponse.json(makeResourcePage(firewallFactory.buildList(1)))
)
);
const { getByTestId, getByText } = renderWithTheme(<DefaultFirewalls />);
const { getByTestId, getByText } = renderWithTheme(<DefaultFirewalls />, {
flags: { linodeInterfaces: { enabled: true } },
});

// Loading state should render
expect(getByTestId(loadingTestId)).toBeInTheDocument();
Expand Down
5 changes: 4 additions & 1 deletion packages/manager/src/features/Account/DefaultFirewalls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { useSnackbar } from 'notistack';
import * as React from 'react';
import { Controller, useForm } from 'react-hook-form';

import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes';

import { FirewallSelect } from '../Firewalls/components/FirewallSelect';

import type { UpdateFirewallSettings } from '@linode/api-v4';
Expand All @@ -27,12 +29,13 @@ const DEFAULT_FIREWALL_PLACEHOLDER = 'None';

export const DefaultFirewalls = () => {
const { enqueueSnackbar } = useSnackbar();
const { isLinodeInterfacesEnabled } = useIsLinodeInterfacesEnabled();

const {
data: firewallSettings,
error: firewallSettingsError,
isLoading: isLoadingFirewallSettings,
} = useFirewallSettingsQuery();
} = useFirewallSettingsQuery({ enabled: isLinodeInterfacesEnabled });

const { mutateAsync: updateFirewallSettings } = useMutateFirewallSettings();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { usePaginationV2 } from 'src/hooks/usePaginationV2';
import { getAPIErrorOrDefault } from 'src/utilities/errorUtils';
import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes';

import { getLinodeIdFromInterfaceDevice } from '../../shared';
import { formattedTypes } from './constants';
import { FirewallDeviceRow } from './FirewallDeviceRow';

Expand Down Expand Up @@ -69,7 +70,7 @@ export const FirewallDeviceTable = React.memo(

const updatedDevices = devices.map((device) => {
if (device.entity.type === 'interface') {
const linodeId = Number(device.entity.url.split('/')[4]);
const linodeId = getLinodeIdFromInterfaceDevice(device.entity);
const associatedLinode = linodesWithInterfaces?.find(
(linode) => linode.id === linodeId
);
Expand Down
57 changes: 52 additions & 5 deletions packages/manager/src/features/Firewalls/FirewallDetail/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import {
useAllFirewallDevicesQuery,
useFirewallQuery,
useFirewallSettingsQuery,
useGrants,
useMutateFirewall,
useProfile,
} from '@linode/queries';
import { CircleProgress, ErrorState } from '@linode/ui';
import { Chip, CircleProgress, ErrorState, Paper } from '@linode/ui';
import { Typography } from '@mui/material';
import { useParams } from '@tanstack/react-router';
import * as React from 'react';

Expand All @@ -15,6 +17,7 @@ import { GenerateFirewallDialog } from 'src/components/GenerateFirewallDialog/Ge
import { LandingHeader } from 'src/components/LandingHeader';
import { LinkButton } from 'src/components/LinkButton';
import { NotFound } from 'src/components/NotFound';
import { SuspenseLoader } from 'src/components/SuspenseLoader';
import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel';
import { TabPanels } from 'src/components/Tabs/TabPanels';
import { Tabs } from 'src/components/Tabs/Tabs';
Expand All @@ -25,8 +28,14 @@ import { useTabs } from 'src/hooks/useTabs';
import { getErrorStringOrDefault } from 'src/utilities/errorUtils';
import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes';

import { checkIfUserCanModifyFirewall } from '../shared';
import { SuspenseLoader } from 'src/components/SuspenseLoader';
import {
FIREWALL_DEFAULT_ENTITY_TO_READABLE_NAME,
getFirewallDefaultEntities,
} from '../components/FirewallSelectOption.utils';
import {
checkIfUserCanModifyFirewall,
getLinodeIdFromInterfaceDevice,
} from '../shared';

const FirewallRulesLanding = React.lazy(() =>
import('./Rules/FirewallRulesLanding').then((module) => ({
Expand Down Expand Up @@ -57,6 +66,14 @@ export const FirewallDetail = () => {

const firewallId = Number(id);

const { data: firewallSettings } = useFirewallSettingsQuery({
enabled: isLinodeInterfacesEnabled,
});

const defaultEntities =
firewallSettings &&
getFirewallDefaultEntities(firewallId, firewallSettings);

const userCanModifyFirewall = checkIfUserCanModifyFirewall(
firewallId,
profile,
Expand All @@ -75,7 +92,7 @@ export const FirewallDetail = () => {
isLinodeInterfacesEnabled &&
device.entity.type === 'interface'
) {
const linodeId = device.entity.url.split('/')[4];
const linodeId = getLinodeIdFromInterfaceDevice(device.entity);
if (!acc.seenLinodeIdsForInterfaces.has(linodeId)) {
acc.linodeCount += 1;
}
Expand All @@ -86,7 +103,7 @@ export const FirewallDetail = () => {
{
linodeCount: 0,
nodebalancerCount: 0,
seenLinodeIdsForInterfaces: new Set<string>(),
seenLinodeIdsForInterfaces: new Set<number>(),
}
) || {
linodeCount: 0,
Expand Down Expand Up @@ -173,6 +190,36 @@ export const FirewallDetail = () => {
{...secureVMFirewallBanner.firewallDetails}
/>
)}
{isLinodeInterfacesEnabled &&
defaultEntities &&
defaultEntities.length > 0 && (
<Paper
sx={(theme) => ({
alignItems: 'center',
columnGap: 1,
display: 'flex',
flexWrap: 'wrap',
margin: `${theme.spacingFunction(8)} 0`,
padding: `${theme.spacingFunction(8)} ${theme.spacingFunction(
16
)}`,
rowGap: 1,
})}
>
<Typography
sx={(theme) => ({ marginRight: theme.spacingFunction(8) })}
>
<strong>Default</strong>
</Typography>
{defaultEntities.map((defaultEntity) => (
<Chip
key={defaultEntity}
label={FIREWALL_DEFAULT_ENTITY_TO_READABLE_NAME[defaultEntity]}
size="small"
/>
))}
</Paper>
)}
<Tabs index={tabIndex} onChange={handleTabChange}>
<TanStackTabLinkList tabs={tabs} />
<React.Suspense fallback={<SuspenseLoader />}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import { FirewallStatus } from '@linode/api-v4/lib/firewalls';
import { Theme, useTheme } from '@mui/material/styles';
import { useGrants, useProfile } from '@linode/queries';
import { useTheme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery';
import * as React from 'react';

import { Action, ActionMenu } from 'src/components/ActionMenu/ActionMenu';
import { ActionMenu } from 'src/components/ActionMenu/ActionMenu';
import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction';
import { useGrants, useProfile } from '@linode/queries';
import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes';

import { checkIfUserCanModifyFirewall } from '../shared';
import {
DEFAULT_FIREWALL_TOOLTIP_TEXT,
NO_PERMISSIONS_TOOLTIP_TEXT,
} from './constants';

import type { FirewallStatus } from '@linode/api-v4/lib/firewalls';
import type { Theme } from '@mui/material/styles';
import type { Action } from 'src/components/ActionMenu/ActionMenu';

export interface ActionHandlers {
[index: string]: any;
Expand All @@ -20,21 +28,21 @@ interface Props extends ActionHandlers {
firewallID: number;
firewallLabel: string;
firewallStatus: FirewallStatus;
isDefaultFirewall: boolean;
}

export const noPermissionTooltipText =
"You don't have permissions to modify this Firewall.";

export const FirewallActionMenu = React.memo((props: Props) => {
const theme = useTheme<Theme>();
const matchesSmDown = useMediaQuery(theme.breakpoints.down('md'));
const { data: profile } = useProfile();
const { data: grants } = useGrants();
const { isLinodeInterfacesEnabled } = useIsLinodeInterfacesEnabled();

const {
firewallID,
firewallLabel,
firewallStatus,
isDefaultFirewall,
triggerDeleteFirewall,
triggerDisableFirewall,
triggerEnableFirewall,
Expand All @@ -46,12 +54,15 @@ export const FirewallActionMenu = React.memo((props: Props) => {
grants
);

const disabledProps = !userCanModifyFirewall
? {
disabled: true,
tooltip: noPermissionTooltipText,
}
: {};
const disabledProps =
!userCanModifyFirewall || (isLinodeInterfacesEnabled && isDefaultFirewall)
? {
disabled: true,
tooltip: isDefaultFirewall
? DEFAULT_FIREWALL_TOOLTIP_TEXT
: NO_PERMISSIONS_TOOLTIP_TEXT,
}
: {};

const actions: Action[] = [
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useFirewallsQuery } from '@linode/queries';
import { Button, CircleProgress, ErrorState } from '@linode/ui';
import { useLocation, useNavigate } from '@tanstack/react-router';
import * as React from 'react';
Expand All @@ -19,7 +20,6 @@ import { useOrder } from 'src/hooks/useOrder';
import { usePagination } from 'src/hooks/usePagination';
import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck';
import { useSecureVMNoticesEnabled } from 'src/hooks/useSecureVMNoticesEnabled';
import { useFirewallsQuery } from '@linode/queries';
import { getAPIErrorOrDefault } from 'src/utilities/errorUtils';

import { CreateFirewallDrawer } from './CreateFirewallDrawer';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ import {
getRuleString,
} from './FirewallRow';

const queryMocks = vi.hoisted(() => ({
useFirewallSettingsQuery: vi.fn().mockReturnValue({}),
}));

vi.mock('@linode/queries', async () => {
const actual = await vi.importActual('@linode/queries');
return {
...actual,
useFirewallSettingsQuery: queryMocks.useFirewallSettingsQuery,
};
});

beforeAll(() => mockMatchMedia());

describe('FirewallRow', () => {
Expand Down Expand Up @@ -54,6 +66,27 @@ describe('FirewallRow', () => {
triggerEnableFirewall: mockTriggerEnableFirewall,
};

it('renders a TableRow with the default firewall chip, status, rules, and Linodes', () => {
queryMocks.useFirewallSettingsQuery.mockReturnValue({
data: {
default_firewall_ids: {
linode: null,
nodebalancer: null,
public_interface: 1,
vpc_interface: null,
},
},
});
const { getByTestId, getByText } = render(
wrapWithTableBody(<FirewallRow {...baseProps} />, {
flags: { linodeInterfaces: { enabled: true } },
})
);
getByTestId('firewall-row-1');
getByText(firewall.label);
getByText('DEFAULT');
});

it('renders a TableRow with label, status, rules, and Linodes', () => {
const { getByTestId, getByText } = render(
wrapWithTableBody(<FirewallRow {...baseProps} />)
Expand All @@ -68,21 +101,33 @@ describe('FirewallRow', () => {
describe('getDeviceLinks', () => {
it('should return a single Link if one Device is attached', () => {
const device = firewallDeviceFactory.build();
const links = getDeviceLinks([device.entity]);
const links = getDeviceLinks({
entities: [device.entity],
isLoading: false,
linodesWithInterfaceDevices: undefined,
});
const { getByText } = renderWithTheme(links);
expect(getByText(device.entity.label ?? ''));
});

it('should render up to three comma-separated links', () => {
const devices = firewallDeviceFactory.buildList(3);
const links = getDeviceLinks(devices.map((device) => device.entity));
const links = getDeviceLinks({
entities: devices.map((device) => device.entity),
isLoading: false,
linodesWithInterfaceDevices: undefined,
});
const { queryAllByTestId } = renderWithTheme(links);
expect(queryAllByTestId('firewall-row-link')).toHaveLength(3);
});

it('should render "plus N more" text for any devices over three', () => {
const devices = firewallDeviceFactory.buildList(13);
const links = getDeviceLinks(devices.map((device) => device.entity));
const links = getDeviceLinks({
entities: devices.map((device) => device.entity),
isLoading: false,
linodesWithInterfaceDevices: undefined,
});
const { getByText, queryAllByTestId } = renderWithTheme(links);
expect(queryAllByTestId('firewall-row-link')).toHaveLength(3);
expect(getByText(/10 more/));
Expand Down
Loading