309 lines
9.8 KiB
Vue
309 lines
9.8 KiB
Vue
<script setup lang="ts">
|
||
import {
|
||
NButton,
|
||
NDataTable,
|
||
NPopconfirm,
|
||
NSpin,
|
||
type DataTableColumns,
|
||
} from 'naive-ui'
|
||
import { computed, h, ref } from 'vue'
|
||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||
|
||
import { deleteVcn, enableVcnIpv6, getVcn, listSecurityLists, listSubnets, renameVcn } from '@/api/networks'
|
||
import FootNote from '@/components/FootNote.vue'
|
||
import LifecycleBadge from '@/components/LifecycleBadge.vue'
|
||
import RenameModal from '@/components/RenameModal.vue'
|
||
import StatusBadge from '@/components/StatusBadge.vue'
|
||
import { fmtTime } from '@/composables/useFormat'
|
||
import { useAsync } from '@/composables/useAsync'
|
||
import type { Ipv6Step, SecurityList, Subnet } from '@/types/api'
|
||
import { useToast } from '@/composables/useToast'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const message = useToast()
|
||
|
||
const cfgId = computed(() => Number(route.params.cfgId))
|
||
const vcnId = computed(() => String(route.params.vcnId))
|
||
/** 列表页跳转时带上查询区域,非主区域 VCN 的所有请求都要发往该区域 */
|
||
const region = computed(() => (route.query.region as string) || undefined)
|
||
|
||
const vcn = useAsync(() => getVcn(cfgId.value, vcnId.value, region.value))
|
||
const subnets = useAsync(() => listSubnets(cfgId.value, vcnId.value, region.value))
|
||
const seclists = useAsync(() => listSecurityLists(cfgId.value, vcnId.value, region.value))
|
||
|
||
const ipv6Steps = ref<Ipv6Step[] | null>(null)
|
||
const enabling = ref(false)
|
||
|
||
async function runEnableIpv6() {
|
||
enabling.value = true
|
||
try {
|
||
const report = await enableVcnIpv6(cfgId.value, vcnId.value, region.value)
|
||
ipv6Steps.value = report.steps
|
||
const failed = report.steps.filter((s) => s.status === 'failed').length
|
||
if (failed) message.warning(`${failed} 个步骤失败,详见执行报告`)
|
||
else message.success('一键 IPv6 执行完成')
|
||
void vcn.run()
|
||
void subnets.run()
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '执行失败')
|
||
} finally {
|
||
enabling.value = false
|
||
}
|
||
}
|
||
|
||
const removing = ref(false)
|
||
|
||
async function removeVcn() {
|
||
removing.value = true
|
||
try {
|
||
await deleteVcn(cfgId.value, vcnId.value, region.value)
|
||
message.success('VCN 已删除')
|
||
void router.push({ name: 'network' })
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '删除失败')
|
||
} finally {
|
||
removing.value = false
|
||
}
|
||
}
|
||
|
||
const showRename = ref(false)
|
||
const renaming = ref(false)
|
||
|
||
async function doRename(name: string) {
|
||
renaming.value = true
|
||
try {
|
||
await renameVcn(cfgId.value, vcnId.value, name, region.value)
|
||
message.success('已重命名')
|
||
showRename.value = false
|
||
void vcn.run()
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '重命名失败')
|
||
} finally {
|
||
renaming.value = false
|
||
}
|
||
}
|
||
|
||
const subnetColumns: DataTableColumns<Subnet> = [
|
||
{
|
||
title: '子网',
|
||
key: 'displayName',
|
||
minWidth: 170,
|
||
render: (row) =>
|
||
h('div', [
|
||
h('div', { class: 'font-medium' }, row.displayName),
|
||
h('div', { class: 'mono text-xs text-ink-3 mt-px' }, row.dnsLabel),
|
||
]),
|
||
},
|
||
{
|
||
title: 'IPv4 CIDR',
|
||
key: 'cidrBlock',
|
||
minWidth: 120,
|
||
render: (r) => h('span', { class: 'mono' }, r.cidrBlock),
|
||
},
|
||
{
|
||
title: 'IPv6 /64',
|
||
key: 'ipv6CidrBlock',
|
||
minWidth: 200,
|
||
render: (r) =>
|
||
r.ipv6CidrBlock
|
||
? h('span', { class: 'mono text-[12.5px]' }, r.ipv6CidrBlock)
|
||
: h('span', { class: 'text-xs text-ink-3' }, '未启用'),
|
||
},
|
||
{
|
||
title: '公网 IP',
|
||
key: 'prohibitPublicIp',
|
||
width: 100,
|
||
render: (r) =>
|
||
h(StatusBadge, {
|
||
kind: r.prohibitPublicIp ? 'stop' : 'run',
|
||
label: r.prohibitPublicIp ? '禁止' : '允许',
|
||
dot: false,
|
||
}),
|
||
},
|
||
{
|
||
title: '状态',
|
||
key: 'lifecycleState',
|
||
width: 120,
|
||
render: (r) => h(LifecycleBadge, { state: r.lifecycleState }),
|
||
},
|
||
]
|
||
|
||
function ruleSummary(list: SecurityList): string {
|
||
const ingress = list.ingressRules ?? []
|
||
const egress = list.egressRules ?? []
|
||
const ing = ingress.length
|
||
const eg = egress.length
|
||
const allOpen =
|
||
ingress.some((r) => r.protocol === 'all' && r.source === '0.0.0.0/0') &&
|
||
egress.some((r) => r.protocol === 'all' && r.destination === '0.0.0.0/0')
|
||
return allOpen ? `出入全放行 · 入 ${ing} / 出 ${eg}` : `入 ${ing} / 出 ${eg}`
|
||
}
|
||
|
||
const seclistColumns: DataTableColumns<SecurityList> = [
|
||
{
|
||
title: '安全列表',
|
||
key: 'displayName',
|
||
minWidth: 240,
|
||
render: (row) => h('span', { class: 'font-medium' }, row.displayName),
|
||
},
|
||
{
|
||
title: '规则',
|
||
key: 'rules',
|
||
minWidth: 180,
|
||
render: (row) => h('span', { class: 'text-[13px] text-ink-2' }, ruleSummary(row)),
|
||
},
|
||
{
|
||
title: '状态',
|
||
key: 'lifecycleState',
|
||
width: 120,
|
||
render: (r) => h(LifecycleBadge, { state: r.lifecycleState }),
|
||
},
|
||
]
|
||
|
||
const stepColumns: DataTableColumns<Ipv6Step> = [
|
||
{
|
||
title: '步骤',
|
||
key: 'step',
|
||
minWidth: 170,
|
||
render: (r) => h('span', { class: 'mono text-[12.5px]' }, r.step),
|
||
},
|
||
{
|
||
title: '状态',
|
||
key: 'status',
|
||
width: 110,
|
||
render: (r) =>
|
||
h(StatusBadge, {
|
||
kind: r.status === 'done' ? 'run' : r.status === 'skipped' ? 'check' : 'term',
|
||
label: r.status,
|
||
}),
|
||
},
|
||
{
|
||
title: '详情',
|
||
key: 'detail',
|
||
minWidth: 260,
|
||
render: (r) => h('span', { class: 'mono text-xs break-all' }, r.detail),
|
||
},
|
||
]
|
||
</script>
|
||
|
||
<template>
|
||
<div class="flex flex-col gap-4 p-6 pb-8 max-md:p-3">
|
||
<div
|
||
v-if="vcn.loading.value && !vcn.data.value"
|
||
class="panel flex items-center justify-center py-20"
|
||
>
|
||
<NSpin size="small" />
|
||
</div>
|
||
<div v-else-if="vcn.error.value" class="panel px-5 py-10 text-center text-[13px] text-ink-3">
|
||
{{ vcn.error.value }}
|
||
</div>
|
||
|
||
<div v-if="vcn.data.value" class="panel px-5 py-4">
|
||
<RouterLink
|
||
class="mb-2 inline-flex items-center gap-1.5 text-[13px] text-ink-3 hover:text-ink"
|
||
:to="{ name: 'network' }"
|
||
>
|
||
← 返回网络列表
|
||
</RouterLink>
|
||
<div class="flex flex-wrap items-center gap-3">
|
||
<h1 class="page-title">{{ vcn.data.value.displayName }}</h1>
|
||
<LifecycleBadge :state="vcn.data.value.lifecycleState" />
|
||
<StatusBadge
|
||
v-if="vcn.data.value.ipv6CidrBlocks?.length"
|
||
kind="prov"
|
||
label="IPv6 已启用"
|
||
:dot="false"
|
||
/>
|
||
<div class="flex-1" />
|
||
<NButton size="small" @click="showRename = true">重命名</NButton>
|
||
<NPopconfirm @positive-click="removeVcn">
|
||
<template #trigger>
|
||
<NButton size="small" type="error" ghost :loading="removing" :disabled="removing">
|
||
{{ removing ? '删除中…' : '删除 VCN' }}
|
||
</NButton>
|
||
</template>
|
||
删除 VCN?将级联删除其子网、网关、路由等关联资源;子网仍被实例占用时会失败,需先终止实例
|
||
</NPopconfirm>
|
||
</div>
|
||
<div class="mt-3 grid grid-cols-4 gap-4 max-lg:grid-cols-2 max-md:grid-cols-1">
|
||
<div class="min-w-0">
|
||
<div class="text-xs text-ink-3">IPv4 CIDR</div>
|
||
<div class="mono mt-0.5 text-[13px]">{{ (vcn.data.value.cidrBlocks ?? []).join(', ') }}</div>
|
||
</div>
|
||
<div class="min-w-0">
|
||
<div class="text-xs text-ink-3">IPv6 CIDR</div>
|
||
<div class="mono mt-0.5 text-[13px] break-all">
|
||
{{ (vcn.data.value.ipv6CidrBlocks ?? []).join(', ') || '未启用' }}
|
||
</div>
|
||
</div>
|
||
<div class="min-w-0">
|
||
<div class="text-xs text-ink-3">DNS Label</div>
|
||
<div class="mono mt-0.5 text-[13px]">{{ vcn.data.value.dnsLabel || '—' }}</div>
|
||
</div>
|
||
<div class="min-w-0">
|
||
<div class="text-xs text-ink-3">创建时间</div>
|
||
<div class="mt-0.5 text-[13px]">{{ fmtTime(vcn.data.value.timeCreated) }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<div class="border-b border-line-soft px-4.5 py-3.5 text-sm font-semibold">子网</div>
|
||
<NDataTable
|
||
:columns="subnetColumns"
|
||
:data="subnets.data.value ?? []"
|
||
:loading="subnets.loading.value"
|
||
:scroll-x="760"
|
||
:row-key="(r: Subnet) => r.id"
|
||
/>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<div class="border-b border-line-soft px-4.5 py-3.5 text-sm font-semibold">安全列表</div>
|
||
<NDataTable
|
||
:columns="seclistColumns"
|
||
:data="seclists.data.value ?? []"
|
||
:loading="seclists.loading.value"
|
||
:scroll-x="600"
|
||
:row-key="(r: SecurityList) => r.id"
|
||
/>
|
||
<FootNote>
|
||
更新安全列表时规则字段一经提供即全量替换;protocol 取 all / 6(TCP) / 17(UDP) / 1(ICMP) /
|
||
58(ICMPv6)
|
||
</FootNote>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<div class="flex items-center justify-between border-b border-line-soft px-4.5 py-3.5">
|
||
<div class="text-sm font-semibold">一键启用 IPv6</div>
|
||
<NButton size="small" type="primary" :loading="enabling" @click="runEnableIpv6">
|
||
{{ ipv6Steps ? '重新执行' : '执行' }}
|
||
</NButton>
|
||
</div>
|
||
<NDataTable
|
||
v-if="ipv6Steps"
|
||
:columns="stepColumns"
|
||
:data="ipv6Steps"
|
||
:scroll-x="620"
|
||
:row-key="(r: Ipv6Step) => r.step"
|
||
/>
|
||
<div v-else class="px-4.5 py-4 text-[13px] text-ink-3">
|
||
依次执行:VCN 分配 /56 → 子网分配 /64 → 默认路由补 ::/0 → 默认安全列表放行 IPv6
|
||
出方向;全部步骤幂等,已配置的自动跳过。
|
||
</div>
|
||
<FootNote>
|
||
只改默认路由表与默认安全列表;实例要实际获得 IPv6 还需在创建实例 / VNIC 时启用 IPv6 分配
|
||
</FootNote>
|
||
</div>
|
||
|
||
<RenameModal
|
||
v-model:show="showRename"
|
||
title="重命名 VCN"
|
||
:current="vcn.data.value?.displayName ?? ''"
|
||
:submitting="renaming"
|
||
@submit="doRename"
|
||
/>
|
||
</div>
|
||
</template>
|