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
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { renderToString } from 'react-dom/server'
import { IngressRenderer } from './IngressRenderer'

describe('IngressRenderer default backend', () => {
it('shows the default backend and no "not routed" warning', () => {
const html = renderToString(
<IngressRenderer data={{
metadata: { name: 'catch-all', namespace: 'team' },
spec: { ingressClassName: 'nginx', defaultBackend: { service: { name: 'fallback', port: { number: 80 } } } },
}} />,
)
expect(html).toContain('Default Backend')
expect(html.replace(/<!-- -->/g, '')).toContain('fallback:80')
expect(html).not.toContain('Traffic will not be routed')
})

it('still warns when an Ingress routes nowhere', () => {
const html = renderToString(<IngressRenderer data={{ metadata: { name: 'empty', namespace: 'team' }, spec: { ingressClassName: 'nginx' } }} />)
expect(html).toContain('Traffic will not be routed')
})

it('shows a resource default backend', () => {
const html = renderToString(
<IngressRenderer data={{
metadata: { name: 'static', namespace: 'team' },
spec: { ingressClassName: 'nginx', defaultBackend: { resource: { apiGroup: 'k8s.example.com', kind: 'StorageBucket', name: 'static-assets' } } },
}} />,
)
expect(html).toContain('Default Backend')
expect(html).toContain('StorageBucket/static-assets')
})
})
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Globe, Shield, Clock } from 'lucide-react'
import type { ResourceRef } from '../../../types'
import { Section, PropertyList, Property, AlertBanner, ResourceLink } from '../../ui/drawer-components'
import { kindToPluralWithGroup } from '../../../utils/navigation'

interface IngressRendererProps {
data: any
Expand All @@ -16,7 +17,10 @@ export function IngressRenderer({ data, onNavigate }: IngressRendererProps) {
// Check for issues
const hasNoAddress = lbIngress.length === 0
const hasNoClass = !spec.ingressClassName && !data.metadata?.annotations?.['kubernetes.io/ingress.class']
const hasNoRules = rules.length === 0
const defaultService = spec.defaultBackend?.service
const defaultResource = spec.defaultBackend?.resource
// An Ingress with only a default backend still routes everything to it.
const hasNoRules = rules.length === 0 && !spec.defaultBackend

return (
<>
Expand Down Expand Up @@ -50,11 +54,34 @@ export function IngressRenderer({ data, onNavigate }: IngressRendererProps) {
{lbIngress.length > 0 && (
<Property label="Address" value={lbIngress[0].ip || lbIngress[0].hostname} />
)}
{defaultService?.name && (
<Property label="Default Backend" value={
Comment thread
nadaverell marked this conversation as resolved.
<ResourceLink
name={defaultService.name}
kind="services"
namespace={data.metadata?.namespace || ''}
label={<span className="text-blue-400">{defaultService.name}:{defaultService.port?.number || defaultService.port?.name}</span>}
onNavigate={onNavigate}
/>
} />
)}
{defaultResource?.name && (
<Property label="Default Backend" value={
<ResourceLink
name={defaultResource.name}
kind={kindToPluralWithGroup(defaultResource.kind, defaultResource.apiGroup || '')}
group={defaultResource.apiGroup || ''}
namespace={data.metadata?.namespace || ''}
label={<span className="text-blue-400">{`${defaultResource.kind}/${defaultResource.name}`}</span>}
onNavigate={onNavigate}
/>
} />
)}
<Property label="TLS" value={tls.length > 0 ? `${tls.length} certificate(s)` : 'None'} />
</PropertyList>
</Section>

<Section title="Rules" defaultExpanded>
{rules.length > 0 && <Section title="Rules" defaultExpanded>
<div className="space-y-3">
{rules.map((rule: any, i: number) => (
<div key={i} className="card-inner-lg">
Expand Down Expand Up @@ -87,7 +114,7 @@ export function IngressRenderer({ data, onNavigate }: IngressRendererProps) {
</div>
))}
</div>
</Section>
</Section>}

{tls.length > 0 && (
<Section title="TLS" icon={Shield}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { baseSubtitle } from './K8sResourceNode'
import { baseSubtitle, knativeDomainsTooltipLines } from './K8sResourceNode'

describe('baseSubtitle', () => {
it('describes workflow templates without dropping newer topology kinds', () => {
Expand All @@ -18,6 +18,23 @@ describe('baseSubtitle', () => {
})).toBe('ClusterIP :80 +2 more')
})

it('describes a domain-mapped Knative Service by its custom domain', () => {
expect(baseSubtitle('KnativeService', { url: 'http://api.team.svc.cluster.local' })).toBe('api.team.svc.cluster.local')
expect(baseSubtitle('KnativeService', { url: 'http://api.team.svc.cluster.local', mappedDomains: ['api.example.com'] })).toBe('api.example.com')
expect(baseSubtitle('KnativeService', { mappedDomains: ['a.example.com', 'b.example.com'] })).toBe('a.example.com +1')
})

it('labels a mapped Knative Service URL by what it is', () => {
const text = (data: Record<string, unknown>) => knativeDomainsTooltipLines(data).map((l) => l.text)
// Private: the Service URL is the internal address, shown once.
expect(text({ mappedDomains: ['api.example.com'], url: 'http://api.team.svc.cluster.local', internalUrl: 'http://api.team.svc.cluster.local' }))
.toEqual(['api.example.com', 'Internal: http://api.team.svc.cluster.local'])
// Public: its own URL isn't internal.
expect(text({ mappedDomains: ['shop.example.com'], url: 'https://shop.team.example.org', internalUrl: 'http://shop.team.svc.cluster.local' }))
.toEqual(['shop.example.com', 'Service URL: https://shop.team.example.org', 'Internal: http://shop.team.svc.cluster.local'])
expect(text({ url: 'https://shop.team.example.org' })).toEqual([])
})

it('describes a Calico staged deletion as a removal, not as selecting everything', () => {
// A staged deletion carries no selector, because the Calico API forbids one.
// Falling through to the empty-selector default would label the broadest
Expand Down
42 changes: 41 additions & 1 deletion packages/k8s-ui/src/components/topology/K8sResourceNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,32 @@ export function servicePortsTooltip(ports: ServicePortEntry[]): React.ReactNode
}

// Default dimensions for unknown CRD kinds
// A domain-mapped Knative Service shows its custom domain as the subtitle; the
// tooltip lists every mapped domain, then the Service's own URL when it is
// public, and its cluster-internal address.
export function knativeDomainsTooltipLines(nodeData: Record<string, unknown>): { text: string; secondary?: boolean }[] {
const mapped = (nodeData.mappedDomains as string[] | undefined) || [];
if (mapped.length === 0) return [];
const url = nodeData.url as string | undefined;
const internalUrl = nodeData.internalUrl as string | undefined;
const lines: { text: string; secondary?: boolean }[] = mapped.map((domain) => ({ text: domain }));
if (url && url !== internalUrl) lines.push({ text: `Service URL: ${url}`, secondary: true });
if (internalUrl) lines.push({ text: `Internal: ${internalUrl}`, secondary: true });
return lines;
}

function knativeDomainsTooltip(nodeData: Record<string, unknown>): React.ReactNode | null {
const lines = knativeDomainsTooltipLines(nodeData);
if (lines.length === 0) return null;
return (
<div className="space-y-0.5">
{lines.map((line) => (
<div key={line.text} className={line.secondary ? 'text-theme-text-tertiary' : undefined}>{line.text}</div>
))}
</div>
);
}

export const DEFAULT_NODE_DIMENSIONS = { width: 260, height: 84 };

const NODE_NAME_LENGTH_LIMITS: Partial<Record<NodeKind, number>> = {
Expand Down Expand Up @@ -488,6 +514,10 @@ export function baseSubtitle(kind: NodeKind, nodeData: Record<string, unknown>):
return `${count} pods (${healthy} healthy)`;
}
case "KnativeService": {
const mapped = (nodeData.mappedDomains as string[] | undefined) || [];
if (mapped.length > 0) {
return mapped.length > 1 ? `${mapped[0]} +${mapped.length - 1}` : mapped[0];
}
const url = nodeData.url as string;
if (url) {
// Show just the hostname from the URL for compactness
Expand Down Expand Up @@ -558,7 +588,10 @@ export const K8sResourceNode = memo(function K8sResourceNode({
const subtitle = getSubtitle(kind, nodeData)
const portsTooltip = kind === 'Service'
? servicePortsTooltip((nodeData.ports as ServicePortEntry[] | undefined) || [])
: null
: kind === 'KnativeService'
? knativeDomainsTooltip(nodeData)
: null
const clusterLocal = kind === 'KnativeService' && nodeData.visibility === 'cluster-local'
const isInternet = kind === 'Internet'
const isPodGroup = kind === 'PodGroup'
const isSmallNode = kind === 'ConfigMap' || kind === 'Secret' || kind === 'ServiceAccount' || kind === 'SealedSecret' || kind === 'ServiceMonitor' || kind === 'PodMonitor' || kind === 'HorizontalPodAutoscaler'
Expand Down Expand Up @@ -664,6 +697,13 @@ export const K8sResourceNode = memo(function K8sResourceNode({
</span>
</Tooltip>
)}
{clusterLocal && (
<Tooltip content="Reachable only inside the cluster, so it has no Internet edge" position="right">
<Badge tone="note" size="sm" className="!text-[9px] !px-1 !py-0.5 normal-case tracking-normal cursor-help">
Cluster-local
</Badge>
</Tooltip>
)}
{trafficRole && (
<Badge
tone={trafficRole === 'canary' || trafficRole === 'preview' ? 'accent1' : 'accent2'}
Expand Down
74 changes: 3 additions & 71 deletions packages/k8s-ui/src/components/topology/TopologyGraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'

import { K8sResourceNode } from './K8sResourceNode'
import { GroupNode } from './GroupNode'
import { splitInternetByGroup } from './internet-groups'
import { NEUTRAL_OWNER, type WorkloadFocus } from '../../utils/workload-colors'
import { ownershipOf } from '../../utils/topology-neighborhood'
import { buildHierarchicalElkGraph, applyHierarchicalLayout, getGroupKey, isGroupEffectivelyCollapsed, type GroupDisplayLevel } from './layout'
Expand Down Expand Up @@ -546,75 +547,6 @@ export function TopologyGraph({
return { nodes: newNodes, edges: newEdges }
}, [])

// Transform to per-group Internet nodes in traffic view with grouping
const createPerGroupInternetNodes = useCallback((
nodes: TopologyNode[],
edges: TopologyEdge[],
groupMode: GroupingMode
): { nodes: TopologyNode[]; edges: TopologyEdge[] } => {
if (groupMode === 'none') {
return { nodes, edges }
}

// Find the single Internet node
const internetNode = nodes.find(n => n.kind === 'Internet')
if (!internetNode) {
return { nodes, edges }
}

// Find all ingresses/gateways and group them
const ingresses = nodes.filter(n => n.kind === 'Ingress' || n.kind === 'Gateway')
const groupsWithIngresses = new Map<string, TopologyNode[]>()

for (const ingress of ingresses) {
const groupKey = getGroupKey(ingress, groupMode)
if (groupKey) {
if (!groupsWithIngresses.has(groupKey)) {
groupsWithIngresses.set(groupKey, [])
}
groupsWithIngresses.get(groupKey)!.push(ingress)
}
}

// If no groups with ingresses, keep original
if (groupsWithIngresses.size === 0) {
return { nodes, edges }
}

// Remove original Internet node and its edges
const newNodes = nodes.filter(n => n.id !== internetNode.id)
const newEdges = edges.filter(e => e.source !== internetNode.id)

// Create per-group Internet nodes
for (const [groupKey, groupIngresses] of groupsWithIngresses) {
const internetId = `internet-${groupMode}-${groupKey}`

// Add Internet node for this group with group metadata
newNodes.push({
id: internetId,
kind: 'Internet',
name: 'Internet',
status: 'healthy',
data: {
// Add group metadata so it gets grouped with its ingresses
namespace: groupMode === 'namespace' ? groupKey : groupIngresses[0]?.data?.namespace,
labels: groupMode === 'app' ? { 'app.kubernetes.io/name': groupKey } : {},
},
})

// Add edges from this Internet node to its ingresses
for (const ingress of groupIngresses) {
newEdges.push({
id: `${internetId}-to-${ingress.id}`,
source: internetId,
target: ingress.id,
type: 'routes-to',
})
}
}

return { nodes: newNodes, edges: newEdges }
}, [])

// Prepare topology data with expanded pod groups
const { workingNodes, workingEdges } = useMemo(() => {
Expand All @@ -634,13 +566,13 @@ export function TopologyGraph({

// In traffic view with grouping, create per-group Internet nodes
if (isTrafficView && groupingMode !== 'none') {
const result = createPerGroupInternetNodes(nodes, edges, groupingMode)
const result = splitInternetByGroup(nodes, edges, groupingMode)
nodes = result.nodes
edges = result.edges
}

return { workingNodes: nodes, workingEdges: edges }
}, [topology, expandedPodGroups, expandPodGroup, isTrafficView, groupingMode, createPerGroupInternetNodes])
}, [topology, expandedPodGroups, expandPodGroup, isTrafficView, groupingMode])

// Handle card click in card-grid view — find the topology node and open drawer
const handleCardClick = useCallback((nodeId: string) => {
Expand Down
35 changes: 35 additions & 0 deletions packages/k8s-ui/src/components/topology/internet-groups.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import type { TopologyEdge, TopologyNode } from '../../types'
import { splitInternetByGroup } from './internet-groups'

const node = (id: string, kind: string, namespace?: string): TopologyNode =>
({ id, kind, name: id, status: 'healthy', data: namespace ? { namespace } : {} }) as TopologyNode
const fromInternet = (target: string): TopologyEdge => ({ id: `internet-to-${target}`, source: 'internet', target, type: 'routes-to' })

describe('splitInternetByGroup', () => {
it('keeps every entrypoint connected, not only Ingresses and Gateways', () => {
const nodes = [
node('internet', 'Internet'),
node('ingress/shop/web', 'Ingress', 'shop'),
node('knativeservice/shop/api', 'KnativeService', 'shop'),
node('ingressroute/edge/dash', 'IngressRoute', 'edge'),
]
const edges = nodes.slice(1).map(n => fromInternet(n.id))
const out = splitInternetByGroup(nodes, edges, 'namespace')

const reached = out.edges.filter(e => out.nodes.find(n => n.id === e.source)?.kind === 'Internet').map(e => `${e.source} -> ${e.target}`)
expect(reached.sort()).toEqual([
'internet-namespace-edge -> ingressroute/edge/dash',
'internet-namespace-shop -> ingress/shop/web',
'internet-namespace-shop -> knativeservice/shop/api',
])
expect(out.nodes.some(n => n.id === 'internet')).toBe(false)
})

it('leaves an ungrouped entrypoint on the original Internet node', () => {
const nodes = [node('internet', 'Internet'), node('ingress/shop/web', 'Ingress', 'shop'), node('gateway//shared', 'Gateway')]
const out = splitInternetByGroup(nodes, nodes.slice(1).map(n => fromInternet(n.id)), 'namespace')
expect(out.edges.some(e => e.source === 'internet' && e.target === 'gateway//shared')).toBe(true)
expect(out.nodes.some(n => n.id === 'internet')).toBe(true)
})
})
55 changes: 55 additions & 0 deletions packages/k8s-ui/src/components/topology/internet-groups.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { GroupingMode, TopologyEdge, TopologyNode } from '../../types'
import { getGroupKey } from './layout'

/**
* Splits the traffic view's single Internet node into one per group, so each
* namespace or app shows its own entry from outside. Every original Internet
* edge is kept, whatever kind its entrypoint is (Ingress, Gateway, Istio
* Gateway, Knative Service, Traefik route, Contour HTTPProxy); an entrypoint
* with no group stays connected to the original Internet node.
*/
export function splitInternetByGroup(
nodes: TopologyNode[],
edges: TopologyEdge[],
groupMode: GroupingMode,
): { nodes: TopologyNode[]; edges: TopologyEdge[] } {
if (groupMode === 'none') return { nodes, edges }
const internetNode = nodes.find(n => n.kind === 'Internet')
if (!internetNode) return { nodes, edges }

const nodeById = new Map(nodes.map(n => [n.id, n]))
const groups = new Map<string, { entry: TopologyNode; edges: TopologyEdge[] }>()
const regrouped = new Set<TopologyEdge>()
for (const edge of edges) {
if (edge.source !== internetNode.id) continue
const target = nodeById.get(edge.target)
const groupKey = target ? getGroupKey(target, groupMode) : null
if (!target || !groupKey) continue
if (!groups.has(groupKey)) groups.set(groupKey, { entry: target, edges: [] })
groups.get(groupKey)!.edges.push(edge)
regrouped.add(edge)
}
if (groups.size === 0) return { nodes, edges }

const keepOriginal = edges.some(e => e.source === internetNode.id && !regrouped.has(e))
const newNodes = keepOriginal ? [...nodes] : nodes.filter(n => n.id !== internetNode.id)
const newEdges = edges.filter(e => !regrouped.has(e))
for (const [groupKey, group] of groups) {
const internetId = `internet-${groupMode}-${groupKey}`
newNodes.push({
id: internetId,
kind: 'Internet',
name: 'Internet',
status: 'healthy',
data: {
// Group metadata so the node lands inside its entrypoints' group.
namespace: groupMode === 'namespace' ? groupKey : group.entry.data?.namespace,
labels: groupMode === 'app' ? { 'app.kubernetes.io/name': groupKey } : {},
},
})
for (const edge of group.edges) {
newEdges.push({ ...edge, id: `${internetId}-to-${edge.target}`, source: internetId })
}
}
return { nodes: newNodes, edges: newEdges }
}
Loading
Loading