feat: add topology

Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com>
This commit is contained in:
Yi-Ya Chen 2026-02-04 17:34:29 +08:00
parent 6cca297775
commit f214d69ed3
No known key found for this signature in database
GPG Key ID: 9A2E6FBD33F68EDE
7 changed files with 577 additions and 4 deletions

View File

@ -8,6 +8,10 @@
"dependencies": {
"@babel/plugin-transform-class-static-block": "7.28.3",
"@rancher/shell": "3.0.5-rc.8",
"@vue-flow/background": "^1.3.0",
"@vue-flow/controls": "^1.1.1",
"@vue-flow/core": "^1.33.5",
"@vue-flow/minimap": "^1.4.0",
"cache-loader": "^4.1.0",
"color": "4.2.3",
"ip": "2.0.1",

View File

@ -0,0 +1,485 @@
<script>
import { VueFlow } from '@vue-flow/core';
import { Background } from '@vue-flow/background';
import { Controls } from '@vue-flow/controls';
import { MiniMap } from '@vue-flow/minimap';
import { allHash } from '@shell/utils/promise';
import { HCI } from '../types';
export default {
name: 'VPCDetail',
components: {
VueFlow,
Background,
Controls,
MiniMap,
},
props: {
value: {
type: Object,
required: true,
},
},
data() {
return {
nodes: [],
edges: [],
loading: true,
};
},
// Layout constants
LAYOUT: {
PODS_PER_ROW: 5,
POD_WIDTH: 150,
HORIZONTAL_SPACING: 160,
VERTICAL_SPACING: 95,
SUBNET_SPACING: 550,
VPC_Y_POSITION: 50,
SUBNET_Y_POSITION: 200,
POD_START_Y: 380,
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
await allHash({
subnets: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.SUBNET }),
ips: this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.IP }),
});
await this.loadTopology();
},
computed: {
vpcName() {
return this.value?.metadata?.name || '';
},
subnetCount() {
const subnets = this.nodes.filter((n) => n.data?.type === 'subnet');
return subnets.length;
},
podCount() {
const pods = this.nodes.filter((n) => n.data?.type === 'pod');
return pods.length;
},
inStore() {
return this.$store.getters['currentProduct']?.inStore || 'cluster';
},
allSubnets() {
return this.$store.getters[`${ this.inStore }/all`](HCI.SUBNET) || [];
},
allIps() {
return this.$store.getters[`${ this.inStore }/all`](HCI.IP) || [];
},
},
watch: {
allSubnets: {
handler() {
if (!this.loading) {
this.loadTopology();
}
},
deep: true,
},
allIps: {
handler() {
if (!this.loading) {
this.loadTopology();
}
},
deep: true,
},
'value.metadata.resourceVersion'() {
if (!this.loading) {
this.loadTopology();
}
},
},
methods: {
async loadTopology() {
if (this._loadingTopology) {
return;
}
this._loadingTopology = true;
const wasLoading = this.loading;
if (!wasLoading) {
this.loading = true;
}
try {
const vpc = this.value;
if (!vpc || !vpc.metadata) {
this.loading = false;
return;
}
const subnets = this.allSubnets;
const ips = this.allIps;
const vpcSubnets = (Array.isArray(subnets) ? subnets : []).filter(
(subnet) => subnet.spec?.vpc === vpc.metadata.name,
);
const vpcPods = (Array.isArray(ips) ? ips : []).filter((ip) => {
const ipSubnet = ip.spec?.subnet;
const hasPodName = ip.spec?.podName;
return (
hasPodName &&
vpcSubnets.some((subnet) => subnet.metadata.name === ipSubnet)
);
});
const nodes = [];
const edges = [];
// Add VPC node (top layer)
nodes.push({
id: 'vpc',
type: 'default',
position: { x: 400, y: this.LAYOUT.VPC_Y_POSITION },
data: {
label: `VPC: ${ vpc.metadata.name }`,
type: 'vpc',
},
style: {
background: '#f0f9ff',
color: '#0c4a6e',
border: '2px solid #38bdf8',
borderRadius: '12px',
padding: '10px',
width: '200px',
fontWeight: 'bold',
},
});
// Add subnet nodes (middle layer)
vpcSubnets.forEach((subnet, index) => {
const subnetId = `subnet-${ subnet.metadata.name }`;
nodes.push({
id: subnetId,
type: 'default',
position: {
x: index * this.LAYOUT.SUBNET_SPACING + 50,
y: this.LAYOUT.SUBNET_Y_POSITION,
},
data: {
label: `Subnet:${ subnet.metadata.name }\nCIDR: ${
subnet.spec?.cidrBlock || 'N/A'
}`,
type: 'subnet',
},
style: {
background: '#fefce8',
color: '#713f12',
border: '2px solid #facc15',
borderRadius: '12px',
padding: '10px',
width: '180px',
whiteSpace: 'pre-wrap',
},
});
// Connect subnet to VPC
edges.push({
id: `edge-vpc-${ subnet.metadata.name }`,
source: 'vpc',
target: subnetId,
animated: true,
style: { stroke: '#38bdf8', strokeWidth: 2 },
});
});
// Add pod nodes (bottom layer) - grouped by subnet
vpcSubnets.forEach((subnet, subnetIndex) => {
const subnetPods = vpcPods.filter(
(ip) => ip.spec?.subnet === subnet.metadata.name,
);
subnetPods.forEach((pod, podIndex) => {
const podName = pod.spec?.podName || pod.metadata.name;
const podIp = pod.spec?.ipAddress || 'N/A';
const podNamespace = pod.spec?.namespace || 'default';
const podId = `pod-${ podName }-${ podIndex }`;
// Calculate pod position
const subnetOffset = subnetIndex * this.LAYOUT.SUBNET_SPACING;
const columnIndex = podIndex % this.LAYOUT.PODS_PER_ROW;
const rowIndex = Math.floor(podIndex / this.LAYOUT.PODS_PER_ROW);
nodes.push({
id: podId,
type: 'default',
position: {
x: subnetOffset + columnIndex * this.LAYOUT.HORIZONTAL_SPACING + 10,
y: this.LAYOUT.POD_START_Y + rowIndex * this.LAYOUT.VERTICAL_SPACING,
},
data: {
label: `${ podName.substring(
0,
13,
) }\nIP: ${ podIp }\nNS: ${ podNamespace }`,
type: 'pod',
},
style: {
background: '#f0fdf4',
color: '#14532d',
border: '2px solid #22c55e',
borderRadius: '8px',
padding: '8px',
width: `${ this.LAYOUT.POD_WIDTH }px`,
fontSize: '11px',
whiteSpace: 'pre-wrap',
},
});
// Connect pod to its subnet
edges.push({
id: `edge-${ subnet.metadata.name }-${ podId }`,
source: `subnet-${ subnet.metadata.name }`,
target: podId,
animated: false,
style: {
stroke: '#22c55e',
strokeWidth: 1,
strokeDasharray: '2,2',
},
});
});
});
this.nodes = nodes;
this.edges = edges;
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to load VPC topology:', error);
this.$store.dispatch(
'growl/error',
{
title: 'Topology Error',
message: `Failed to load VPC topology: ${ error.message }`,
},
{ root: true },
);
} finally {
this.loading = false;
this._loadingTopology = false;
}
},
onNodeClick(event) {
// Handle node click
},
onEdgeClick(event) {
// Handle edge click
},
},
};
</script>
<template>
<div class="vpc-topology">
<div class="topology-header">
<div class="header-title">
<div class="stats">
<span class="stat-badge">{{ subnetCount }} subnet(s)</span>
<span class="stat-badge">{{ podCount }} pod(s)</span>
</div>
</div>
<div class="legend">
<div class="legend-item">
<div class="legend-color vpc" />
<span>VPC</span>
</div>
<div class="legend-item">
<div class="legend-color subnet" />
<span>Subnet</span>
</div>
<div class="legend-item">
<div class="legend-color pod" />
<span>Pod</span>
</div>
</div>
</div>
<div
v-if="loading"
class="loading"
>
<i class="icon icon-spinner icon-spin" />
Loading topology...
</div>
<div
v-else-if="nodes.length === 0"
class="empty-state"
>
<i class="icon icon-info" />
<p>No resources found in this VPC</p>
</div>
<VueFlow
v-else
v-model:nodes="nodes"
v-model:edges="edges"
class="vpc-flow"
:default-zoom="0.8"
:min-zoom="0.2"
:max-zoom="2"
fit-view-on-init
@node-click="onNodeClick"
@edge-click="onEdgeClick"
>
<Background
pattern-color="#f1f1f1"
:gap="12"
size="1"
/>
<Controls />
<MiniMap />
</VueFlow>
</div>
</template>
<style lang="scss" scoped>
@import '../styles/vue-flow.scss';
.vpc-topology {
height: 800px;
width: 100%;
background: #f5f5f5;
border-radius: 4px;
overflow: hidden;
.topology-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 0;
background: white;
border-bottom: 1px solid #ddd;
.header-title {
display: flex;
flex-direction: column;
gap: 8px;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.stats {
display: flex;
gap: 10px;
.stat-badge {
display: inline-block;
padding: 4px 12px;
background: #e5e7eb;
border-radius: 12px;
font-size: 12px;
color: #374151;
font-weight: 500;
}
}
}
.legend {
display: flex;
gap: 20px;
.legend-item {
display: flex;
align-items: center;
gap: 8px;
.legend-color {
width: 20px;
height: 20px;
border-radius: 4px;
&.vpc {
background: #f0f9ff;
border: 2px solid #38bdf8;
}
&.subnet {
background: #fefce8;
border: 2px solid #facc15;
}
&.pod {
background: #f0fdf4;
border: 2px solid #22c55e;
}
}
span {
font-size: 14px;
color: #333;
}
}
}
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: calc(100% - 100px);
font-size: 16px;
color: #666;
i {
margin-right: 10px;
font-size: 20px;
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: calc(100% - 100px);
color: #9ca3af;
i {
font-size: 48px;
margin-bottom: 16px;
}
p {
font-size: 16px;
margin: 0;
}
}
.vpc-flow {
height: calc(100% - 100px);
}
}
</style>

View File

@ -1056,6 +1056,7 @@ harvester:
tooltip: The IP address list to reserve from automatic assignment. The gateway IP address is always excluded and will be automatically added to the list.
vpc:
viewTopology: Topology
noAddonEnabled:
prefix: The kubeovn-operator add-on is not enabled, click
middle: here

View File

@ -155,6 +155,15 @@ export default {
return location;
},
viewTopology(group) {
const vpc = group.key;
const resource = this.$store.getters[`harvester/byId`](HCI.VPC, vpc);
if (resource && resource.goToDetail) {
resource.goToDetail();
}
},
showVpcAction(event, group) {
const vpc = group.key;
@ -218,6 +227,14 @@ export default {
>
{{ t('harvester.vpc.createSubnet') }}
</router-link>
<button
type="button"
class="btn btn-sm role-secondary mr-5"
@click="viewTopology(group)"
>
<i class="icon icon-globe mr-5" />
{{ t('harvester.vpc.viewTopology') }}
</button>
<button
type="button"
class="btn btn-sm role-multi-action actions mr-10"

View File

@ -0,0 +1,4 @@
@import '@vue-flow/core/dist/style.css';
@import '@vue-flow/core/dist/theme-default.css';
@import '@vue-flow/controls/dist/style.css';
@import '@vue-flow/minimap/dist/style.css';

View File

@ -18,6 +18,7 @@ export const HCI = {
CLUSTER_NETWORK: 'network.harvesterhci.io.clusternetwork',
SUBNET: 'kubeovn.io.subnet',
VPC: 'kubeovn.io.vpc',
IP: 'kubeovn.io.ip',
VM_IMAGE_DOWNLOADER: 'harvesterhci.io.virtualmachineimagedownloader',
SUPPORT_BUNDLE: 'harvesterhci.io.supportbundle',
NETWORK_ATTACHMENT: 'harvesterhci.io.networkattachmentdefinition',

View File

@ -3221,6 +3221,11 @@
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.8.tgz#7545ba4fc3c003d6c756f651f3bf163d8f0f29ba"
integrity sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==
"@types/web-bluetooth@^0.0.20":
version "0.0.20"
resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz#f066abfcd1cbe66267cdbbf0de010d8a41b41597"
integrity sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==
"@types/webpack-env@^1.15.2":
version "1.18.5"
resolved "https://registry.yarnpkg.com/@types/webpack-env/-/webpack-env-1.18.5.tgz#eccda0b04fe024bed505881e2e532f9c119169bf"
@ -3382,6 +3387,35 @@
"@typescript-eslint/types" "5.62.0"
eslint-visitor-keys "^3.3.0"
"@vue-flow/background@^1.3.0":
version "1.3.2"
resolved "https://registry.yarnpkg.com/@vue-flow/background/-/background-1.3.2.tgz#0c90cd05e5d60da017bbaf5a1c3eb6af7ed9b778"
integrity sha512-eJPhDcLj1wEo45bBoqTXw1uhl0yK2RaQGnEINqvvBsAFKh/camHJd5NPmOdS1w+M9lggc9igUewxaEd3iCQX2w==
"@vue-flow/controls@^1.1.1":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@vue-flow/controls/-/controls-1.1.3.tgz#40866b553101fbef22d2b9a043965ed76fca4b2c"
integrity sha512-XCf+G+jCvaWURdFlZmOjifZGw3XMhN5hHlfMGkWh9xot+9nH9gdTZtn+ldIJKtarg3B21iyHU8JjKDhYcB6JMw==
"@vue-flow/core@^1.33.5":
version "1.48.2"
resolved "https://registry.yarnpkg.com/@vue-flow/core/-/core-1.48.2.tgz#cef8641b17f6220c257d4208bdb2082cee882225"
integrity sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA==
dependencies:
"@vueuse/core" "^10.5.0"
d3-drag "^3.0.0"
d3-interpolate "^3.0.1"
d3-selection "^3.0.0"
d3-zoom "^3.0.0"
"@vue-flow/minimap@^1.4.0":
version "1.5.4"
resolved "https://registry.yarnpkg.com/@vue-flow/minimap/-/minimap-1.5.4.tgz#c9c3badea49d4166aa9cdc713017397d9df7574c"
integrity sha512-l4C+XTAXnRxsRpUdN7cAVFBennC1sVRzq4bDSpVK+ag7tdMczAnhFYGgbLkUw3v3sY6gokyWwMl8CDonp8eB2g==
dependencies:
d3-selection "^3.0.0"
d3-zoom "^3.0.0"
"@vue/babel-helper-vue-jsx-merge-props@^1.4.0":
version "1.4.0"
resolved "https://registry.yarnpkg.com/@vue/babel-helper-vue-jsx-merge-props/-/babel-helper-vue-jsx-merge-props-1.4.0.tgz#8d53a1e21347db8edbe54d339902583176de09f2"
@ -3777,6 +3811,28 @@
resolved "https://registry.yarnpkg.com/@vue/web-component-wrapper/-/web-component-wrapper-1.3.0.tgz#b6b40a7625429d2bd7c2281ddba601ed05dc7f1a"
integrity sha512-Iu8Tbg3f+emIIMmI2ycSI8QcEuAUgPTgHwesDU1eKMLE4YC/c/sFbGc70QgMq31ijRftV0R7vCm9co6rldCeOA==
"@vueuse/core@^10.5.0":
version "10.11.1"
resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-10.11.1.tgz#15d2c0b6448d2212235b23a7ba29c27173e0c2c6"
integrity sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==
dependencies:
"@types/web-bluetooth" "^0.0.20"
"@vueuse/metadata" "10.11.1"
"@vueuse/shared" "10.11.1"
vue-demi ">=0.14.8"
"@vueuse/metadata@10.11.1":
version "10.11.1"
resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-10.11.1.tgz#209db7bb5915aa172a87510b6de2ca01cadbd2a7"
integrity sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==
"@vueuse/shared@10.11.1":
version "10.11.1"
resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-10.11.1.tgz#62b84e3118ae6e1f3ff38f4fbe71b0c5d0f10938"
integrity sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==
dependencies:
vue-demi ">=0.14.8"
"@webassemblyjs/ast@1.12.1", "@webassemblyjs/ast@^1.12.1":
version "1.12.1"
resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.12.1.tgz#bb16a0e8b1914f979f45864c23819cc3e3f0d4bb"
@ -5799,7 +5855,7 @@ d3-delaunay@6:
resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz#5fc75284e9c2375c36c839411a0cf550cbfc4d5e"
integrity sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==
"d3-drag@2 - 3", d3-drag@3:
"d3-drag@2 - 3", d3-drag@3, d3-drag@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/d3-drag/-/d3-drag-3.0.0.tgz#994aae9cd23c719f53b5e10e3a0a6108c69607ba"
integrity sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==
@ -5854,7 +5910,7 @@ d3-hierarchy@3:
resolved "https://registry.yarnpkg.com/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz#b01cd42c1eed3d46db77a5966cf726f8c09160c6"
integrity sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==
"d3-interpolate@1 - 3", "d3-interpolate@1.2.0 - 3", d3-interpolate@3:
"d3-interpolate@1 - 3", "d3-interpolate@1.2.0 - 3", d3-interpolate@3, d3-interpolate@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d"
integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==
@ -5900,7 +5956,7 @@ d3-scale@4:
d3-time "2.1.1 - 3"
d3-time-format "2 - 4"
"d3-selection@2 - 3", d3-selection@3, d3-selection@3.0.0:
"d3-selection@2 - 3", d3-selection@3, d3-selection@3.0.0, d3-selection@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31"
integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==
@ -5942,7 +5998,7 @@ d3-shape@3:
d3-interpolate "1 - 3"
d3-timer "1 - 3"
d3-zoom@3:
d3-zoom@3, d3-zoom@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-3.0.0.tgz#d13f4165c73217ffeaa54295cd6969b3e7aee8f3"
integrity sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==
@ -12797,6 +12853,11 @@ vue-component-type-helpers@^2.0.0:
resolved "https://registry.yarnpkg.com/vue-component-type-helpers/-/vue-component-type-helpers-2.2.0.tgz#de5fa802b6beae7125595ec0d3d5195a22691623"
integrity sha512-cYrAnv2me7bPDcg9kIcGwjJiSB6Qyi08+jLDo9yuvoFQjzHiPTzML7RnkJB1+3P6KMsX/KbCD4QE3Tv/knEllw==
vue-demi@>=0.14.8:
version "0.14.10"
resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.10.tgz#afc78de3d6f9e11bf78c55e8510ee12814522f04"
integrity sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==
vue-draggable-next@^2.2.1:
version "2.3.0"
resolved "https://registry.yarnpkg.com/vue-draggable-next/-/vue-draggable-next-2.3.0.tgz#ba83154f60b8a3c24059c18b8060b72200a4c673"