Skip to content

Commit 6713f57

Browse files
authored
Topology routing: per-Pod Service membership, default backends, private Knative, and every Internet entrypoint (#2045)
The traffic view drew routes that don't exist and dropped some that do: - A Service selecting only stable Pods was drawn to canary Pods too. - An Ingress with only a default backend had no edge, and its drawer warned "Traffic will not be routed". - A private Knative Service looked reachable from the Internet. - Every Traefik IngressRoute, Knative Service, Istio Gateway and Contour HTTPProxy was disconnected from the Internet node. The traffic view always groups by namespace for layout, even under "No Grouping", and regrouping rebuilt Internet edges only to Ingresses and Gateways. These come from an audit of every place Radar builds topology edges; this PR fixes the routing and membership findings. ## What changed **Pod membership is per Pod, not per group.** Pods group by app label, so one group can hold stable and canary Pods that different Services select. The traffic view took the union of the group's Services and drew every one to every Pod. - Membership is now recorded per Pod (`PodGroup.PodServiceIDs`). - Individual Pod edges and summary-mode health counts use each Pod's own Services. - When a Service selects only part of a group, its edge to the group is labelled, e.g. "5 of 7 pods". Expanding the group reconnects each Pod only to its own Services, through the same `ownerIds` the resources view uses for mixed-owner groups. **Selectors require their keys.** A selector value of `""` matches only Pods that carry the label with an empty value, as in Kubernetes. Radar read a missing key as `""`, so a Service selecting `role: ""` was drawn to Pods without the label. This applies to every selector match in topology. **Every Internet entrypoint stays connected.** Regrouping the Internet node now moves each original Internet edge, whatever the entrypoint kind, and leaves an ungrouped entrypoint on the original node. The logic moved into a pure, tested helper, `internet-groups.ts`. **Ingress default backends.** Several readers ignored `spec.defaultBackend`. One extraction (`configrefs.IngressBackendServices`: the default backend plus every rule path, each Service once) now feeds: - Topology, in both views. The same edge ID used to be emitted once per path. - AI resource context. - The missing-Service check, which no longer reports one Service once per path and now checks the default backend. The Ingress drawer shows a **Default Backend** row, for a Service or a resource backend, and no longer warns for an Ingress that routes everything to its default backend. **Private Knative Services.** A Knative Service is private when it has the label `networking.knative.dev/visibility=cluster-local`, or when its published URL is its cluster-local address (`status.url` host equals `status.address.url` host). Knative builds a private Route's URL from the same cluster-local template as its address, on any cluster domain, so this also covers a label set on the Route. A DomainMapping that targets the Service or its Route publishes it again. Private Services get no Internet edge ([private services](https://knative.dev/docs/serving/services/private-services/), [DomainMapping](https://knative.dev/docs/serving/services/custom-domains/)). The node says why: - A cluster-local Service carries a **Cluster-local** badge. Radar withholds it if DomainMappings can't be read, because the Service may be mapped. - A mapped Service shows its custom domain as its subtitle. The tooltip lists every mapped domain and the internal address. **Traefik ServersTransport configures a route; it doesn't replace its backend.** A backend with `serversTransport` used to become route → transport → Service, so the Service lost the route from its Routes, focused topology stopped at the transport, and two routes sharing a transport looked joined. Now: - The route always routes to its backend. - The transport is configuration of each route that uses it, drawn even when the backend Service is missing. - The transport resolves where Traefik resolves it: an HTTP backend's namespace, a TCP route's own namespace. A TraefikService backend ignores `serversTransport`, so it gets no transport edge. ## Result on a live cluster A kind cluster with 7 `shop` Pods (5 stable, 2 canary) behind three Services, 3 `cart` Pods behind stable and canary Services, Ingresses with a default backend, three repeated paths and missing Services, Traefik routes sharing a transport, and public, private and domain-mapped Knative Services. Same cluster, `main` vs this PR: | Traffic view, before | Traffic view, after | |---|---| | ![Before: Internet reaches only Ingresses; IngressRoutes and Knative Services float unconnected](https://github.com/user-attachments/assets/fd6a433f-1548-48c1-a2c4-97363eb2d798) | ![After: every public entrypoint connected, private Knative Services off the Internet, partial-membership labels on the shop group](https://github.com/user-attachments/assets/3643bf87-25e3-446e-a698-e7803ef5e01b) | | Ingress drawer, before | Ingress drawer, after | |---|---| | ![Before: default-backend-only Ingress warns Traffic will not be routed](https://github.com/user-attachments/assets/163f35f3-e6d1-4227-9157-90832e4c1241) | ![After: Default Backend shop:80 and its Service under Related Resources](https://github.com/user-attachments/assets/86488609-a76e-4ab4-8008-bb066253fc92) | - The "5 of 7" and "2 of 7" labels match the Services' EndpointSlices exactly, as do the per-Pod `ownerIds`. - A missing-Service audit on one Ingress went from `gone` three times to `ghost-default` and `gone` once each. On a real Knative 1.23 install (EKS), the traffic view looks like this: ![Knative on a real cluster: hello-private with a Cluster-local badge and no Internet edge; search-mapped and api-mapped showing their custom domains](https://github.com/user-attachments/assets/3364dc8a-caea-4697-8984-21d10823a8ae) ## Testing - Builder-level Go tests for each fix: - stable/canary Services against small groups, large groups and summary mode; - transports shared by two routes, with a missing backend, across namespaces, and on a TraefikService backend; - a default-backend-only Ingress and a triplicated path, in both views; - public, labelled, URL-private, custom-cluster-domain, unpublished and domain-mapped Knative Services, including the node's badge and domains, and a failed DomainMapping list; - selectors with an empty value, against Pods with and without the label. - An audit check test, an Ingress drawer render test, and unit tests for the regrouping helper. - Each new test fails with its fix reverted. - Full suites pass: `pkg` and `internal` Go tests, `make tsc`, the shared UI and web unit suites. ## Notes - **Not fixed here:** every Istio Gateway and Gateway API Gateway still gets an Internet edge, including Knative's cluster-local gateway. Telling an external gateway from an internal one means reading its Service type and listeners, which is a design question. It's tracked in RAD-695, with these known gaps that predate this PR: - provider-qualified transport references (`ns-name@kubernetescrd`) and transports inside TraefikService weighted or mirroring lists aren't resolved; - app grouping doesn't move Internet edges for entrypoints the layout assigns to an app by inference. - An IngressRoute's backend still files under Pods in the drawer; #2049 moves it to Routes To. - **Version skew:** a newer UI against an older Radar keeps that Radar's membership decisions. An older UI against this Radar ignores the partial-membership label.
1 parent d66825d commit 6713f57

18 files changed

Lines changed: 898 additions & 224 deletions
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { renderToString } from 'react-dom/server'
3+
import { IngressRenderer } from './IngressRenderer'
4+
5+
describe('IngressRenderer default backend', () => {
6+
it('shows the default backend and no "not routed" warning', () => {
7+
const html = renderToString(
8+
<IngressRenderer data={{
9+
metadata: { name: 'catch-all', namespace: 'team' },
10+
spec: { ingressClassName: 'nginx', defaultBackend: { service: { name: 'fallback', port: { number: 80 } } } },
11+
}} />,
12+
)
13+
expect(html).toContain('Default Backend')
14+
expect(html.replace(/<!-- -->/g, '')).toContain('fallback:80')
15+
expect(html).not.toContain('Traffic will not be routed')
16+
})
17+
18+
it('still warns when an Ingress routes nowhere', () => {
19+
const html = renderToString(<IngressRenderer data={{ metadata: { name: 'empty', namespace: 'team' }, spec: { ingressClassName: 'nginx' } }} />)
20+
expect(html).toContain('Traffic will not be routed')
21+
})
22+
23+
it('shows a resource default backend', () => {
24+
const html = renderToString(
25+
<IngressRenderer data={{
26+
metadata: { name: 'static', namespace: 'team' },
27+
spec: { ingressClassName: 'nginx', defaultBackend: { resource: { apiGroup: 'k8s.example.com', kind: 'StorageBucket', name: 'static-assets' } } },
28+
}} />,
29+
)
30+
expect(html).toContain('Default Backend')
31+
expect(html).toContain('StorageBucket/static-assets')
32+
})
33+
})

‎packages/k8s-ui/src/components/resources/renderers/IngressRenderer.tsx‎

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Globe, Shield, Clock } from 'lucide-react'
22
import type { ResourceRef } from '../../../types'
33
import { Section, PropertyList, Property, AlertBanner, ResourceLink } from '../../ui/drawer-components'
4+
import { kindToPluralWithGroup } from '../../../utils/navigation'
45

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

2125
return (
2226
<>
@@ -50,11 +54,34 @@ export function IngressRenderer({ data, onNavigate }: IngressRendererProps) {
5054
{lbIngress.length > 0 && (
5155
<Property label="Address" value={lbIngress[0].ip || lbIngress[0].hostname} />
5256
)}
57+
{defaultService?.name && (
58+
<Property label="Default Backend" value={
59+
<ResourceLink
60+
name={defaultService.name}
61+
kind="services"
62+
namespace={data.metadata?.namespace || ''}
63+
label={<span className="text-blue-400">{defaultService.name}:{defaultService.port?.number || defaultService.port?.name}</span>}
64+
onNavigate={onNavigate}
65+
/>
66+
} />
67+
)}
68+
{defaultResource?.name && (
69+
<Property label="Default Backend" value={
70+
<ResourceLink
71+
name={defaultResource.name}
72+
kind={kindToPluralWithGroup(defaultResource.kind, defaultResource.apiGroup || '')}
73+
group={defaultResource.apiGroup || ''}
74+
namespace={data.metadata?.namespace || ''}
75+
label={<span className="text-blue-400">{`${defaultResource.kind}/${defaultResource.name}`}</span>}
76+
onNavigate={onNavigate}
77+
/>
78+
} />
79+
)}
5380
<Property label="TLS" value={tls.length > 0 ? `${tls.length} certificate(s)` : 'None'} />
5481
</PropertyList>
5582
</Section>
5683

57-
<Section title="Rules" defaultExpanded>
84+
{rules.length > 0 && <Section title="Rules" defaultExpanded>
5885
<div className="space-y-3">
5986
{rules.map((rule: any, i: number) => (
6087
<div key={i} className="card-inner-lg">
@@ -87,7 +114,7 @@ export function IngressRenderer({ data, onNavigate }: IngressRendererProps) {
87114
</div>
88115
))}
89116
</div>
90-
</Section>
117+
</Section>}
91118

92119
{tls.length > 0 && (
93120
<Section title="TLS" icon={Shield}>

‎packages/k8s-ui/src/components/topology/K8sResourceNode.test.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest'
2-
import { baseSubtitle } from './K8sResourceNode'
2+
import { baseSubtitle, knativeDomainsTooltipLines } from './K8sResourceNode'
33

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

21+
it('describes a domain-mapped Knative Service by its custom domain', () => {
22+
expect(baseSubtitle('KnativeService', { url: 'http://api.team.svc.cluster.local' })).toBe('api.team.svc.cluster.local')
23+
expect(baseSubtitle('KnativeService', { url: 'http://api.team.svc.cluster.local', mappedDomains: ['api.example.com'] })).toBe('api.example.com')
24+
expect(baseSubtitle('KnativeService', { mappedDomains: ['a.example.com', 'b.example.com'] })).toBe('a.example.com +1')
25+
})
26+
27+
it('labels a mapped Knative Service URL by what it is', () => {
28+
const text = (data: Record<string, unknown>) => knativeDomainsTooltipLines(data).map((l) => l.text)
29+
// Private: the Service URL is the internal address, shown once.
30+
expect(text({ mappedDomains: ['api.example.com'], url: 'http://api.team.svc.cluster.local', internalUrl: 'http://api.team.svc.cluster.local' }))
31+
.toEqual(['api.example.com', 'Internal: http://api.team.svc.cluster.local'])
32+
// Public: its own URL isn't internal.
33+
expect(text({ mappedDomains: ['shop.example.com'], url: 'https://shop.team.example.org', internalUrl: 'http://shop.team.svc.cluster.local' }))
34+
.toEqual(['shop.example.com', 'Service URL: https://shop.team.example.org', 'Internal: http://shop.team.svc.cluster.local'])
35+
expect(text({ url: 'https://shop.team.example.org' })).toEqual([])
36+
})
37+
2138
it('describes a Calico staged deletion as a removal, not as selecting everything', () => {
2239
// A staged deletion carries no selector, because the Calico API forbids one.
2340
// Falling through to the empty-selector default would label the broadest

‎packages/k8s-ui/src/components/topology/K8sResourceNode.tsx‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,32 @@ export function servicePortsTooltip(ports: ServicePortEntry[]): React.ReactNode
200200
}
201201

202202
// Default dimensions for unknown CRD kinds
203+
// A domain-mapped Knative Service shows its custom domain as the subtitle; the
204+
// tooltip lists every mapped domain, then the Service's own URL when it is
205+
// public, and its cluster-internal address.
206+
export function knativeDomainsTooltipLines(nodeData: Record<string, unknown>): { text: string; secondary?: boolean }[] {
207+
const mapped = (nodeData.mappedDomains as string[] | undefined) || [];
208+
if (mapped.length === 0) return [];
209+
const url = nodeData.url as string | undefined;
210+
const internalUrl = nodeData.internalUrl as string | undefined;
211+
const lines: { text: string; secondary?: boolean }[] = mapped.map((domain) => ({ text: domain }));
212+
if (url && url !== internalUrl) lines.push({ text: `Service URL: ${url}`, secondary: true });
213+
if (internalUrl) lines.push({ text: `Internal: ${internalUrl}`, secondary: true });
214+
return lines;
215+
}
216+
217+
function knativeDomainsTooltip(nodeData: Record<string, unknown>): React.ReactNode | null {
218+
const lines = knativeDomainsTooltipLines(nodeData);
219+
if (lines.length === 0) return null;
220+
return (
221+
<div className="space-y-0.5">
222+
{lines.map((line) => (
223+
<div key={line.text} className={line.secondary ? 'text-theme-text-tertiary' : undefined}>{line.text}</div>
224+
))}
225+
</div>
226+
);
227+
}
228+
203229
export const DEFAULT_NODE_DIMENSIONS = { width: 260, height: 84 };
204230

205231
const NODE_NAME_LENGTH_LIMITS: Partial<Record<NodeKind, number>> = {
@@ -488,6 +514,10 @@ export function baseSubtitle(kind: NodeKind, nodeData: Record<string, unknown>):
488514
return `${count} pods (${healthy} healthy)`;
489515
}
490516
case "KnativeService": {
517+
const mapped = (nodeData.mappedDomains as string[] | undefined) || [];
518+
if (mapped.length > 0) {
519+
return mapped.length > 1 ? `${mapped[0]} +${mapped.length - 1}` : mapped[0];
520+
}
491521
const url = nodeData.url as string;
492522
if (url) {
493523
// Show just the hostname from the URL for compactness
@@ -558,7 +588,10 @@ export const K8sResourceNode = memo(function K8sResourceNode({
558588
const subtitle = getSubtitle(kind, nodeData)
559589
const portsTooltip = kind === 'Service'
560590
? servicePortsTooltip((nodeData.ports as ServicePortEntry[] | undefined) || [])
561-
: null
591+
: kind === 'KnativeService'
592+
? knativeDomainsTooltip(nodeData)
593+
: null
594+
const clusterLocal = kind === 'KnativeService' && nodeData.visibility === 'cluster-local'
562595
const isInternet = kind === 'Internet'
563596
const isPodGroup = kind === 'PodGroup'
564597
const isSmallNode = kind === 'ConfigMap' || kind === 'Secret' || kind === 'ServiceAccount' || kind === 'SealedSecret' || kind === 'ServiceMonitor' || kind === 'PodMonitor' || kind === 'HorizontalPodAutoscaler'
@@ -664,6 +697,13 @@ export const K8sResourceNode = memo(function K8sResourceNode({
664697
</span>
665698
</Tooltip>
666699
)}
700+
{clusterLocal && (
701+
<Tooltip content="Reachable only inside the cluster, so it has no Internet edge" position="right">
702+
<Badge tone="note" size="sm" className="!text-[9px] !px-1 !py-0.5 normal-case tracking-normal cursor-help">
703+
Cluster-local
704+
</Badge>
705+
</Tooltip>
706+
)}
667707
{trafficRole && (
668708
<Badge
669709
tone={trafficRole === 'canary' || trafficRole === 'preview' ? 'accent1' : 'accent2'}

‎packages/k8s-ui/src/components/topology/TopologyGraph.tsx‎

Lines changed: 3 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
3232

3333
import { K8sResourceNode } from './K8sResourceNode'
3434
import { GroupNode } from './GroupNode'
35+
import { splitInternetByGroup } from './internet-groups'
3536
import { NEUTRAL_OWNER, type WorkloadFocus } from '../../utils/workload-colors'
3637
import { ownershipOf } from '../../utils/topology-neighborhood'
3738
import { buildHierarchicalElkGraph, applyHierarchicalLayout, getGroupKey, isGroupEffectivelyCollapsed, type GroupDisplayLevel } from './layout'
@@ -548,75 +549,6 @@ export function TopologyGraph({
548549
return { nodes: newNodes, edges: newEdges }
549550
}, [])
550551

551-
// Transform to per-group Internet nodes in traffic view with grouping
552-
const createPerGroupInternetNodes = useCallback((
553-
nodes: TopologyNode[],
554-
edges: TopologyEdge[],
555-
groupMode: GroupingMode
556-
): { nodes: TopologyNode[]; edges: TopologyEdge[] } => {
557-
if (groupMode === 'none') {
558-
return { nodes, edges }
559-
}
560-
561-
// Find the single Internet node
562-
const internetNode = nodes.find(n => n.kind === 'Internet')
563-
if (!internetNode) {
564-
return { nodes, edges }
565-
}
566-
567-
// Find all ingresses/gateways and group them
568-
const ingresses = nodes.filter(n => n.kind === 'Ingress' || n.kind === 'Gateway')
569-
const groupsWithIngresses = new Map<string, TopologyNode[]>()
570-
571-
for (const ingress of ingresses) {
572-
const groupKey = getGroupKey(ingress, groupMode)
573-
if (groupKey) {
574-
if (!groupsWithIngresses.has(groupKey)) {
575-
groupsWithIngresses.set(groupKey, [])
576-
}
577-
groupsWithIngresses.get(groupKey)!.push(ingress)
578-
}
579-
}
580-
581-
// If no groups with ingresses, keep original
582-
if (groupsWithIngresses.size === 0) {
583-
return { nodes, edges }
584-
}
585-
586-
// Remove original Internet node and its edges
587-
const newNodes = nodes.filter(n => n.id !== internetNode.id)
588-
const newEdges = edges.filter(e => e.source !== internetNode.id)
589-
590-
// Create per-group Internet nodes
591-
for (const [groupKey, groupIngresses] of groupsWithIngresses) {
592-
const internetId = `internet-${groupMode}-${groupKey}`
593-
594-
// Add Internet node for this group with group metadata
595-
newNodes.push({
596-
id: internetId,
597-
kind: 'Internet',
598-
name: 'Internet',
599-
status: 'healthy',
600-
data: {
601-
// Add group metadata so it gets grouped with its ingresses
602-
namespace: groupMode === 'namespace' ? groupKey : groupIngresses[0]?.data?.namespace,
603-
labels: groupMode === 'app' ? { 'app.kubernetes.io/name': groupKey } : {},
604-
},
605-
})
606-
607-
// Add edges from this Internet node to its ingresses
608-
for (const ingress of groupIngresses) {
609-
newEdges.push({
610-
id: `${internetId}-to-${ingress.id}`,
611-
source: internetId,
612-
target: ingress.id,
613-
type: 'routes-to',
614-
})
615-
}
616-
}
617-
618-
return { nodes: newNodes, edges: newEdges }
619-
}, [])
620552

621553
// Prepare topology data with expanded pod groups
622554
const { workingNodes, workingEdges } = useMemo(() => {
@@ -636,13 +568,13 @@ export function TopologyGraph({
636568

637569
// In traffic view with grouping, create per-group Internet nodes
638570
if (isTrafficView && groupingMode !== 'none') {
639-
const result = createPerGroupInternetNodes(nodes, edges, groupingMode)
571+
const result = splitInternetByGroup(nodes, edges, groupingMode)
640572
nodes = result.nodes
641573
edges = result.edges
642574
}
643575

644576
return { workingNodes: nodes, workingEdges: edges }
645-
}, [topology, expandedPodGroups, expandPodGroup, isTrafficView, groupingMode, createPerGroupInternetNodes])
577+
}, [topology, expandedPodGroups, expandPodGroup, isTrafficView, groupingMode])
646578

647579
// Handle card click in card-grid view — find the topology node and open drawer
648580
const handleCardClick = useCallback((nodeId: string) => {
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { describe, expect, it } from 'vitest'
2+
import type { TopologyEdge, TopologyNode } from '../../types'
3+
import { splitInternetByGroup } from './internet-groups'
4+
5+
const node = (id: string, kind: string, namespace?: string): TopologyNode =>
6+
({ id, kind, name: id, status: 'healthy', data: namespace ? { namespace } : {} }) as TopologyNode
7+
const fromInternet = (target: string): TopologyEdge => ({ id: `internet-to-${target}`, source: 'internet', target, type: 'routes-to' })
8+
9+
describe('splitInternetByGroup', () => {
10+
it('keeps every entrypoint connected, not only Ingresses and Gateways', () => {
11+
const nodes = [
12+
node('internet', 'Internet'),
13+
node('ingress/shop/web', 'Ingress', 'shop'),
14+
node('knativeservice/shop/api', 'KnativeService', 'shop'),
15+
node('ingressroute/edge/dash', 'IngressRoute', 'edge'),
16+
]
17+
const edges = nodes.slice(1).map(n => fromInternet(n.id))
18+
const out = splitInternetByGroup(nodes, edges, 'namespace')
19+
20+
const reached = out.edges.filter(e => out.nodes.find(n => n.id === e.source)?.kind === 'Internet').map(e => `${e.source} -> ${e.target}`)
21+
expect(reached.sort()).toEqual([
22+
'internet-namespace-edge -> ingressroute/edge/dash',
23+
'internet-namespace-shop -> ingress/shop/web',
24+
'internet-namespace-shop -> knativeservice/shop/api',
25+
])
26+
expect(out.nodes.some(n => n.id === 'internet')).toBe(false)
27+
})
28+
29+
it('leaves an ungrouped entrypoint on the original Internet node', () => {
30+
const nodes = [node('internet', 'Internet'), node('ingress/shop/web', 'Ingress', 'shop'), node('gateway//shared', 'Gateway')]
31+
const out = splitInternetByGroup(nodes, nodes.slice(1).map(n => fromInternet(n.id)), 'namespace')
32+
expect(out.edges.some(e => e.source === 'internet' && e.target === 'gateway//shared')).toBe(true)
33+
expect(out.nodes.some(n => n.id === 'internet')).toBe(true)
34+
})
35+
})
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { GroupingMode, TopologyEdge, TopologyNode } from '../../types'
2+
import { getGroupKey } from './layout'
3+
4+
/**
5+
* Splits the traffic view's single Internet node into one per group, so each
6+
* namespace or app shows its own entry from outside. Every original Internet
7+
* edge is kept, whatever kind its entrypoint is (Ingress, Gateway, Istio
8+
* Gateway, Knative Service, Traefik route, Contour HTTPProxy); an entrypoint
9+
* with no group stays connected to the original Internet node.
10+
*/
11+
export function splitInternetByGroup(
12+
nodes: TopologyNode[],
13+
edges: TopologyEdge[],
14+
groupMode: GroupingMode,
15+
): { nodes: TopologyNode[]; edges: TopologyEdge[] } {
16+
if (groupMode === 'none') return { nodes, edges }
17+
const internetNode = nodes.find(n => n.kind === 'Internet')
18+
if (!internetNode) return { nodes, edges }
19+
20+
const nodeById = new Map(nodes.map(n => [n.id, n]))
21+
const groups = new Map<string, { entry: TopologyNode; edges: TopologyEdge[] }>()
22+
const regrouped = new Set<TopologyEdge>()
23+
for (const edge of edges) {
24+
if (edge.source !== internetNode.id) continue
25+
const target = nodeById.get(edge.target)
26+
const groupKey = target ? getGroupKey(target, groupMode) : null
27+
if (!target || !groupKey) continue
28+
if (!groups.has(groupKey)) groups.set(groupKey, { entry: target, edges: [] })
29+
groups.get(groupKey)!.edges.push(edge)
30+
regrouped.add(edge)
31+
}
32+
if (groups.size === 0) return { nodes, edges }
33+
34+
const keepOriginal = edges.some(e => e.source === internetNode.id && !regrouped.has(e))
35+
const newNodes = keepOriginal ? [...nodes] : nodes.filter(n => n.id !== internetNode.id)
36+
const newEdges = edges.filter(e => !regrouped.has(e))
37+
for (const [groupKey, group] of groups) {
38+
const internetId = `internet-${groupMode}-${groupKey}`
39+
newNodes.push({
40+
id: internetId,
41+
kind: 'Internet',
42+
name: 'Internet',
43+
status: 'healthy',
44+
data: {
45+
// Group metadata so the node lands inside its entrypoints' group.
46+
namespace: groupMode === 'namespace' ? groupKey : group.entry.data?.namespace,
47+
labels: groupMode === 'app' ? { 'app.kubernetes.io/name': groupKey } : {},
48+
},
49+
})
50+
for (const edge of group.edges) {
51+
newEdges.push({ ...edge, id: `${internetId}-to-${edge.target}`, source: internetId })
52+
}
53+
}
54+
return { nodes: newNodes, edges: newEdges }
55+
}

0 commit comments

Comments
 (0)