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
8 changes: 8 additions & 0 deletions src/components/ServiceMapApp/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { KV, Labels } from '~/domain/labels';
import { FilterEntry, FilterDirection } from '~/domain/filtering';

import { useApplication } from '~/application';
import { ServiceMapExporter } from '~/utils/export';

import { sizes } from '~/ui/vars';
import { useFlowsTableColumns } from './hooks/useColumns';
Expand Down Expand Up @@ -119,6 +120,12 @@ export const ServiceMapApp = observer(function ServiceMapApp() {
ui.controls.setFlowFilters([FilterEntry.newDNS(dns).setDirection(FilterDirection.Either)]);
}, []);

const onExport = useCallback(() => {
const services = store.currentFrame.services.cardsMap;
const links = store.currentFrame.interactions.links;
ServiceMapExporter.export(services, links);
}, [store.currentFrame]);

const cardRenderer = mobx.action((props: CardProps<ServiceCard>) => {
const l7endpoints = store.currentFrame.interactions.l7endpoints;

Expand Down Expand Up @@ -166,6 +173,7 @@ export const ServiceMapApp = observer(function ServiceMapApp() {
onShowRemoteNodeToggle={() => ui.controls.toggleShowRemoteNode()}
showPrometheusApp={store.controls.showPrometheusApp}
onShowPrometheusAppToggle={() => ui.controls.toggleShowPrometheusApp()}
onExport={onExport}
/>
);

Expand Down
28 changes: 28 additions & 0 deletions src/components/TopBar/ExportButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import React, { useCallback } from 'react';
import { observer } from 'mobx-react';

import { Icon, Button } from '@blueprintjs/core';

import css from './styles.scss';

export interface Props {
onExport?: () => void;
}

export const ExportButton = observer(function ExportButton(props: Props) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.

const handleClick = useCallback(() => {
props.onExport?.();
}, [props.onExport]);

return (
<Button
minimal
small
icon={<Icon icon="export" iconSize={16} />}
onClick={handleClick}
title="Export Service Map Connections"
>
Export
</Button>
);
});
3 changes: 3 additions & 0 deletions src/components/TopBar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { VerdictFilterDropdown } from './VerdictFilterDropdown';
import { VisualFiltersDropdown } from './VisualFiltersDropdown';
import { NamespaceSelectorDropdown } from './NamespaceSelectorDropdown';
import { ConnectionIndicator } from './ConnectionIndicator';
import { ExportButton } from './ExportButton';

import css from './styles.scss';

Expand All @@ -35,6 +36,7 @@ export interface Props {
onShowRemoteNodeToggle?: () => void;
showPrometheusApp: boolean;
onShowPrometheusAppToggle: () => void;
onExport?: () => void;
}

export const TopBar = observer(function TopBar(props: Props) {
Expand Down Expand Up @@ -72,6 +74,7 @@ export const TopBar = observer(function TopBar(props: Props) {
{props.currentNamespace && RenderedFilters}
</div>
<div className={css.right}>
<ExportButton onExport={props.onExport} />
<div className={css.spacer} />
<div className={css.spacer} />

Expand Down
2 changes: 2 additions & 0 deletions src/utils/export/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { ServiceMapExporter } from './serviceMapExporter';
export type { ExportedConnection, ExportedServiceMap } from './types';
133 changes: 133 additions & 0 deletions src/utils/export/serviceMapExporter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { ServiceCard } from '~/domain/service-map/card';
import { Link } from '~/domain/link';
import { ExportedConnection, ExportedServiceMap } from './types';

export class ServiceMapExporter {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please, use pure functions and unit tests them. (No class, and especially no static method)

/**
* Exports service map connections to JSON format
*/
public static exportToJSON(
services: Map<string, ServiceCard>,
links: Link[],
): ExportedServiceMap {
const connections: ExportedConnection[] = [];
let skippedLinks = 0;

console.log('[ServiceMapExporter] Starting export...', {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

totalServices: services.size,
totalLinks: links.length,
});

for (const link of links) {
const sourceService = services.get(link.sourceId);
const destService = services.get(link.destinationId);

// Skip if we can't find either service
if (!sourceService || !destService) {
skippedLinks++;
console.warn('[ServiceMapExporter] Skipping link - service not found:', {
sourceId: link.sourceId,
destId: link.destinationId,
sourceFound: !!sourceService,
destFound: !!destService,
});
continue;
}

const latency = link.throughput.latency;
connections.push({
source: {
id: link.sourceId,
name: sourceService.caption,
namespace: sourceService.namespace,
},
destination: {
id: link.destinationId,
name: destService.caption,
namespace: destService.namespace,
},
destinationPort: link.destinationPort,
ipProtocol: link.ipProtocol.toString(),
verdicts: Array.from(link.verdicts).map(v => v.toString()),
authTypes: Array.from(link.authTypes).map(a => a.toString()),
isEncrypted: link.isEncrypted,
throughput: {
flowAmount: link.throughput.flowAmount,
latency: latency
? {
min: Number(latency.min.seconds),
max: Number(latency.max.seconds),
avg: Number(latency.avg.seconds),
}
: null,
bytesTransfered: link.throughput.bytesTransfered,
},
});
}

// Calculate unique services involved in connections
const uniqueServiceIds = new Set<string>();
connections.forEach(conn => {
uniqueServiceIds.add(conn.source.id);
uniqueServiceIds.add(conn.destination.id);
});

const result = {
exportedAt: new Date().toISOString(),
connections,
summary: {
totalConnections: connections.length,
totalServices: uniqueServiceIds.size,
},
};

console.log('[ServiceMapExporter] Export complete:', {
exportedConnections: result.summary.totalConnections,
uniqueServices: result.summary.totalServices,
skippedLinks,
timestamp: result.exportedAt,
});

// Log sample connections for verification
if (connections.length > 0) {
console.log('[ServiceMapExporter] Sample connections (first 3):',
connections.slice(0, 3).map(c => ({
from: `${c.source.name} (${c.source.namespace})`,
to: `${c.destination.name} (${c.destination.namespace})`,
port: c.destinationPort,
protocol: c.ipProtocol,
encrypted: c.isEncrypted,
}))
);
}

return result;
}

/**
* Downloads the exported data as a JSON file
*/
public static downloadJSON(data: ExportedServiceMap, filename?: string): void {
const jsonString = JSON.stringify(data, null, 2);
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);

const link = document.createElement('a');
link.href = url;
link.download = filename || `hubble-service-map-${Date.now()}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);

// Clean up the URL object
URL.revokeObjectURL(url);
}

/**
* Exports and downloads service map connections in one operation
*/
public static export(services: Map<string, ServiceCard>, links: Link[]): void {
const data = this.exportToJSON(services, links);
this.downloadJSON(data);
}
}
35 changes: 35 additions & 0 deletions src/utils/export/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
export interface ExportedConnection {
source: {
id: string;
name: string;
namespace: string | null;
};
destination: {
id: string;
name: string;
namespace: string | null;
};
destinationPort: number;
ipProtocol: string;
verdicts: string[];
authTypes: string[];
isEncrypted: boolean;
throughput: {
flowAmount: number;
latency: {
min: number;
max: number;
avg: number;
} | null;
bytesTransfered: number;
};
}

export interface ExportedServiceMap {
exportedAt: string;
connections: ExportedConnection[];
summary: {
totalConnections: number;
totalServices: number;
};
}
Loading