Files
oci-portal-dash/src/components/instance/InstanceSpecForm.vue
T

588 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { NCheckbox, NInput, NSelect } from 'naive-ui'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { listAvailabilityDomains, listImages, listShapes } from '@/api/instances'
import { listSubnets, listVcns } from '@/api/networks'
import { listLimits } from '@/api/tenant'
import AppInputNumber from '@/components/AppInputNumber.vue'
import FormField from '@/components/FormField.vue'
import FormSection from '@/components/FormSection.vue'
import { shortAd } from '@/composables/useFormat'
import { useAsync } from '@/composables/useAsync'
import type { ComputeShape, LimitItem } from '@/types/api'
/** 实例规格表单(创建实例弹窗与抢机任务共用):
* Shape / 可用域 / 规格 / 镜像 / VCN 子网 / 引导卷 / 登录方式 / IP 分配。
* 实例名、数量、租户 / 区域 / 区间由父表单提供,经 props 传入作数据源;
* sectioned 为 true 时渲染「规格 / 网络与存储 / 登录与 IP」分区标题。 */
const props = defineProps<{
cfgId: number | null
region?: string
compartmentId?: string
sectioned?: boolean
/** 可用域字段说明;抢机任务留空为轮询语义,与手动创建(固定 AD-1)不同 */
adHint?: string
/** 创建场景:AD 留空(默认 AD-1)而所选 shape 在 AD-1 无配额时,自动落位首个有配额 AD;
* 抢机表单留空 = 轮询语义,勿开 */
autoPickAd?: boolean
}>()
/** VCN 选择的「自动创建」哨兵:提交时不传 subnetId,由后端建 VCN 与子网 */
const AUTO_VCN = '__auto__'
const blank = {
availabilityDomain: null as string | null,
shape: 'VM.Standard.A1.Flex',
ocpus: 2,
memoryInGBs: 12,
imageId: null as string | null,
vcnId: AUTO_VCN,
subnetId: null as string | null,
bootVolumeSizeGBs: null as number | null,
bootVolumeVpusPerGB: 10,
assignPublicIp: true,
assignIpv6: true,
authMode: 'password' as 'password' | 'ssh',
randomPassword: true,
rootPassword: '',
sshPublicKey: '',
}
const form = reactive({ ...blank })
const images = useAsync(
() =>
props.cfgId === null
? Promise.resolve([])
: listImages(props.cfgId, { region: props.region || undefined, shape: form.shape }),
false,
)
const ads = useAsync(
() =>
props.cfgId === null
? Promise.resolve([])
: listAvailabilityDomains(props.cfgId, props.region || undefined),
false,
)
const vcns = useAsync(
() =>
props.cfgId === null
? Promise.resolve([])
: listVcns(props.cfgId, props.region || undefined, props.compartmentId || undefined).catch(
() => [],
),
false,
)
/** 租户当前区域实际提供的 shape 清单(服务端带缓存),失败回退基础清单 */
const shapes = useAsync(
() =>
props.cfgId === null
? Promise.resolve([])
: listShapes(props.cfgId, props.region || undefined).catch(() => []),
false,
)
/** compute 配额一次全量拉取(服务端单页 1000 条),本地按映射统计各 shape 核数配额 */
const limits = useAsync(
() =>
props.cfgId === null
? Promise.resolve([])
: listLimits(props.cfgId, { service: 'compute', region: props.region || undefined }).catch(
() => [],
),
false,
)
const subnets = useAsync(
() =>
props.cfgId === null || form.vcnId === AUTO_VCN
? Promise.resolve([])
: listSubnets(props.cfgId, form.vcnId, props.region || undefined).catch(() => []),
false,
)
function reloadAll() {
if (props.cfgId === null) return
void images.run()
void ads.run()
void vcns.run()
void shapes.run()
void limits.run()
}
/** 重置表单并按当前租户 / 区域重新加载数据源,父表单打开时调用 */
function reset() {
Object.assign(form, blank)
pendingSubnetId.value = null
reloadAll()
}
// 弹窗 / tab 均为按需挂载(display-directive=if),挂载即自加载一次
onMounted(reset)
// 租户 / 区域 / 区间切换(抢机表单内联选择场景):失效选择并重载
watch(
() => [props.cfgId, props.region, props.compartmentId],
() => {
form.imageId = null
form.vcnId = AUTO_VCN
form.subnetId = null
form.availabilityDomain = null
reloadAll()
},
)
watch(
() => form.shape,
() => {
form.imageId = null
if (props.cfgId !== null) void images.run()
},
)
watch(
() => form.vcnId,
() => {
form.subnetId = null
void subnets.run()
},
)
// 子网加载后:优先落位编辑回填的子网,否则默认选第一个
watch(
() => subnets.data.value,
(list) => {
if (!list?.length) return
if (pendingSubnetId.value && list.some((s) => s.id === pendingSubnetId.value)) {
form.subnetId = pendingSubnetId.value
pendingSubnetId.value = null
return
}
if (!form.subnetId) form.subnetId = list[0].id
},
)
/** 编辑任务回填:spec 为 buildSpec 产出的字段集合(payload.instance */
const pendingSubnetId = ref<string | null>(null)
function applySpec(spec: Record<string, unknown>) {
const s = spec as Partial<ReturnType<typeof buildSpec>> & Record<string, unknown>
if (typeof s.shape === 'string') form.shape = s.shape
if (typeof s.ocpus === 'number') form.ocpus = s.ocpus
if (typeof s.memoryInGBs === 'number') form.memoryInGBs = s.memoryInGBs
if (typeof s.availabilityDomain === 'string') form.availabilityDomain = s.availabilityDomain
if (typeof s.imageId === 'string') form.imageId = s.imageId
if (typeof s.bootVolumeSizeGBs === 'number') form.bootVolumeSizeGBs = s.bootVolumeSizeGBs
if (typeof s.bootVolumeVpusPerGB === 'number') form.bootVolumeVpusPerGB = s.bootVolumeVpusPerGB
form.assignPublicIp = s.assignPublicIp !== false
form.assignIpv6 = s.assignIpv6 !== false
form.authMode = typeof s.sshPublicKey === 'string' && s.sshPublicKey ? 'ssh' : 'password'
form.sshPublicKey = typeof s.sshPublicKey === 'string' ? s.sshPublicKey : ''
form.randomPassword = s.generateRootPassword === true || !s.rootPassword
form.rootPassword = typeof s.rootPassword === 'string' ? s.rootPassword : ''
pendingSubnetId.value = typeof s.subnetId === 'string' ? s.subnetId : null
}
// 回填的子网归属未知:VCN 列表加载后逐个查找包含它的 VCN 并落位
watch(
() => vcns.data.value,
async (list) => {
const target = pendingSubnetId.value
if (!target || !list?.length || props.cfgId === null) return
for (const v of list) {
const subs = await listSubnets(props.cfgId, v.id, props.region || undefined).catch(() => [])
if (subs.some((s) => s.id === target)) {
form.vcnId = v.id // 触发 subnets.run,落位在 subnets 加载 watch 中完成
return
}
}
pendingSubnetId.value = null
},
)
/** 免费类计费类型:仅用于排序置顶,不在选项文案中标注 */
const FREE_BILLING = new Set(['ALWAYS_FREE', 'LIMITED_FREE'])
/** shape 的核数配额名:quotaNames 排除 memory / reserved / reservable 变体,
* 剩余为核数(或实例数)限额名,与 compute limits 的 name 同名 */
function coreQuotaNames(s: ComputeShape): string[] {
return (s.quotaNames ?? []).filter(
(n) => !n.includes('memory') && !n.includes('reserved') && !n.includes('reservable'),
)
}
/** 单个 shape 的配额汇总:AD 行按 AD 求和,REGION 行累计为区域总额;无匹配行返回 null */
function shapeQuotaEntry(names: Set<string>, rows: LimitItem[]): ShapeQuota | null {
const byAd = new Map<string, number>()
let regional: number | null = null
let matched = false
for (const r of rows) {
if (!names.has(r.name)) continue
matched = true
if (r.scopeType === 'AD' && r.availabilityDomain) {
byAd.set(r.availabilityDomain, (byAd.get(r.availabilityDomain) ?? 0) + Math.max(r.value, 0))
} else if (r.scopeType === 'REGION') {
regional = (regional ?? 0) + Math.max(r.value, 0)
}
}
return matched ? { byAd, regional } : null
}
interface ShapeQuota {
byAd: Map<string, number> // AD 全名 → 核数配额
regional: number | null // 区域级总额,null = 无区域行
}
/** 全部 shape 的配额汇总;quotaNames 缺失或 limits 无匹配行的 shape 无条目(不判定) */
const quotaByShape = computed(() => {
const rows = limits.data.value ?? []
const out = new Map<string, ShapeQuota>()
if (!rows.length) return out
for (const s of shapes.data.value ?? []) {
const names = new Set(coreQuotaNames(s))
if (!names.size) continue
const entry = shapeQuotaEntry(names, rows)
if (entry) out.set(s.name, entry)
}
return out
})
/** shape 在指定 AD(null = 任一 AD)是否有配额;null = 数据不足不判定 */
function quotaInAd(name: string, ad: string | null): boolean | null {
const q = quotaByShape.value.get(name)
if (!q) return null
if (q.regional !== null && q.regional <= 0) return false
if (!q.byAd.size) return true // 仅区域级限额且 > 0
if (ad === null) return [...q.byAd.values()].some((v) => v > 0)
return (q.byAd.get(ad) ?? 0) > 0
}
/** 排序:免费 shape 优先 → VM.Standard 系列 → 其他 VM → 裸金属等 */
function shapeRank(s: { billingType: string; name: string }): number {
if (FREE_BILLING.has(s.billingType)) return 0
if (s.name.startsWith('VM.Standard')) return 1
if (s.name.startsWith('VM.')) return 2
return 3
}
const shapeOptions = computed(() => {
// 清单全部来自 shapes 接口,不做本地预设;接口不可用时仍可手输(tag 模式);
// 选定 AD 时按该 AD 判定配额,未选时看任一 AD
const list = shapes.data.value ?? []
const ad = form.availabilityDomain
return [...list]
.sort((a, b) => shapeRank(a) - shapeRank(b) || a.name.localeCompare(b.name))
.map((s) => {
const ok = quotaInAd(s.name, ad)
const suffix = ok === false ? (ad ? ' · 该 AD 无配额' : ' · 无配额') : ''
return { label: s.name + suffix, value: s.name, disabled: ok === false }
})
})
// 清单 / 配额加载后,当前 shape 不在清单或无配额时自动切到第一个可用项
watch(
() => shapeOptions.value,
(opts) => {
const cur = opts.find((o) => o.value === form.shape)
if (!opts.length || (cur && !cur.disabled)) return
const first = opts.find((o) => !o.disabled)
if (first) form.shape = String(first.value)
},
)
const selectedShape = computed(
() => shapes.data.value?.find((s) => s.name === form.shape) ?? null,
)
/** 清单外(手输 / 未加载)的 shape 按名称后缀判定弹性 */
const isFlex = computed(() => selectedShape.value?.isFlexible ?? form.shape.endsWith('.Flex'))
const ocpuRange = computed(() => ({
min: selectedShape.value?.ocpusMin || 1,
max: selectedShape.value?.ocpusMax || 128,
}))
const memRange = computed(() => ({
min: selectedShape.value?.memoryMinGBs || 1,
max: selectedShape.value?.memoryMaxGBs || 1024,
}))
// 切换 shape 后把已填规格收敛到该 shape 的合法范围
watch(selectedShape, (s) => {
if (!s?.isFlexible) return
form.ocpus = Math.min(Math.max(form.ocpus, ocpuRange.value.min), ocpuRange.value.max)
form.memoryInGBs = Math.min(Math.max(form.memoryInGBs, memRange.value.min), memRange.value.max)
})
/** 同名镜像只保留日期最新的一版:去掉尾部 -2026.05.09-0 作分组 key */
const imageOptions = computed(() => {
const groups = new Map<string, { id: string; displayName: string; timeCreated: string }>()
for (const img of images.data.value ?? []) {
const key = img.displayName.replace(/-\d{4}\.\d{2}\.\d{2}-\d+$/, '')
const prev = groups.get(key)
if (!prev || img.timeCreated > prev.timeCreated) groups.set(key, img)
}
return [...groups.values()]
.sort((a, b) => a.displayName.localeCompare(b.displayName))
.map((i) => ({ label: i.displayName, value: i.id }))
})
/** AD 选项按当前 shape 的配额标注并禁用无配额项;数据不足时不标注 */
const adOptions = computed(
() =>
ads.data.value?.map((ad) => {
const ok = quotaInAd(form.shape, ad)
return {
label: shortAd(ad) + (ok === false ? '(无配额)' : ''),
value: ad,
disabled: ok === false,
}
}) ?? [],
)
// 创建场景(autoPickAd):AD 留空将由后端取 AD-1;所选 shape 在 AD-1 无配额
// 而其他 AD 有配额时,自动落位首个有配额 AD(字段可见,用户可改)
watch([() => form.shape, quotaByShape, () => ads.data.value], () => {
if (!props.autoPickAd || form.availabilityDomain !== null) return
const list = ads.data.value ?? []
if (!list.length || quotaInAd(form.shape, list[0]) !== false) return
const target = list.find((ad) => quotaInAd(form.shape, ad) === true)
if (target) form.availabilityDomain = target
})
const vcnOptions = computed(() => [
{ label: '自动创建(oci-portal-auto-vcn', value: AUTO_VCN },
...(vcns.data.value ?? []).map((v) => ({ label: v.displayName, value: v.id })),
])
const subnetOptions = computed(
() =>
subnets.data.value?.map((s) => ({
label: `${s.displayName}${s.cidrBlock}${s.ipv6CidrBlock ? ' · IPv6' : ''}`,
value: s.id,
})) ?? [],
)
const selectedSubnet = computed(
() => subnets.data.value?.find((s) => s.id === form.subnetId) ?? null,
)
/** 自动创建的 VCN 默认启用 IPv6;选现有子网时看其是否有 IPv6 CIDR */
const ipv6Supported = computed(
() => form.vcnId === AUTO_VCN || !!selectedSubnet.value?.ipv6CidrBlock,
)
// 按子网支持程度自动勾选 / 取消 IPv6
watch(ipv6Supported, (v) => {
form.assignIpv6 = v
})
const valid = computed(() => {
if (!form.imageId) return false
if (form.vcnId !== AUTO_VCN && !form.subnetId) return false
return !isFlex.value || form.ocpus > 0
})
/** 规格摘要(父表单底部预估条用):shape 短名 + 弹性核内存 */
const summary = computed(() => {
const parts = [form.shape.replace(/^VM\.Standard\.?/, '') || form.shape]
if (isFlex.value) parts.push(`${form.ocpus} OCPU · ${form.memoryInGBs} GB`)
return parts.join(' · ')
})
/** 产出规格字段(不含 displayName / count / region / compartmentId,由父表单拼接) */
function buildSpec(): Record<string, unknown> {
const usePwd = form.authMode === 'password'
return {
availabilityDomain: form.availabilityDomain ?? undefined,
shape: form.shape,
ocpus: isFlex.value ? form.ocpus : undefined,
memoryInGBs: isFlex.value ? form.memoryInGBs : undefined,
imageId: form.imageId ?? undefined,
subnetId: form.vcnId === AUTO_VCN ? undefined : (form.subnetId ?? undefined),
bootVolumeSizeGBs: form.bootVolumeSizeGBs ?? undefined,
// 性能独立于大小:留空按镜像默认大小时同样生效(后端仅镜像启动源使用)
bootVolumeVpusPerGB: form.bootVolumeVpusPerGB || undefined,
assignPublicIp: form.assignPublicIp,
assignIpv6: form.assignIpv6,
generateRootPassword: usePwd && form.randomPassword ? true : undefined,
rootPassword:
usePwd && !form.randomPassword && form.rootPassword ? form.rootPassword : undefined,
sshPublicKey:
form.authMode === 'ssh' && form.sshPublicKey.trim() ? form.sshPublicKey.trim() : undefined,
}
}
defineExpose({ valid, buildSpec, reset, applySpec, summary })
</script>
<template>
<FormSection v-if="props.sectioned" title="规格" />
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField
label="Shape"
required
hint="清单来自租户当前区域实际提供的 shape(无配额已标注),可手输其他"
>
<NSelect
v-model:value="form.shape"
filterable
tag
:options="shapeOptions"
:loading="shapes.loading.value || limits.loading.value"
/>
</FormField>
<FormField label="可用域" :hint="props.adHint ?? '留空默认 AD-1'">
<NSelect
v-model:value="form.availabilityDomain"
clearable
:options="adOptions"
:loading="ads.loading.value"
placeholder="自动"
/>
</FormField>
</div>
<div v-if="isFlex" class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="OCPU" required :hint="`该 shape 可选 ${ocpuRange.min}${ocpuRange.max}`">
<AppInputNumber
v-model:value="form.ocpus"
:min="ocpuRange.min"
:max="ocpuRange.max"
class="w-full"
/>
</FormField>
<FormField label="内存(GB" :hint="`该 shape 可选 ${memRange.min}${memRange.max} GB`">
<AppInputNumber
v-model:value="form.memoryInGBs"
:min="memRange.min"
:max="memRange.max"
class="w-full"
/>
</FormField>
</div>
<FormField label="镜像" required hint="同名镜像只显示日期最新的一版,已按 shape 过滤">
<NSelect
v-model:value="form.imageId"
filterable
:options="imageOptions"
:loading="images.loading.value"
placeholder="选择启动镜像"
/>
</FormField>
<FormSection v-if="props.sectioned" title="网络与存储" />
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="VCN" hint="「自动创建」复用或创建 oci-portal-auto-vcn 公网子网,含 IPv4/IPv6 默认路由">
<NSelect v-model:value="form.vcnId" :options="vcnOptions" :loading="vcns.loading.value" />
</FormField>
<FormField v-if="form.vcnId !== AUTO_VCN" label="子网" required>
<NSelect
v-model:value="form.subnetId"
:options="subnetOptions"
:loading="subnets.loading.value"
placeholder="选择子网"
/>
</FormField>
</div>
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="引导卷(GB" hint="留空按镜像默认(约 47 GB">
<AppInputNumber
v-model:value="form.bootVolumeSizeGBs"
:min="47"
:max="32768"
clearable
class="w-full"
placeholder="默认"
/>
</FormField>
<FormField label="引导卷性能(VPU/GB" hint="10 均衡 120 超高性能">
<AppInputNumber
v-model:value="form.bootVolumeVpusPerGB"
:min="10"
:max="120"
:step="10"
class="w-full"
/>
</FormField>
</div>
<FormSection v-if="props.sectioned" title="登录与 IP" />
<FormField
label="登录方式"
hint="root 密码经 cloud-init 开启 root 密码登录,随机密码生成后写入实例标签 RootPassword"
>
<div class="grid grid-cols-2 gap-2.5 max-md:grid-cols-1">
<button
type="button"
class="flex cursor-pointer items-start gap-2.5 rounded-lg border bg-white px-3 py-2.5 text-left"
:class="
form.authMode === 'password'
? 'border-accent shadow-[0_0_0_1px_var(--color-accent)]'
: 'border-line hover:border-ink-3'
"
@click="form.authMode = 'password'"
>
<span
class="mt-0.5 flex h-[15px] w-[15px] flex-none items-center justify-center rounded-full border-[1.5px]"
:class="form.authMode === 'password' ? 'border-accent' : 'border-line'"
>
<span v-if="form.authMode === 'password'" class="h-[7px] w-[7px] rounded-full bg-accent" />
</span>
<span class="min-w-0">
<span class="block text-[12.5px] font-semibold">root 密码</span>
<span class="mt-0.5 block text-[11px] leading-snug text-ink-3">
随机或手动设置, cloud-init 开启 root 登录
</span>
</span>
</button>
<button
type="button"
class="flex cursor-pointer items-start gap-2.5 rounded-lg border bg-white px-3 py-2.5 text-left"
:class="
form.authMode === 'ssh'
? 'border-accent shadow-[0_0_0_1px_var(--color-accent)]'
: 'border-line hover:border-ink-3'
"
@click="form.authMode = 'ssh'"
>
<span
class="mt-0.5 flex h-[15px] w-[15px] flex-none items-center justify-center rounded-full border-[1.5px]"
:class="form.authMode === 'ssh' ? 'border-accent' : 'border-line'"
>
<span v-if="form.authMode === 'ssh'" class="h-[7px] w-[7px] rounded-full bg-accent" />
</span>
<span class="min-w-0">
<span class="block text-[12.5px] font-semibold">SSH 公钥</span>
<span class="mt-0.5 block text-[11px] leading-snug text-ink-3">
粘贴 ssh-ed25519 / ssh-rsa 公钥,不开启密码登录
</span>
</span>
</button>
</div>
</FormField>
<template v-if="form.authMode === 'password'">
<div class="mb-3.5">
<NCheckbox v-model:checked="form.randomPassword">随机密码</NCheckbox>
</div>
<FormField v-if="!form.randomPassword" label="root 密码">
<NInput
v-model:value="form.rootPassword"
type="password"
show-password-on="click"
placeholder="设置 root 登录密码"
/>
</FormField>
</template>
<FormField v-else label="SSH 公钥">
<NInput
v-model:value="form.sshPublicKey"
type="textarea"
:rows="2"
class="mono"
placeholder="ssh-ed25519 AAAA…"
/>
</FormField>
<div class="flex flex-wrap gap-5">
<NCheckbox v-model:checked="form.assignPublicIp">分配公网 IPv4</NCheckbox>
<NCheckbox v-model:checked="form.assignIpv6" :disabled="!ipv6Supported">
分配 IPv6{{ ipv6Supported ? '' : '(所选子网未启用)' }}
</NCheckbox>
</div>
</template>