Skip to content
Open
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
53 changes: 53 additions & 0 deletions kmesh/src/components/nodeinfo/Detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@
* Detail view for a single KmeshNodeInfo resource.
* Shows full IPsec security state for one node: SPI, addresses, Pod CIDRs, and boot ID.
*/
import { K8s } from '@kinvolk/headlamp-plugin/lib';
import {
MainInfoSection,
SectionBox,
SimpleTable,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import { Link as HeadlampLink } from '@kinvolk/headlamp-plugin/lib/components/common';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
Expand All @@ -15,8 +18,54 @@ import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import React from 'react';
import { useParams } from 'react-router-dom';
import { useKmeshDaemonPods } from '../../hooks/useKmeshDaemonPods';
import { KmeshNodeInfo } from '../../resources/kmeshNodeInfo';

/** Related resources for a KmeshNodeInfo: the underlying Node and the kmesh-daemon Pod on it. */
function NodeInfoRelatedResources({ nodeName }: { nodeName: string }) {
const [node] = K8s.ResourceClasses.Node.useGet(nodeName);
const { pods: daemonPods } = useKmeshDaemonPods();
const daemonPod = daemonPods.find(p => p.nodeName === nodeName) ?? null;
const [daemonPodObject] = K8s.ResourceClasses.Pod.useGet(
daemonPod?.name ?? '',
daemonPod?.namespace ?? ''
);
Comment on lines +29 to +32

return (
<SectionBox title="Related Resources">
<SimpleTable
data={[
{ label: 'Node', resources: node ? [node] : [] },
{ label: 'Kmesh Daemon Pod', resources: daemonPodObject ? [daemonPodObject] : [] },
]}
columns={[
{
label: 'Resource Type',
getter: (row: { label: string; resources: any[] }) => row.label,
},
{
label: 'Resource',
getter: (row: { label: string; resources: any[] }) =>
row.resources.length > 0 ? (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{row.resources.map(resource => (
<HeadlampLink key={resource.metadata.uid} kubeObject={resource}>
{resource.getName()}
</HeadlampLink>
))}
</Box>
) : (
<Typography variant="body2" color="text.secondary">
Not found
</Typography>
),
},
]}
/>
</SectionBox>
);
}

/**
* Detail view for a single KmeshNodeInfo resource.
*/
Expand Down Expand Up @@ -169,6 +218,10 @@ export default function KmeshNodeInfoDetail() {
]}
/>
</Box>

<Box sx={{ mt: 2 }}>
<NodeInfoRelatedResources nodeName={nodeInfo.getName()} />
</Box>
</Box>
);
}
129 changes: 129 additions & 0 deletions kmesh/src/components/waypoints/Detail.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const { mockUseGet, mockPodUseList, mockServiceUseList, mockNamespaceUseList, mockUseParams } =
vi.hoisted(() => ({
mockUseGet: vi.fn(),
mockPodUseList: vi.fn(),
mockServiceUseList: vi.fn(),
mockNamespaceUseList: vi.fn(),
mockUseParams: vi.fn(() => ({})),
}));

vi.mock('react-router-dom', () => ({
useParams: mockUseParams,
}));

vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
K8s: {
ResourceClasses: {
Pod: { useList: mockPodUseList },
Service: { useList: mockServiceUseList },
Namespace: { useList: mockNamespaceUseList },
},
},
}));

vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
ObjectEventList: () => null,
SectionBox: ({ title, children }: any) => <div data-testid={`section-${title}`}>{children}</div>,
SimpleTable: ({ data, columns }: any) => (
<table>
<tbody>
{data.map((row: any, i: number) => (
<tr key={i}>
{columns.map((col: any) => (
<td key={col.label}>{col.getter(row)}</td>
))}
</tr>
))}
</tbody>
</table>
),
StatusLabel: ({ children }: any) => <span>{children}</span>,
}));

vi.mock('@kinvolk/headlamp-plugin/lib/components/common', () => ({
MainInfoSection: ({ extraInfo }: any) => (
<div data-testid="main-info-section">
{extraInfo?.map((info: any) => (
<div key={info.name}>
{info.name}: {String(info.value)}
</div>
))}
</div>
),
Link: ({ kubeObject, children }: any) => <span>{children ?? kubeObject?.getName()}</span>,
}));

vi.mock('../../resources/waypoint', () => ({
Waypoint: { useGet: mockUseGet },
}));

import WaypointDetail from './Detail';

beforeEach(() => {
mockPodUseList.mockReturnValue([[]]);
mockServiceUseList.mockReturnValue([[]]);
mockNamespaceUseList.mockReturnValue([[]]);
});

afterEach(() => {
cleanup();
mockUseGet.mockReset();
mockPodUseList.mockReset();
mockServiceUseList.mockReset();
mockNamespaceUseList.mockReset();
mockUseParams.mockReset().mockReturnValue({});
});

function waypoint(overrides: Record<string, any> = {}) {
return {
spec: { gatewayClassName: 'kmesh-waypoint' },
status: { conditions: [] },
image: 'kmesh/waypoint:latest',
currentStatus: 'Programmed',
getName: () => 'my-waypoint',
metadata: { uid: 'waypoint-uid', namespace: 'default' },
...overrides,
};
}

function pod(name: string) {
return { getName: () => name, metadata: { uid: `pod-${name}` } };
}

function service(name: string) {
return { getName: () => name, metadata: { uid: `svc-${name}` } };
}

function namespace(name: string, labels: Record<string, string> = {}) {
return { getName: () => name, metadata: { uid: `ns-${name}`, labels } };
}

describe('WaypointDetail related resources', () => {
it('shows "None found" for all related resource rows when nothing matches', () => {
mockUseParams.mockReturnValue({ namespace: 'default', name: 'my-waypoint' });
mockUseGet.mockReturnValue([waypoint(), null]);

render(<WaypointDetail />);

expect(screen.getAllByText('None found')).toHaveLength(3);
});

it('renders links for proxy pods, the proxy service, and enrolled namespaces', () => {
mockUseParams.mockReturnValue({ namespace: 'default', name: 'my-waypoint' });
mockUseGet.mockReturnValue([waypoint(), null]);
mockPodUseList.mockReturnValue([[pod('my-waypoint-abcde')]]);
mockServiceUseList.mockReturnValue([[service('my-waypoint')]]);
mockNamespaceUseList.mockReturnValue([
[namespace('team-a', { 'istio.io/use-waypoint': 'my-waypoint' }), namespace('team-b')],
]);

render(<WaypointDetail />);

expect(screen.getByText('my-waypoint-abcde')).toBeTruthy();
expect(screen.getByText('team-a')).toBeTruthy();
expect(screen.queryByText('team-b')).toBeNull();
Comment on lines +125 to +127
});
});
78 changes: 77 additions & 1 deletion kmesh/src/components/waypoints/Detail.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
import { K8s } from '@kinvolk/headlamp-plugin/lib';
import {
ObjectEventList,
SectionBox,
SimpleTable,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import { MainInfoSection } from '@kinvolk/headlamp-plugin/lib/components/common';
import {
Link as HeadlampLink,
MainInfoSection,
} from '@kinvolk/headlamp-plugin/lib/components/common';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { useParams } from 'react-router-dom';
import { Waypoint } from '../../resources/waypoint';
import { kmeshRoutePaths } from '../../utils/kmeshRoutes';

/** Label the Gateway API deployer sets on the proxy Pod/Service it creates for a Gateway. */
const GATEWAY_NAME_LABEL = 'istio.io/gateway-name';
/** Label a Namespace carries when its workloads are enrolled to use a specific waypoint. */
const USE_WAYPOINT_LABEL = 'istio.io/use-waypoint';

/**
* Props for the Waypoint Detail view component.
*
Expand Down Expand Up @@ -92,6 +103,70 @@ function ConditionsTable({ conditions }: { conditions?: any[] }) {
);
}

interface RelatedResourceRow {
label: string;
resources: any[];
}

function RelatedResourcesTable({ rows }: { rows: RelatedResourceRow[] }) {
return (
<SectionBox title="Related Resources">
<SimpleTable
data={rows}
columns={[
{
label: 'Resource Type',
getter: (row: RelatedResourceRow) => row.label,
},
{
label: 'Resources',
getter: (row: RelatedResourceRow) =>
row.resources.length > 0 ? (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{row.resources.map(resource => (
<HeadlampLink key={resource.metadata.uid} kubeObject={resource}>
{resource.getName()}
</HeadlampLink>
))}
</Box>
) : (
<Typography variant="body2" color="text.secondary">
None found
</Typography>
),
},
]}
/>
</SectionBox>
);
}

function WaypointRelatedResources({ name, namespace }: { name: string; namespace: string }) {
const [proxyPods] = K8s.ResourceClasses.Pod.useList({
namespace,
labelSelector: `${GATEWAY_NAME_LABEL}=${name}`,
});
const [proxyServices] = K8s.ResourceClasses.Service.useList({
namespace,
labelSelector: `${GATEWAY_NAME_LABEL}=${name}`,
});
const [namespaces] = K8s.ResourceClasses.Namespace.useList();

const enrolledNamespaces = (namespaces ?? []).filter(
ns => ns.metadata?.labels?.[USE_WAYPOINT_LABEL] === name
);
Comment on lines +155 to +157

return (
<RelatedResourcesTable
rows={[
{ label: 'Proxy Pods', resources: proxyPods ?? [] },
{ label: 'Proxy Service', resources: proxyServices ?? [] },
{ label: 'Namespaces Using This Waypoint', resources: enrolledNamespaces },
]}
/>
);
}

function WaypointDetailContent({
name,
namespace,
Expand Down Expand Up @@ -126,6 +201,7 @@ function WaypointDetailContent({
]}
/>
{waypoint && <ConditionsTable conditions={waypoint.status?.conditions} />}
{waypoint && <WaypointRelatedResources name={name} namespace={namespace} />}
{waypoint && <ObjectEventList object={waypoint as any} />}
</>
);
Expand Down
Loading