mirror of
https://github.com/harvester/harvester-ui-extension.git
synced 2026-03-22 13:11:47 +00:00
* feat: add topology Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com> * feat: add provider info Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com> * refactor: remove comments Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com> * fix: exclude default network Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com> * feat: add VPC peering Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com> * refactor: remove regex Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com> * refactor: adjust row height Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com> * feat: introduce auto layout Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com> --------- Signed-off-by: Yi-Ya Chen <yiya.chen@suse.com>
1447 lines
40 KiB
Vue
1447 lines
40 KiB
Vue
<script>
|
|
import ELK from 'elkjs/lib/elk.bundled.js';
|
|
import { VueFlow, Handle } 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 { NETWORK_ATTACHMENT } from '@shell/config/types';
|
|
import Checkbox from '@components/Form/Checkbox/Checkbox.vue';
|
|
import { HCI } from '../types';
|
|
import { NETWORK_TYPE } from '../config/types';
|
|
|
|
const NETWORK_PROVIDERS = { OVN: 'ovn' };
|
|
|
|
const NODE_TYPES = {
|
|
VPC: 'vpc',
|
|
GROUP: 'group',
|
|
SUBNET: 'subnet',
|
|
OVERLAY_NETWORK: 'overlay-network',
|
|
VM: 'vm',
|
|
MULTI_NETWORK_VM: 'multi-network-vm',
|
|
};
|
|
|
|
const STOPPED_VM_STATUSES = ['stopped', 'paused'];
|
|
const elk = new ELK();
|
|
|
|
const THEME_COLORS = {
|
|
VPC: '#2453ff',
|
|
PEER_VPC: 'rgba(36, 83, 255, 0.6)',
|
|
BG_VPC: 'rgba(36, 83, 255, 0.1)',
|
|
BG_PEER_VPC: 'rgba(36, 83, 255, 0.05)',
|
|
SUBNET: '#fe7c3f',
|
|
BG_SUBNET: 'rgba(254, 124, 63, 0.1)',
|
|
OVERLAY: '#cb1fdb',
|
|
BG_OVERLAY: 'rgba(203, 31, 219, 0.1)',
|
|
VM: '#00bda7',
|
|
VM_GLOW: 'rgba(0, 189, 167, 0.6)',
|
|
BG_VM: 'rgba(0, 189, 167, 0.1)',
|
|
STOPPED: '#9ca3af',
|
|
BG_STOPPED: 'rgba(156, 163, 175, 0.1)',
|
|
LINK_GRAY: '#9ca3af',
|
|
PEER_BADGE_BG: 'rgba(36, 83, 255, 0.14)',
|
|
PEER_BADGE_TEXT: '#1f3fbf',
|
|
};
|
|
|
|
const LAYOUT = {
|
|
BASE_PADDING: 24,
|
|
NODE_WIDTH: 230,
|
|
GROUP_NODE_GAP: 30,
|
|
AUTO_NODE_GAP: 110,
|
|
AUTO_RANK_GAP: 140,
|
|
PEER_COLUMN_GAP: 220,
|
|
PEER_VERTICAL_GAP: 36,
|
|
};
|
|
|
|
const NODE_WIDTH_PX = `${ LAYOUT.NODE_WIDTH }px`;
|
|
|
|
const NODE_HEIGHT = {
|
|
DEFAULT: 96,
|
|
SUBNET: 120,
|
|
OVERLAY: 120,
|
|
VM: 170,
|
|
};
|
|
|
|
export default {
|
|
name: 'VPCDetail',
|
|
|
|
components: {
|
|
VueFlow,
|
|
Handle,
|
|
Background,
|
|
Controls,
|
|
MiniMap,
|
|
Checkbox,
|
|
},
|
|
|
|
props: {
|
|
value: {
|
|
type: Object,
|
|
required: true,
|
|
},
|
|
},
|
|
|
|
data() {
|
|
return {
|
|
nodes: [],
|
|
edges: [],
|
|
loading: true,
|
|
flowInstance: null,
|
|
isFitted: false,
|
|
selectedNodeId: null,
|
|
relatedIds: new Set(),
|
|
showVPC: true,
|
|
showSubnets: true,
|
|
showVMs: true,
|
|
showOverlays: true,
|
|
};
|
|
},
|
|
|
|
async fetch() {
|
|
const store = this.$store.getters['currentProduct'].inStore;
|
|
|
|
try {
|
|
await allHash({
|
|
vpcs: this.$store.dispatch(`${ store }/findAll`, { type: HCI.VPC }),
|
|
subnets: this.$store.dispatch(`${ store }/findAll`, { type: HCI.SUBNET }),
|
|
ips: this.$store.dispatch(`${ store }/findAll`, { type: HCI.IP }),
|
|
vms: this.$store.dispatch(`${ store }/findAll`, { type: HCI.VM }),
|
|
clusterNetworks: this.$store.dispatch(`${ store }/findAll`, { type: HCI.CLUSTER_NETWORK }),
|
|
networkAttachments: this.$store.dispatch(`${ store }/findAll`, { type: NETWORK_ATTACHMENT }),
|
|
});
|
|
} catch (error) {
|
|
// eslint-disable-next-line no-console
|
|
console.error('Failed to fetch VPC resources:', error);
|
|
}
|
|
await this.loadTopology();
|
|
},
|
|
|
|
computed: {
|
|
inStore() {
|
|
return this.$store.getters['currentProduct']?.inStore || 'cluster';
|
|
},
|
|
allSubnets() {
|
|
return this.$store.getters[`${ this.inStore }/all`](HCI.SUBNET) || [];
|
|
},
|
|
allVpcs() {
|
|
return this.$store.getters[`${ this.inStore }/all`](HCI.VPC) || [];
|
|
},
|
|
allIps() {
|
|
return this.$store.getters[`${ this.inStore }/all`](HCI.IP) || [];
|
|
},
|
|
allVMs() {
|
|
return this.$store.getters[`${ this.inStore }/all`](HCI.VM) || [];
|
|
},
|
|
allNetworkAttachments() {
|
|
return (
|
|
this.$store.getters[`${ this.inStore }/all`](NETWORK_ATTACHMENT) || []
|
|
);
|
|
},
|
|
|
|
subnetCount() {
|
|
return this.nodes.filter((node) => node.type === NODE_TYPES.SUBNET)
|
|
.length;
|
|
},
|
|
overlayCount() {
|
|
return this.nodes.filter(
|
|
(node) => node.type === NODE_TYPES.OVERLAY_NETWORK,
|
|
).length;
|
|
},
|
|
vmCount() {
|
|
return this.nodes.filter(
|
|
(node) => node.type && node.type.includes(NODE_TYPES.VM),
|
|
).length;
|
|
},
|
|
|
|
backgroundPatternColor() {
|
|
return this.getCssVar('--default-active-bg') || '#f1f1f1';
|
|
},
|
|
colors() {
|
|
return THEME_COLORS;
|
|
},
|
|
topologyCssVars() {
|
|
return {
|
|
'--node-vpc-color': this.colors.VPC,
|
|
'--node-vpc-bg': this.colors.BG_VPC,
|
|
'--node-peer-vpc-color': this.colors.PEER_VPC,
|
|
'--node-peer-vpc-bg': this.colors.BG_PEER_VPC,
|
|
'--node-subnet-color': this.colors.SUBNET,
|
|
'--node-subnet-bg': this.colors.BG_SUBNET,
|
|
'--node-overlay-color': this.colors.OVERLAY,
|
|
'--node-overlay-bg': this.colors.BG_OVERLAY,
|
|
'--node-group-bg': this.getCssVar('--box-bg') || 'rgba(0, 0, 0, 0.05)',
|
|
'--node-vm-color': this.colors.VM,
|
|
'--node-vm-bg': this.colors.BG_VM,
|
|
'--node-vm-stopped-color': this.colors.STOPPED,
|
|
'--node-vm-stopped-bg': this.colors.BG_STOPPED,
|
|
'--badge-vpc-bg': this.colors.VPC,
|
|
'--badge-subnet-bg': this.colors.SUBNET,
|
|
'--badge-overlay-bg': this.colors.OVERLAY,
|
|
'--badge-vm-bg': this.colors.VM,
|
|
'--status-running-color': this.colors.VM,
|
|
'--status-running-glow': this.colors.VM_GLOW,
|
|
'--status-stopped-color': this.colors.STOPPED,
|
|
'--badge-peer-bg': this.colors.PEER_BADGE_BG,
|
|
'--badge-peer-text': this.colors.PEER_BADGE_TEXT,
|
|
};
|
|
},
|
|
|
|
visibilityOptions() {
|
|
const options = [
|
|
{
|
|
modelKey: 'showVPC',
|
|
label: this.t('harvester.vpc.topology.visibility.vpc'),
|
|
badgeClass: 'badge-vpc',
|
|
count: this.nodes.filter((node) => node.type === NODE_TYPES.VPC)
|
|
.length,
|
|
},
|
|
{
|
|
modelKey: 'showSubnets',
|
|
label: this.t('harvester.vpc.topology.visibility.subnets'),
|
|
badgeClass: 'badge-subnet',
|
|
count: this.subnetCount,
|
|
},
|
|
{
|
|
modelKey: 'showOverlays',
|
|
label: this.t('harvester.vpc.topology.visibility.overlayNetworks'),
|
|
badgeClass: 'badge-overlay',
|
|
count: this.overlayCount,
|
|
disabled: !this.showSubnets,
|
|
},
|
|
{
|
|
modelKey: 'showVMs',
|
|
label: this.t('harvester.vpc.topology.visibility.vms'),
|
|
badgeClass: 'badge-vm',
|
|
count: this.vmCount,
|
|
},
|
|
];
|
|
|
|
return options.filter((option) => option.count > 0);
|
|
},
|
|
|
|
filteredNodes() {
|
|
return this.nodes
|
|
.filter((node) => {
|
|
const type = node.type;
|
|
|
|
if (type === NODE_TYPES.VPC && !this.showVPC) return false;
|
|
if (this.isSubnetScopedType(type) && !this.showSubnets) return false;
|
|
if (type === NODE_TYPES.OVERLAY_NETWORK && !this.showOverlays) {
|
|
return false;
|
|
}
|
|
if (this.isVmType(type) && !this.showVMs) return false;
|
|
|
|
return true;
|
|
})
|
|
.map((node) => {
|
|
const { stateClass, zIndex } = this.getNodeState(node);
|
|
|
|
return {
|
|
...node,
|
|
data: { ...node.data, stateClass },
|
|
style: { ...node.style, zIndex },
|
|
};
|
|
});
|
|
},
|
|
|
|
filteredEdges() {
|
|
const visibleIds = new Set(this.filteredNodes.map((node) => node.id));
|
|
|
|
return this.edges
|
|
.filter(
|
|
(edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target),
|
|
)
|
|
.map((edge) => {
|
|
const isRelated =
|
|
!this.selectedNodeId ||
|
|
(this.relatedIds.has(edge.source) &&
|
|
this.relatedIds.has(edge.target));
|
|
|
|
return {
|
|
...edge,
|
|
class: isRelated ? '' : 'dimmed',
|
|
animated: edge.animated && isRelated,
|
|
};
|
|
});
|
|
},
|
|
},
|
|
|
|
watch: {
|
|
showSubnets(newValue) {
|
|
this.showOverlays = newValue;
|
|
},
|
|
},
|
|
|
|
methods: {
|
|
prepareResources(vpcName) {
|
|
const vpcSubnets = this.allSubnets.filter(
|
|
(subnet) => subnet.spec?.vpc === vpcName,
|
|
);
|
|
const vpcSubnetNames = new Set(
|
|
vpcSubnets.map((subnet) => subnet.metadata.name),
|
|
);
|
|
const vmToSubnetsMap = {};
|
|
const vmToDetailsMap = {};
|
|
const vmNames = new Set(
|
|
this.allVMs.map((vm) => vm.metadata?.name).filter(Boolean),
|
|
);
|
|
|
|
this.allIps.forEach((ip) => {
|
|
const podName = ip.spec?.podName;
|
|
const subnetName = ip.spec?.subnet;
|
|
|
|
if (podName && subnetName && vmNames.has(podName)) {
|
|
if (!vmToSubnetsMap[podName]) {
|
|
vmToSubnetsMap[podName] = [];
|
|
vmToDetailsMap[podName] = [];
|
|
}
|
|
if (!vmToSubnetsMap[podName].includes(subnetName)) {
|
|
vmToSubnetsMap[podName].push(subnetName);
|
|
vmToDetailsMap[podName].push({
|
|
mac: ip.spec?.macAddress || 'N/A',
|
|
ip: ip.spec?.ipAddress,
|
|
subnet: subnetName,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
const filteredVMs = this.allVMs.filter((vm) => {
|
|
const subnets = vmToSubnetsMap[vm.metadata?.name] || [];
|
|
|
|
return subnets.some((name) => vpcSubnetNames.has(name));
|
|
});
|
|
|
|
return {
|
|
vpcSubnets,
|
|
vmToSubnetsMap,
|
|
vmToDetailsMap,
|
|
vpcVMs: filteredVMs,
|
|
};
|
|
},
|
|
|
|
async loadTopology() {
|
|
if (this._loadingTopology) return;
|
|
this._loadingTopology = true;
|
|
this.loading = true;
|
|
this.isFitted = false;
|
|
|
|
try {
|
|
const vpc = this.value;
|
|
const {
|
|
vpcSubnets, vmToSubnetsMap, vmToDetailsMap, vpcVMs
|
|
} =
|
|
this.prepareResources(vpc.metadata.name);
|
|
|
|
const nodes = [];
|
|
const edges = [];
|
|
|
|
nodes.push({
|
|
id: NODE_TYPES.VPC,
|
|
type: NODE_TYPES.VPC,
|
|
position: { x: 0, y: 0 },
|
|
data: { name: vpc.metadata.name },
|
|
style: { width: NODE_WIDTH_PX },
|
|
});
|
|
|
|
this.createVpcPeeringNodes({
|
|
nodes,
|
|
edges,
|
|
vpc,
|
|
});
|
|
|
|
vpcSubnets.forEach((subnet) => {
|
|
this.createNetworkNodes(nodes, edges, subnet);
|
|
});
|
|
|
|
this.createVMNodes({
|
|
nodes,
|
|
edges,
|
|
vpcVMs,
|
|
vpcSubnets,
|
|
vmToSubnetsMap,
|
|
vmToDetailsMap,
|
|
});
|
|
|
|
await this.applyAutoLayout(nodes, edges);
|
|
|
|
this.nodes = nodes;
|
|
this.edges = edges;
|
|
this.scheduleInitialFit();
|
|
} catch (error) {
|
|
// eslint-disable-next-line no-console
|
|
console.error('Topology Load Error:', error);
|
|
} finally {
|
|
this.loading = false;
|
|
this._loadingTopology = false;
|
|
}
|
|
},
|
|
|
|
createVpcPeeringNodes({ nodes, edges, vpc }) {
|
|
const peerings = vpc?.spec?.vpcPeerings || [];
|
|
const peerNodeByRemote = new Map();
|
|
let peerIndex = 0;
|
|
|
|
peerings.forEach((peering, index) => {
|
|
const remoteVpc = peering?.remoteVpc;
|
|
|
|
if (!remoteVpc) {
|
|
return;
|
|
}
|
|
|
|
if (!peerNodeByRemote.has(remoteVpc)) {
|
|
const remoteVpcObj = this.allVpcs.find((item) => {
|
|
return item.id === remoteVpc || item.metadata?.name === remoteVpc;
|
|
});
|
|
|
|
const peerName =
|
|
remoteVpcObj?.metadata?.name ||
|
|
remoteVpc.split('/').pop() ||
|
|
remoteVpc;
|
|
|
|
const peerNodeId = `vpc-peer-${ peerIndex }`;
|
|
|
|
peerNodeByRemote.set(remoteVpc, peerNodeId);
|
|
|
|
nodes.push({
|
|
id: peerNodeId,
|
|
type: NODE_TYPES.VPC,
|
|
position: { x: 0, y: 0 },
|
|
data: {
|
|
name: peerName,
|
|
remoteVpcObj,
|
|
isPeerVpc: true,
|
|
},
|
|
style: { width: NODE_WIDTH_PX },
|
|
});
|
|
|
|
peerIndex++;
|
|
}
|
|
|
|
edges.push({
|
|
id: `vpc-peering-${ index }-${ remoteVpc }`,
|
|
source: NODE_TYPES.VPC,
|
|
sourceHandle: 'vpc-right-handle',
|
|
target: peerNodeByRemote.get(remoteVpc),
|
|
type: 'straight',
|
|
animated: false,
|
|
class: 'peering-edge',
|
|
style: {
|
|
stroke: THEME_COLORS.LINK_GRAY,
|
|
strokeWidth: 1.5,
|
|
strokeDasharray: '6,4',
|
|
opacity: 0.5,
|
|
},
|
|
label: peering?.localConnectIP || '',
|
|
});
|
|
});
|
|
},
|
|
|
|
createNetworkNodes(nodes, edges, subnet) {
|
|
const subnetName = subnet.metadata.name;
|
|
const subnetNodeId = `subnet-${ subnetName }`;
|
|
const provider = subnet.spec?.provider || NETWORK_PROVIDERS.OVN;
|
|
const hasOverlay =
|
|
provider !== NETWORK_PROVIDERS.OVN && provider.trim() !== '';
|
|
|
|
if (hasOverlay) {
|
|
const groupId = `group-${ subnetName }`;
|
|
|
|
const nad = this.allNetworkAttachments.find((networkAttachment) => {
|
|
return networkAttachment?.parseConfig?.provider === provider;
|
|
});
|
|
|
|
const overlayName = nad?.parseConfig?.name || nad?.metadata?.name;
|
|
|
|
const clusterNetwork =
|
|
nad?.metadata?.labels?.[HCI.CLUSTER_NETWORK] || 'mgmt';
|
|
|
|
const nadType =
|
|
nad?.metadata?.labels?.[HCI.NETWORK_TYPE] || NETWORK_TYPE.OVERLAY;
|
|
const overlayNodeId = `overlay-${ provider }`;
|
|
|
|
nodes.push({
|
|
id: groupId,
|
|
type: NODE_TYPES.GROUP,
|
|
position: { x: 0, y: 0 },
|
|
data: { type: NODE_TYPES.GROUP },
|
|
style: {
|
|
background: 'var(--node-group-bg)',
|
|
borderRadius: '12px',
|
|
padding: `${ LAYOUT.BASE_PADDING }px`,
|
|
},
|
|
zIndex: -1,
|
|
selectable: false,
|
|
});
|
|
|
|
nodes.push({
|
|
id: subnetNodeId,
|
|
type: NODE_TYPES.SUBNET,
|
|
parentNode: groupId,
|
|
extent: 'parent',
|
|
position: { x: LAYOUT.BASE_PADDING, y: LAYOUT.BASE_PADDING },
|
|
data: {
|
|
name: subnetName,
|
|
cidr: subnet.spec?.cidrBlock,
|
|
provider: provider.split('/').pop(),
|
|
},
|
|
style: { width: NODE_WIDTH_PX },
|
|
});
|
|
|
|
nodes.push({
|
|
id: overlayNodeId,
|
|
type: NODE_TYPES.OVERLAY_NETWORK,
|
|
parentNode: groupId,
|
|
extent: 'parent',
|
|
position: { x: LAYOUT.BASE_PADDING, y: 150 },
|
|
data: {
|
|
name: overlayName,
|
|
nadType,
|
|
clusterNetwork,
|
|
subnetId: subnetNodeId,
|
|
},
|
|
style: { width: NODE_WIDTH_PX },
|
|
});
|
|
|
|
edges.push({
|
|
id: `link-${ subnetName }`,
|
|
source: subnetNodeId,
|
|
target: overlayNodeId,
|
|
type: 'straight',
|
|
animated: false,
|
|
style: {
|
|
stroke: THEME_COLORS.LINK_GRAY,
|
|
strokeWidth: 2,
|
|
strokeDasharray: '4,4',
|
|
},
|
|
});
|
|
} else {
|
|
nodes.push({
|
|
id: subnetNodeId,
|
|
type: NODE_TYPES.SUBNET,
|
|
position: { x: 0, y: 0 },
|
|
data: {
|
|
name: subnetName,
|
|
cidr: subnet.spec?.cidrBlock,
|
|
provider: NETWORK_PROVIDERS.OVN,
|
|
},
|
|
style: { width: NODE_WIDTH_PX },
|
|
});
|
|
}
|
|
|
|
edges.push({
|
|
id: `vpc-to-${ subnetName }`,
|
|
source: NODE_TYPES.VPC,
|
|
sourceHandle: 'vpc-bottom-handle',
|
|
target: subnetNodeId,
|
|
animated: true,
|
|
style: { stroke: THEME_COLORS.VPC, strokeWidth: 2 },
|
|
});
|
|
},
|
|
|
|
createVMNodes({
|
|
nodes,
|
|
edges,
|
|
vpcVMs,
|
|
vpcSubnets,
|
|
vmToSubnetsMap,
|
|
vmToDetailsMap,
|
|
}) {
|
|
const vpcSubnetNames = new Set(
|
|
vpcSubnets.map((subnet) => subnet.metadata.name),
|
|
);
|
|
const subnetNetworkMap = {};
|
|
|
|
vpcSubnets.forEach((subnet) => {
|
|
subnetNetworkMap[subnet.metadata.name] =
|
|
this.getNetworkDisplayBySubnet(subnet);
|
|
});
|
|
|
|
vpcVMs.forEach((vm) => {
|
|
const vmName = vm.metadata?.name;
|
|
const vmSubnetsInVpc = (vmToSubnetsMap[vmName] || []).filter(
|
|
(subnetName) => vpcSubnetNames.has(subnetName),
|
|
);
|
|
const interfacesInVpc = (vmToDetailsMap[vmName] || [])
|
|
.filter((iface) => vpcSubnetNames.has(iface.subnet))
|
|
.map((iface) => ({
|
|
...iface,
|
|
network: subnetNetworkMap[iface.subnet] || iface.subnet,
|
|
}));
|
|
const isMulti = vmSubnetsInVpc.length > 1;
|
|
const status = (vm.status?.printableStatus || '').toLowerCase();
|
|
const isStopped = STOPPED_VM_STATUSES.includes(status);
|
|
const vmId = `vm-${ vmName }`;
|
|
|
|
nodes.push({
|
|
id: vmId,
|
|
type: isMulti ? NODE_TYPES.MULTI_NETWORK_VM : NODE_TYPES.VM,
|
|
position: { x: 0, y: 0 },
|
|
data: {
|
|
name: vmName,
|
|
isStopped,
|
|
interfaces: interfacesInVpc,
|
|
},
|
|
style: { width: NODE_WIDTH_PX },
|
|
zIndex: 10,
|
|
});
|
|
|
|
vmSubnetsInVpc.forEach((name) => {
|
|
const targetSubnet = vpcSubnets.find((s) => s.metadata.name === name);
|
|
const provider =
|
|
targetSubnet?.spec?.provider || NETWORK_PROVIDERS.OVN;
|
|
const sourceId =
|
|
provider !== NETWORK_PROVIDERS.OVN ? `overlay-${ provider }` : `subnet-${ name }`;
|
|
|
|
edges.push({
|
|
id: `edge-${ name }-to-${ vmName }`,
|
|
source: sourceId,
|
|
target: vmId,
|
|
animated: !isStopped,
|
|
style: {
|
|
stroke: isStopped ? THEME_COLORS.STOPPED : THEME_COLORS.VM,
|
|
strokeWidth: 1,
|
|
strokeDasharray: '5,5',
|
|
opacity: isStopped ? 0.4 : 1,
|
|
},
|
|
});
|
|
});
|
|
});
|
|
},
|
|
|
|
getNodeTypeHeight(type) {
|
|
switch (type) {
|
|
case NODE_TYPES.SUBNET:
|
|
return NODE_HEIGHT.SUBNET;
|
|
case NODE_TYPES.OVERLAY_NETWORK:
|
|
return NODE_HEIGHT.OVERLAY;
|
|
default:
|
|
return this.isVmType(type) ? NODE_HEIGHT.VM : NODE_HEIGHT.DEFAULT;
|
|
}
|
|
},
|
|
|
|
getLayoutNodeDimensions(nodes, node) {
|
|
if (node.type !== NODE_TYPES.GROUP) {
|
|
return {
|
|
width: Number.parseInt(node?.style?.width, 10) || LAYOUT.NODE_WIDTH,
|
|
height: this.getNodeTypeHeight(node?.type),
|
|
};
|
|
}
|
|
|
|
const children = nodes.filter((child) => child.parentNode === node.id);
|
|
const childWidth = Number.parseInt(children[0]?.style?.width, 10) || LAYOUT.NODE_WIDTH;
|
|
const contentHeight = children.reduce((total, child) => {
|
|
return total + this.getNodeTypeHeight(child.type);
|
|
}, 0);
|
|
const gapsHeight = Math.max(children.length - 1, 0) * LAYOUT.GROUP_NODE_GAP;
|
|
const baseHeight = contentHeight + gapsHeight + LAYOUT.BASE_PADDING * 2;
|
|
const minHeight = NODE_HEIGHT.DEFAULT + LAYOUT.BASE_PADDING * 2;
|
|
|
|
return {
|
|
width: childWidth + LAYOUT.BASE_PADDING * 2,
|
|
height: Math.max(baseHeight, minHeight),
|
|
};
|
|
},
|
|
|
|
getTopLevelNodeId(nodeById, nodeId) {
|
|
return nodeById.get(nodeId)?.parentNode || nodeId;
|
|
},
|
|
|
|
placePeerNodes(topLevelNodes, nodeDimensionsById) {
|
|
const peerNodes = topLevelNodes.filter((node) => node.data?.isPeerVpc);
|
|
|
|
if (!peerNodes.length) {
|
|
return;
|
|
}
|
|
|
|
const maxNonPeerRight = topLevelNodes
|
|
.filter((node) => !node.data?.isPeerVpc)
|
|
.reduce((maxRight, node) => {
|
|
const { width } = nodeDimensionsById.get(node.id);
|
|
|
|
return Math.max(maxRight, node.position.x + width);
|
|
}, 0);
|
|
const minPeerY = Math.min(...peerNodes.map((node) => node.position.y));
|
|
|
|
peerNodes
|
|
.sort((left, right) => left.position.y - right.position.y)
|
|
.forEach((node, index) => {
|
|
const { height } = nodeDimensionsById.get(node.id);
|
|
|
|
node.position = {
|
|
x: maxNonPeerRight + LAYOUT.PEER_COLUMN_GAP,
|
|
y: minPeerY + index * (height + LAYOUT.PEER_VERTICAL_GAP),
|
|
};
|
|
});
|
|
},
|
|
|
|
buildElkGraph(nodes, edges, nodeById, topLevelNodes, nodeDimensionsById) {
|
|
const elkGraph = {
|
|
id: 'root',
|
|
layoutOptions: {
|
|
'elk.algorithm': 'layered',
|
|
'elk.direction': 'DOWN',
|
|
'elk.spacing.nodeNode': `${ LAYOUT.AUTO_NODE_GAP }`,
|
|
},
|
|
children: [],
|
|
edges: [],
|
|
};
|
|
|
|
topLevelNodes.forEach((node) => {
|
|
const { width, height } = nodeDimensionsById.get(node.id);
|
|
|
|
if (node.type === NODE_TYPES.GROUP) {
|
|
const children = nodes.filter((child) => child.parentNode === node.id)
|
|
.map((child) => {
|
|
const childDimensions = this.getLayoutNodeDimensions(nodes, child);
|
|
|
|
return {
|
|
id: child.id,
|
|
width: childDimensions.width,
|
|
height: childDimensions.height,
|
|
};
|
|
});
|
|
|
|
elkGraph.children.push({
|
|
id: node.id,
|
|
width,
|
|
height,
|
|
children,
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
elkGraph.children.push({
|
|
id: node.id,
|
|
width,
|
|
height,
|
|
});
|
|
});
|
|
|
|
const graphEdgeKeys = new Set();
|
|
|
|
edges.forEach((edge) => {
|
|
const source = this.getTopLevelNodeId(nodeById, edge.source);
|
|
const target = this.getTopLevelNodeId(nodeById, edge.target);
|
|
|
|
if (!source || !target || source === target) {
|
|
return;
|
|
}
|
|
|
|
if (!nodeDimensionsById.has(source) || !nodeDimensionsById.has(target)) {
|
|
return;
|
|
}
|
|
|
|
const edgeKey = `${ source }->${ target }`;
|
|
|
|
if (graphEdgeKeys.has(edgeKey)) {
|
|
return;
|
|
}
|
|
|
|
graphEdgeKeys.add(edgeKey);
|
|
|
|
elkGraph.edges.push({
|
|
id: edgeKey,
|
|
sources: [source],
|
|
targets: [target],
|
|
});
|
|
});
|
|
|
|
return elkGraph;
|
|
},
|
|
|
|
applyElkLayout(layout, nodeById) {
|
|
const applyLayoutNode = (layoutNode) => {
|
|
const targetNode = nodeById.get(layoutNode.id);
|
|
|
|
if (targetNode) {
|
|
targetNode.position = {
|
|
x: layoutNode.x || 0,
|
|
y: layoutNode.y || 0,
|
|
};
|
|
|
|
if (targetNode.type === NODE_TYPES.GROUP) {
|
|
targetNode.style.width = `${ layoutNode.width }px`;
|
|
targetNode.style.height = `${ layoutNode.height }px`;
|
|
}
|
|
}
|
|
|
|
if (layoutNode.children) {
|
|
layoutNode.children.forEach((child) => applyLayoutNode(child));
|
|
}
|
|
};
|
|
|
|
if (layout.children) {
|
|
layout.children.forEach((child) => applyLayoutNode(child));
|
|
}
|
|
},
|
|
|
|
async applyAutoLayout(nodes, edges) {
|
|
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
|
const topLevelNodes = nodes.filter((node) => !node.parentNode);
|
|
const nodeDimensionsById = new Map(topLevelNodes.map((node) => {
|
|
return [node.id, this.getLayoutNodeDimensions(nodes, node)];
|
|
}));
|
|
|
|
const elkGraph = this.buildElkGraph(
|
|
nodes,
|
|
edges,
|
|
nodeById,
|
|
topLevelNodes,
|
|
nodeDimensionsById,
|
|
);
|
|
const layout = await elk.layout(elkGraph);
|
|
|
|
this.applyElkLayout(layout, nodeById);
|
|
this.placePeerNodes(topLevelNodes, nodeDimensionsById);
|
|
},
|
|
|
|
getNetworkDisplayBySubnet(subnet) {
|
|
const provider = subnet?.spec?.provider || NETWORK_PROVIDERS.OVN;
|
|
|
|
if (provider === NETWORK_PROVIDERS.OVN || provider.trim() === '') {
|
|
return provider;
|
|
}
|
|
|
|
const nad = this.allNetworkAttachments.find(
|
|
(networkAttachment) => networkAttachment?.parseConfig?.provider === provider,
|
|
);
|
|
|
|
if (!nad) {
|
|
return provider;
|
|
}
|
|
|
|
const networkName = nad?.parseConfig?.name || nad?.metadata?.name;
|
|
const namespace = nad?.metadata?.namespace;
|
|
|
|
return namespace ? `${ namespace }/${ networkName }` : networkName;
|
|
},
|
|
isVmType(type) {
|
|
return type === NODE_TYPES.VM || type === NODE_TYPES.MULTI_NETWORK_VM;
|
|
},
|
|
|
|
isSubnetScopedType(type) {
|
|
return (
|
|
type === NODE_TYPES.SUBNET ||
|
|
type === NODE_TYPES.GROUP ||
|
|
type === NODE_TYPES.OVERLAY_NETWORK
|
|
);
|
|
},
|
|
|
|
getNodeState(node) {
|
|
const defaultZIndex = node.zIndex || 1;
|
|
|
|
if (!this.selectedNodeId) {
|
|
return { stateClass: '', zIndex: defaultZIndex };
|
|
}
|
|
|
|
const isTarget = this.selectedNodeId === node.id;
|
|
const isRelated =
|
|
this.relatedIds.has(node.id) || node.type === NODE_TYPES.VPC;
|
|
const isDimmed =
|
|
node.type !== NODE_TYPES.GROUP && !isRelated && !isTarget;
|
|
|
|
if (isTarget) return { stateClass: 'node-focused', zIndex: 1000 };
|
|
if (isRelated) return { stateClass: 'node-related', zIndex: 999 };
|
|
if (isDimmed) return { stateClass: 'node-dimmed', zIndex: 0 };
|
|
|
|
return { stateClass: '', zIndex: defaultZIndex };
|
|
},
|
|
|
|
hasOutgoingConnection(nodeId) {
|
|
return this.filteredEdges.some((edge) => edge.source === nodeId);
|
|
},
|
|
|
|
onNodeClick({ node }) {
|
|
if (node.type === NODE_TYPES.GROUP) return;
|
|
|
|
// Navigate to peered VPC topology view
|
|
if (node.data?.isPeerVpc && node.data?.remoteVpcObj) {
|
|
this.navigateToPeeringVpc(node.data.remoteVpcObj);
|
|
|
|
return;
|
|
}
|
|
|
|
if (this.selectedNodeId === node.id) {
|
|
this.selectedNodeId = null;
|
|
this.relatedIds.clear();
|
|
|
|
return;
|
|
}
|
|
this.selectedNodeId = node.id;
|
|
const related = new Set([node.id]);
|
|
|
|
const traverse = (id, direction) => {
|
|
this.edges.forEach((edge) => {
|
|
const nextId =
|
|
direction === 'down' ? edge.source === id ? edge.target : null : edge.target === id ? edge.source : null;
|
|
|
|
if (nextId && !related.has(nextId)) {
|
|
related.add(nextId);
|
|
traverse(nextId, direction);
|
|
}
|
|
});
|
|
};
|
|
|
|
traverse(node.id, 'down');
|
|
traverse(node.id, 'up');
|
|
this.relatedIds = related;
|
|
},
|
|
|
|
onPaneClick() {
|
|
this.selectedNodeId = null;
|
|
this.relatedIds.clear();
|
|
},
|
|
|
|
onFlowInit(instance) {
|
|
this.flowInstance = instance;
|
|
this.scheduleInitialFit();
|
|
},
|
|
|
|
scheduleInitialFit() {
|
|
if (this.isFitted || !this.flowInstance || !this.filteredNodes.length) return;
|
|
|
|
this.$nextTick(() => {
|
|
requestAnimationFrame(() => {
|
|
if (this.isFitted || !this.filteredNodes.length) return;
|
|
|
|
this.flowInstance?.fitView({
|
|
padding: 0.25,
|
|
duration: 0,
|
|
includeHiddenNodes: true,
|
|
});
|
|
this.isFitted = true;
|
|
});
|
|
});
|
|
},
|
|
|
|
navigateToPeeringVpc(remoteVpcObj) {
|
|
if (remoteVpcObj && remoteVpcObj.goToDetail) {
|
|
remoteVpcObj.goToDetail();
|
|
}
|
|
},
|
|
|
|
getCssVar(name) {
|
|
return getComputedStyle(document.body).getPropertyValue(name).trim();
|
|
},
|
|
},
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
class="vpc-topology"
|
|
:style="topologyCssVars"
|
|
>
|
|
<div class="topology-header">
|
|
<div class="visibility-controls">
|
|
<Checkbox
|
|
v-for="option in visibilityOptions"
|
|
:key="option.modelKey"
|
|
v-model:value="$data[option.modelKey]"
|
|
class="control-item"
|
|
:label="option.label"
|
|
:disabled="option.disabled"
|
|
>
|
|
<template #label>
|
|
{{ option.label }}
|
|
<span
|
|
class="count-badge"
|
|
:class="[option.badgeClass, { disabled: option.disabled }]"
|
|
>{{ option.count }}</span>
|
|
</template>
|
|
</Checkbox>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="loading"
|
|
class="loading"
|
|
>
|
|
<i class="icon icon-spinner icon-spin" />
|
|
{{ t("harvester.vpc.topology.loading") }}
|
|
</div>
|
|
<div
|
|
v-else-if="nodes.length === 0"
|
|
class="empty-state"
|
|
>
|
|
<i class="icon icon-info" />
|
|
<p>{{ t("harvester.vpc.topology.empty") }}</p>
|
|
</div>
|
|
|
|
<VueFlow
|
|
v-else
|
|
:nodes="filteredNodes"
|
|
:edges="filteredEdges"
|
|
:class="['vpc-flow', { 'is-fitting': !isFitted }]"
|
|
:min-zoom="0.1"
|
|
:max-zoom="2"
|
|
@init="onFlowInit"
|
|
@node-click="onNodeClick"
|
|
@pane-click="onPaneClick"
|
|
>
|
|
<template #node-vpc="{ id, data }">
|
|
<Handle
|
|
v-if="!data.isPeerVpc"
|
|
id="vpc-bottom-handle"
|
|
type="source"
|
|
position="bottom"
|
|
/>
|
|
<Handle
|
|
v-if="!data.isPeerVpc && hasOutgoingConnection(id)"
|
|
id="vpc-right-handle"
|
|
type="source"
|
|
position="right"
|
|
/>
|
|
<Handle
|
|
v-if="data.isPeerVpc"
|
|
type="target"
|
|
position="left"
|
|
/>
|
|
<div
|
|
class="custom-node vpc-node"
|
|
:class="[data.stateClass, { 'peer-vpc': data.isPeerVpc }]"
|
|
>
|
|
<div class="node-name">
|
|
{{ data.name }}
|
|
<span
|
|
v-if="data.isPeerVpc"
|
|
class="peer-badge"
|
|
>{{ t("harvester.vpc.topology.labels.peering") }}</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template #node-group></template>
|
|
|
|
<template #node-subnet="{ id, data }">
|
|
<Handle
|
|
class="handle-center"
|
|
type="target"
|
|
position="top"
|
|
/>
|
|
<Handle
|
|
v-if="hasOutgoingConnection(id)"
|
|
class="handle-center"
|
|
type="source"
|
|
position="bottom"
|
|
/>
|
|
<div
|
|
class="custom-node subnet-node"
|
|
:class="data.stateClass"
|
|
>
|
|
<div class="node-name">
|
|
{{ data.name }}
|
|
</div>
|
|
<div class="node-details">
|
|
<div>
|
|
{{ t("harvester.vpc.topology.labels.cidr") }}: {{ data.cidr }}
|
|
</div>
|
|
<div>
|
|
{{ t("harvester.vpc.topology.labels.provider") }}:
|
|
{{ data.provider }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template #node-overlay-network="{ id, data }">
|
|
<Handle
|
|
class="handle-center"
|
|
type="target"
|
|
position="top"
|
|
/>
|
|
<Handle
|
|
v-if="hasOutgoingConnection(id)"
|
|
class="handle-center"
|
|
type="source"
|
|
position="bottom"
|
|
/>
|
|
<div
|
|
class="custom-node overlay-node"
|
|
:class="data.stateClass"
|
|
>
|
|
<div class="node-name">
|
|
{{ data.name }}
|
|
</div>
|
|
<div class="node-details">
|
|
<div>
|
|
{{ t("harvester.vpc.topology.labels.type") }}: {{ data.nadType }}
|
|
</div>
|
|
<div>
|
|
{{ t("harvester.vpc.topology.labels.clusterNetwork") }}:
|
|
{{ data.clusterNetwork }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template #node-vm="{ data }">
|
|
<Handle
|
|
type="target"
|
|
position="top"
|
|
/>
|
|
<div
|
|
class="custom-node vm-node"
|
|
:class="[data.stateClass, { stopped: data.isStopped }]"
|
|
>
|
|
<div class="node-header">
|
|
<span
|
|
class="status-indicator"
|
|
:class="data.isStopped ? 'is-stopped' : 'is-running'"
|
|
></span>
|
|
<div class="node-name">
|
|
{{ data.name }}
|
|
</div>
|
|
</div>
|
|
<div class="node-details">
|
|
<div
|
|
v-for="(iface, idx) in data.interfaces"
|
|
:key="idx"
|
|
class="interface-group"
|
|
>
|
|
<div class="subnet-text">
|
|
{{ t("harvester.vpc.topology.labels.subnet") }}:
|
|
{{ iface.subnet }}
|
|
</div>
|
|
<div class="network-text">
|
|
{{ t("harvester.vpc.topology.labels.network") }}:
|
|
{{ iface.network }}
|
|
</div>
|
|
<div class="ip-text">
|
|
{{ t("harvester.vpc.topology.labels.ip") }}: {{ iface.ip }}
|
|
</div>
|
|
<div class="mac-text-static">
|
|
{{ t("harvester.vpc.topology.labels.mac") }}: {{ iface.mac }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template #node-multi-network-vm="{ data }">
|
|
<Handle
|
|
type="target"
|
|
position="top"
|
|
/>
|
|
<div
|
|
class="custom-node vm-node"
|
|
:class="[data.stateClass, { stopped: data.isStopped }]"
|
|
>
|
|
<div class="node-header">
|
|
<span
|
|
class="status-indicator"
|
|
:class="data.isStopped ? 'is-stopped' : 'is-running'"
|
|
></span>
|
|
<div class="node-name">
|
|
{{ data.name }}
|
|
</div>
|
|
</div>
|
|
<div class="node-details">
|
|
<div
|
|
v-for="(iface, idx) in data.interfaces"
|
|
:key="idx"
|
|
class="interface-group"
|
|
>
|
|
<div class="subnet-text">
|
|
{{ t("harvester.vpc.topology.labels.subnet") }}:
|
|
{{ iface.subnet }}
|
|
</div>
|
|
<div class="network-text">
|
|
{{ t("harvester.vpc.topology.labels.network") }}:
|
|
{{ iface.network }}
|
|
</div>
|
|
<div class="ip-text">
|
|
{{ t("harvester.vpc.topology.labels.ip") }}: {{ iface.ip }}
|
|
</div>
|
|
<div class="mac-text-static">
|
|
{{ t("harvester.vpc.topology.labels.mac") }}: {{ iface.mac }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<Background
|
|
:pattern-color="backgroundPatternColor"
|
|
:gap="12"
|
|
size="1"
|
|
/>
|
|
<Controls /><MiniMap />
|
|
</VueFlow>
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="scss" scoped>
|
|
$transition-duration: 0.5s;
|
|
$transition-ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
$transition-ease-smooth: cubic-bezier(0.4, 0, 0.2, 1);
|
|
|
|
.vpc-topology {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: calc(100dvh - 220px);
|
|
min-height: 480px;
|
|
width: 100%;
|
|
background: var(--body-bg);
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
.topology-header {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 12px 0;
|
|
border-bottom: 1px solid var(--border);
|
|
.visibility-controls {
|
|
display: flex;
|
|
gap: 24px;
|
|
align-items: center;
|
|
.control-item {
|
|
display: flex;
|
|
align-items: center;
|
|
cursor: pointer;
|
|
::v-deep .checkbox-label {
|
|
display: flex;
|
|
align-items: center;
|
|
line-height: 1;
|
|
}
|
|
.count-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 2px 8px;
|
|
border-radius: 30px;
|
|
font-size: 12px;
|
|
min-width: 20px;
|
|
height: 20px;
|
|
text-align: center;
|
|
color: #fff;
|
|
margin-left: 8px;
|
|
line-height: 1;
|
|
|
|
&.badge-vpc {
|
|
background: var(--badge-vpc-bg);
|
|
}
|
|
|
|
&.badge-subnet {
|
|
background: var(--badge-subnet-bg);
|
|
}
|
|
|
|
&.badge-overlay {
|
|
background: var(--badge-overlay-bg);
|
|
}
|
|
|
|
&.badge-vm {
|
|
background: var(--badge-vm-bg);
|
|
}
|
|
|
|
&.disabled {
|
|
opacity: 0.4;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.loading {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex: 1;
|
|
min-height: 0;
|
|
font-size: 16px;
|
|
color: var(--muted);
|
|
i {
|
|
margin-right: 10px;
|
|
font-size: 20px;
|
|
}
|
|
}
|
|
.empty-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex: 1;
|
|
min-height: 0;
|
|
color: var(--muted);
|
|
i {
|
|
font-size: 48px;
|
|
margin-bottom: 16px;
|
|
}
|
|
p {
|
|
font-size: 16px;
|
|
margin: 0;
|
|
}
|
|
}
|
|
.vpc-flow {
|
|
flex: 1;
|
|
min-height: 0;
|
|
}
|
|
|
|
.vpc-flow.is-fitting {
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.vue-flow__node {
|
|
transition: all $transition-duration $transition-ease-spring;
|
|
}
|
|
::v-deep(.handle-center) {
|
|
left: 50%;
|
|
}
|
|
::v-deep(.vue-flow__edge) {
|
|
transition: opacity $transition-duration $transition-ease-smooth,
|
|
filter $transition-duration $transition-ease-smooth;
|
|
path {
|
|
transition: stroke-dasharray 0.4s ease;
|
|
}
|
|
&.dimmed {
|
|
opacity: 0.05 !important;
|
|
filter: grayscale(90%);
|
|
path {
|
|
stroke-dasharray: 5 !important;
|
|
}
|
|
}
|
|
}
|
|
|
|
::v-deep(.vue-flow__edge-text) {
|
|
font-size: 14px;
|
|
}
|
|
|
|
.custom-node {
|
|
width: 100%;
|
|
padding: 10px;
|
|
font-size: 13px;
|
|
line-height: 1.4;
|
|
box-sizing: border-box;
|
|
border-radius: 12px;
|
|
height: auto;
|
|
transition: transform $transition-duration $transition-ease-spring,
|
|
opacity $transition-duration $transition-ease-smooth,
|
|
box-shadow $transition-duration $transition-ease-smooth,
|
|
filter $transition-duration $transition-ease-smooth,
|
|
background-color $transition-duration $transition-ease-smooth;
|
|
opacity: 1;
|
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
|
|
|
.node-header {
|
|
display: flex;
|
|
align-items: center;
|
|
margin-bottom: 6px;
|
|
|
|
.node-name {
|
|
margin-bottom: 0;
|
|
}
|
|
}
|
|
.status-indicator {
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 50%;
|
|
margin-right: 8px;
|
|
flex-shrink: 0;
|
|
&.is-running {
|
|
background-color: var(--status-running-color);
|
|
box-shadow: 0 0 6px var(--status-running-glow);
|
|
}
|
|
&.is-stopped {
|
|
background-color: var(--status-stopped-color);
|
|
}
|
|
}
|
|
|
|
.node-name {
|
|
font-weight: 600;
|
|
font-size: 18px;
|
|
margin-bottom: 6px;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.node-details {
|
|
font-size: 14px;
|
|
}
|
|
|
|
.interface-group {
|
|
&:not(:first-child) {
|
|
margin-top: 6px;
|
|
padding-top: 6px;
|
|
border-top: 1px solid var(--node-vm-color);
|
|
}
|
|
}
|
|
|
|
&.vpc-node {
|
|
text-align: center;
|
|
border: 2px solid var(--node-vpc-color);
|
|
background-color: var(--node-vpc-bg);
|
|
|
|
.node-name {
|
|
margin-bottom: 0;
|
|
}
|
|
}
|
|
|
|
&.peer-vpc {
|
|
border-color: var(--node-peer-vpc-color);
|
|
background-color: var(--node-peer-vpc-bg);
|
|
cursor: pointer;
|
|
|
|
&:hover {
|
|
border-color: var(--node-vpc-color);
|
|
box-shadow: 0 6px 16px rgba(36, 83, 255, 0.18);
|
|
transform: translateY(-2px);
|
|
}
|
|
}
|
|
|
|
.peer-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 2px 6px;
|
|
border-radius: 999px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
margin-left: 8px;
|
|
background-color: var(--badge-peer-bg);
|
|
color: var(--badge-peer-text);
|
|
}
|
|
|
|
&.subnet-node {
|
|
border: 2px solid var(--node-subnet-color);
|
|
background-color: var(--node-subnet-bg);
|
|
}
|
|
|
|
&.overlay-node {
|
|
border: 2px dashed var(--node-overlay-color);
|
|
background-color: var(--node-overlay-bg);
|
|
}
|
|
|
|
&.vm-node {
|
|
border: 2px solid var(--node-vm-color);
|
|
background-color: var(--node-vm-bg);
|
|
|
|
&.stopped {
|
|
border: 2px dashed var(--node-vm-stopped-color);
|
|
background-color: var(--node-vm-stopped-bg);
|
|
}
|
|
}
|
|
|
|
&.node-focused {
|
|
transform: scale(1.03) translateY(-4px);
|
|
z-index: 1000;
|
|
opacity: 1 !important;
|
|
box-shadow: 0 15px 35px -5px rgba(0, 0, 0, 0.15),
|
|
0 0 0 3px rgba(36, 83, 255, 0.15);
|
|
}
|
|
&.node-related {
|
|
transform: scale(1.03);
|
|
z-index: 999;
|
|
opacity: 1 !important;
|
|
box-shadow: 0 8px 20px -3px rgba(0, 0, 0, 0.08);
|
|
}
|
|
&.node-dimmed {
|
|
opacity: 0.3;
|
|
filter: grayscale(85%);
|
|
transform: scale(0.98);
|
|
}
|
|
}
|
|
}
|
|
</style>
|