feat: add export button for service map connections#1033
Conversation
Add an export button to the service map that allows users to download endpoint connections as JSON. The export includes: - Source and destination service names, IDs, and namespaces - Destination ports and IP protocols - Connection verdicts (Allowed, Dropped, Error, etc.) - Authentication types and encryption status - Throughput metrics (flow amount, latency, bytes transferred) The export contains only the connection topology (arrows between services), not the underlying flow logs, making it suitable for documentation, analysis, and integration with external tools. Fixes cilium#239
yannikmesserli
left a comment
There was a problem hiding this comment.
Thanks @aviary for this PR! I haven't yet tested it locally, but I want to request code changes first. Especially, the class situation and the console situation.
I know this code base is filled with pattern like you are proposing, but I believe it's not good one. Especially static methods are much better isolated, pure, so we can easily tests them and reuse without the entire context of the class. Can you change this already in the PR - I am working on a proposal for restructuring Hubble UI with better frontend patterns.
Secondly, I will also challenge the feature itself you are implementing. You export a specific schema (ExportedServiceMap) which seems particular to some use-cases (?) but does not feel useful for the entire community. Should we build on top of your PR to address fully #239? A SVG of the service map seems more appropriate for everyone... and we can directly get it from the rendering. I can help with this, if you want?
| import { Link } from '~/domain/link'; | ||
| import { ExportedConnection, ExportedServiceMap } from './types'; | ||
|
|
||
| export class ServiceMapExporter { |
There was a problem hiding this comment.
Please, use pure functions and unit tests them. (No class, and especially no static method)
| const connections: ExportedConnection[] = []; | ||
| let skippedLinks = 0; | ||
|
|
||
| console.log('[ServiceMapExporter] Starting export...', { |
There was a problem hiding this comment.
Is all the console logs really necessary for this feature? Hubble UI console is already filled with some many messages, which usually are useless for most users, I think we should use them with care. Hopefully in the future we can find a more sustainable way. I would recommend to only console log the summary, especially remove all the console logs that you have used for developing (e.g. sample flow)
| onExport?: () => void; | ||
| } | ||
|
|
||
| export const ExportButton = observer(function ExportButton(props: Props) { |
There was a problem hiding this comment.
| export const ExportButton = observer(function ExportButton(props: Props) { | |
| export const ExportButton = function ExportButton(props: Props) { |
you are not using any Mobx elements in this component, so no need to wrap it in observer.
Add Export Button for Service Map Connections
Related issue #239
Overview
This PR adds an export button to the service map that allows users to download endpoint connections as a JSON file. The export captures the current service map topology without including the underlying flow logs.
Changes
New ExportButton component (
src/components/TopBar/ExportButton.tsx)Export utilities (
src/utils/export/)ServiceMapExporterclass with JSON export functionalityIntegration
Exported Data Format
The JSON export includes for each connection:
Testing
Tested with a local kind cluster running Cilium/Hubble:
Example Export
Extracted from tests
{ "exportedAt": "2025-12-03T16:15:23.768Z", "connections": [ { "source": { "id": "4231", "name": "client", "namespace": "app-a" }, "destination": { "id": "43557", "name": "kube-dns", "namespace": "kube-system" }, "destinationPort": 53, "ipProtocol": "2", "verdicts": [ "1" ], "authTypes": [ "0" ], "isEncrypted": false, "throughput": { "flowAmount": 0, "latency": { "min": 0, "max": 0, "avg": 0 }, "bytesTransfered": 0 } }, { "source": { "id": "5853", "name": "client", "namespace": "app-b" }, "destination": { "id": "43557", "name": "kube-dns", "namespace": "kube-system" }, "destinationPort": 53, "ipProtocol": "2", "verdicts": [ "1" ], "authTypes": [ "0" ], "isEncrypted": false, "throughput": { "flowAmount": 0, "latency": { "min": 0, "max": 0, "avg": 0 }, "bytesTransfered": 0 } }, { "source": { "id": "1", "name": "host", "namespace": null }, "destination": { "id": "43557", "name": "kube-dns", "namespace": "kube-system" }, "destinationPort": 8080, "ipProtocol": "1", "verdicts": [ "1" ], "authTypes": [ "0" ], "isEncrypted": false, "throughput": { "flowAmount": 0, "latency": { "min": 0, "max": 0, "avg": 0 }, "bytesTransfered": 0 } }, { "source": { "id": "1", "name": "host", "namespace": null }, "destination": { "id": "43557", "name": "kube-dns", "namespace": "kube-system" }, "destinationPort": 8181, "ipProtocol": "1", "verdicts": [ "1" ], "authTypes": [ "0" ], "isEncrypted": false, "throughput": { "flowAmount": 0, "latency": { "min": 0, "max": 0, "avg": 0 }, "bytesTransfered": 0 } }, { "source": { "id": "14998", "name": "hubble-relay", "namespace": "kube-system" }, "destination": { "id": "1", "name": "host", "namespace": null }, "destinationPort": 4244, "ipProtocol": "1", "verdicts": [ "1" ], "authTypes": [ "0" ], "isEncrypted": false, "throughput": { "flowAmount": 0, "latency": { "min": 0, "max": 0, "avg": 0 }, "bytesTransfered": 0 } }, { "source": { "id": "1", "name": "host", "namespace": null }, "destination": { "id": "14998", "name": "hubble-relay", "namespace": "kube-system" }, "destinationPort": 4222, "ipProtocol": "1", "verdicts": [ "1" ], "authTypes": [ "0" ], "isEncrypted": false, "throughput": { "flowAmount": 0, "latency": { "min": 0, "max": 0, "avg": 0 }, "bytesTransfered": 0 } }, { "source": { "id": "1", "name": "host", "namespace": null }, "destination": { "id": "3615", "name": "hubble-ui", "namespace": "kube-system" }, "destinationPort": 8081, "ipProtocol": "1", "verdicts": [ "1" ], "authTypes": [ "0" ], "isEncrypted": false, "throughput": { "flowAmount": 0, "latency": { "min": 0, "max": 0, "avg": 0 }, "bytesTransfered": 0 } }, { "source": { "id": "3615", "name": "hubble-ui", "namespace": "kube-system" }, "destination": { "id": "43557", "name": "kube-dns", "namespace": "kube-system" }, "destinationPort": 53, "ipProtocol": "2", "verdicts": [ "1" ], "authTypes": [ "0" ], "isEncrypted": false, "throughput": { "flowAmount": 0, "latency": { "min": 0, "max": 0, "avg": 0 }, "bytesTransfered": 0 } }, { "source": { "id": "3615", "name": "hubble-ui", "namespace": "kube-system" }, "destination": { "id": "14998", "name": "hubble-relay", "namespace": "kube-system" }, "destinationPort": 4245, "ipProtocol": "1", "verdicts": [ "1" ], "authTypes": [ "0" ], "isEncrypted": false, "throughput": { "flowAmount": 0, "latency": { "min": 0, "max": 0, "avg": 0 }, "bytesTransfered": 0 } }, { "source": { "id": "43557", "name": "kube-dns", "namespace": "kube-system" }, "destination": { "id": "1", "name": "host", "namespace": null }, "destinationPort": 6443, "ipProtocol": "1", "verdicts": [ "1" ], "authTypes": [ "0" ], "isEncrypted": false, "throughput": { "flowAmount": 0, "latency": { "min": 0, "max": 0, "avg": 0 }, "bytesTransfered": 0 } } ], "summary": { "totalConnections": 10, "totalServices": 6 } }UI changes