初始提交:OCI 面板前端(含 GenAI 网关一期)
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
<script setup lang="ts">
|
||||
import { NInput, NSelect, useMessage, type SelectOption } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { attachVnic } from '@/api/instances'
|
||||
import { listSubnets } from '@/api/networks'
|
||||
import FormField from '@/components/FormField.vue'
|
||||
import FormModal from '@/components/FormModal.vue'
|
||||
import ToggleCard from '@/components/ToggleCard.vue'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { shortAd } from '@/composables/useFormat'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
cfgId: number
|
||||
instanceId: string
|
||||
region?: string
|
||||
/** 已附加网卡数,仅用于配额提示文案 */
|
||||
attachedCount: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:show': [boolean]; attached: [] }>()
|
||||
|
||||
const message = useMessage()
|
||||
const submitting = ref(false)
|
||||
const subnetId = ref<string | null>(null)
|
||||
const displayName = ref('')
|
||||
const privateIp = ref('')
|
||||
const assignPublicIp = ref(false)
|
||||
const assignIpv6 = ref(false)
|
||||
|
||||
/** vcnId 传空列全部子网:次要网卡允许跨 VCN */
|
||||
const subnets = useAsync(() => listSubnets(props.cfgId, '', props.region), false)
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(v) => {
|
||||
if (v) {
|
||||
subnetId.value = null
|
||||
displayName.value = ''
|
||||
privateIp.value = ''
|
||||
assignPublicIp.value = false
|
||||
assignIpv6.value = false
|
||||
void subnets.run()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const options = computed<SelectOption[]>(() =>
|
||||
(subnets.data.value ?? [])
|
||||
.filter((s) => s.lifecycleState === 'AVAILABLE')
|
||||
.map((s) => ({
|
||||
label: `${s.displayName}(${s.cidrBlock})${s.availabilityDomain ? ` · ${shortAd(s.availabilityDomain)}` : ''}`,
|
||||
value: s.id,
|
||||
})),
|
||||
)
|
||||
|
||||
const selected = computed(() => (subnets.data.value ?? []).find((s) => s.id === subnetId.value))
|
||||
/** 私有子网禁分公网 IP;未启用 IPv6 的子网禁自动分配 */
|
||||
const publicIpBlocked = computed(() => !!selected.value?.prohibitPublicIp)
|
||||
const ipv6Blocked = computed(() => !!selected.value && !selected.value.ipv6CidrBlock)
|
||||
|
||||
watch(selected, (s) => {
|
||||
if (s?.prohibitPublicIp) assignPublicIp.value = false
|
||||
if (s && !s.ipv6CidrBlock) assignIpv6.value = false
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
if (!subnetId.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await attachVnic(props.cfgId, props.instanceId, {
|
||||
region: props.region,
|
||||
subnetId: subnetId.value,
|
||||
displayName: displayName.value.trim(),
|
||||
privateIp: privateIp.value.trim(),
|
||||
assignPublicIp: assignPublicIp.value,
|
||||
assignIpv6: assignIpv6.value,
|
||||
})
|
||||
message.success('附加请求已提交,OS 内需自行配置新网卡 IP')
|
||||
emit('update:show', false)
|
||||
emit('attached')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '附加失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormModal
|
||||
:show="show"
|
||||
title="附加 VNIC"
|
||||
:submitting="submitting"
|
||||
:submit-disabled="!subnetId"
|
||||
submit-text="附加"
|
||||
@update:show="emit('update:show', $event)"
|
||||
@submit="submit"
|
||||
>
|
||||
<FormField
|
||||
label="子网"
|
||||
required
|
||||
hint="与实例同 AD 的子网;跨 VCN 子网亦可选"
|
||||
:error="
|
||||
!subnets.loading.value && !options.length ? '该区域没有可用子网,请先创建' : undefined
|
||||
"
|
||||
>
|
||||
<NSelect
|
||||
v-model:value="subnetId"
|
||||
:options="options"
|
||||
:loading="subnets.loading.value"
|
||||
filterable
|
||||
placeholder="选择子网"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="显示名称">
|
||||
<NInput v-model:value="displayName" placeholder="留空自动生成" />
|
||||
</FormField>
|
||||
<FormField label="私网 IP">
|
||||
<NInput v-model:value="privateIp" placeholder="留空自动分配(推荐)" />
|
||||
</FormField>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<ToggleCard
|
||||
v-model="assignPublicIp"
|
||||
title="分配公网 IP"
|
||||
:desc="publicIpBlocked ? '所选子网为私有子网,不可分配公网 IP' : '仅公有子网可用;临时地址,分离即释放'"
|
||||
:disabled="publicIpBlocked"
|
||||
/>
|
||||
<ToggleCard
|
||||
v-model="assignIpv6"
|
||||
title="自动分配 IPv6"
|
||||
:desc="
|
||||
ipv6Blocked
|
||||
? '所选子网未启用 IPv6(可在 VCN 详情一键启用)'
|
||||
: '附加时同时分一个 IPv6;后续可在网卡列表继续添加'
|
||||
"
|
||||
:disabled="ipv6Blocked"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2 rounded-md bg-wash px-3 py-2 text-xs leading-relaxed text-ink-3">
|
||||
当前已附加 {{ attachedCount }} 张网卡。可附加数量由 shape 决定(如 A1.Flex 每 OCPU 1
|
||||
张),超出配额时 OCI 会拒绝请求。
|
||||
</div>
|
||||
</FormModal>
|
||||
</template>
|
||||
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import { NCheckbox, NSelect, useMessage, type SelectGroupOption } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { listBootVolumes } from '@/api/bootVolumes'
|
||||
import { attachVolume, listVolumes } from '@/api/instances'
|
||||
import FormField from '@/components/FormField.vue'
|
||||
import FormModal from '@/components/FormModal.vue'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
cfgId: number
|
||||
instanceId: string
|
||||
region?: string
|
||||
availabilityDomain: string
|
||||
attachedVolumeIds: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:show': [boolean]; attached: [] }>()
|
||||
|
||||
const message = useMessage()
|
||||
const submitting = ref(false)
|
||||
const volumeId = ref<string | null>(null)
|
||||
const readOnly = ref(false)
|
||||
|
||||
const volumes = useAsync(
|
||||
() => listVolumes(props.cfgId, props.availabilityDomain, undefined, props.region),
|
||||
false,
|
||||
)
|
||||
/** 未挂载的引导卷可作为数据卷附加(OCI attachVolume 直接接受引导卷 OCID) */
|
||||
const bootVolumes = useAsync(
|
||||
() =>
|
||||
listBootVolumes(props.cfgId, props.availabilityDomain, undefined, props.region).catch(() => []),
|
||||
false,
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(v) => {
|
||||
if (v) {
|
||||
volumeId.value = null
|
||||
readOnly.value = false
|
||||
void volumes.run()
|
||||
void bootVolumes.run()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const options = computed<SelectGroupOption[]>(() => {
|
||||
const vols = (volumes.data.value ?? [])
|
||||
.filter((v) => v.lifecycleState === 'AVAILABLE' && !props.attachedVolumeIds.includes(v.id))
|
||||
.map((v) => ({ label: `${v.displayName}(${v.sizeInGBs} GB)`, value: v.id }))
|
||||
const bvs = (bootVolumes.data.value ?? [])
|
||||
.filter(
|
||||
(b) =>
|
||||
b.lifecycleState === 'AVAILABLE' &&
|
||||
!b.attachedInstanceId &&
|
||||
!props.attachedVolumeIds.includes(b.id),
|
||||
)
|
||||
.map((b) => ({ label: `${b.displayName}(${b.sizeInGBs} GB)`, value: b.id }))
|
||||
const groups: SelectGroupOption[] = []
|
||||
if (vols.length) groups.push({ type: 'group', label: '块卷', key: 'vol', children: vols })
|
||||
if (bvs.length) groups.push({ type: 'group', label: '引导卷(作数据卷)', key: 'bv', children: bvs })
|
||||
return groups
|
||||
})
|
||||
|
||||
const loading = computed(() => volumes.loading.value || bootVolumes.loading.value)
|
||||
|
||||
async function submit() {
|
||||
if (!volumeId.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await attachVolume(props.cfgId, props.instanceId, volumeId.value, readOnly.value, props.region)
|
||||
message.success('附加请求已提交,稍候在 OS 内可见新磁盘')
|
||||
emit('update:show', false)
|
||||
emit('attached')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '附加失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormModal
|
||||
:show="show"
|
||||
title="附加块卷"
|
||||
:submitting="submitting"
|
||||
:submit-disabled="!volumeId"
|
||||
submit-text="附加"
|
||||
@update:show="emit('update:show', $event)"
|
||||
@submit="submit"
|
||||
>
|
||||
<FormField
|
||||
label="块卷 / 引导卷"
|
||||
required
|
||||
hint="同可用域的块卷,或未挂载的引导卷(作为数据卷附加)"
|
||||
:error="!loading && !options.length ? '同可用域内没有可附加的块卷或引导卷' : undefined"
|
||||
>
|
||||
<NSelect
|
||||
v-model:value="volumeId"
|
||||
:options="options"
|
||||
:loading="loading"
|
||||
placeholder="选择块卷或引导卷"
|
||||
/>
|
||||
</FormField>
|
||||
<NCheckbox v-model:checked="readOnly">只读附加</NCheckbox>
|
||||
<div class="mt-2 text-xs leading-relaxed text-ink-3">
|
||||
半虚拟化附加,运行中实例可热插拔;附加后仍需在实例 OS 内分区挂载文件系统。
|
||||
</div>
|
||||
</FormModal>
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { NInput, useMessage } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { createConsoleConnection } from '@/api/instances'
|
||||
import FormField from '@/components/FormField.vue'
|
||||
import FormModal from '@/components/FormModal.vue'
|
||||
|
||||
const props = defineProps<{ show: boolean; cfgId: number; instanceId: string; region?: string }>()
|
||||
const emit = defineEmits<{ 'update:show': [boolean]; created: [] }>()
|
||||
|
||||
const message = useMessage()
|
||||
const submitting = ref(false)
|
||||
const publicKey = ref('')
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(v) => {
|
||||
if (v) publicKey.value = ''
|
||||
},
|
||||
)
|
||||
|
||||
const isRsa = computed(() => publicKey.value.trim().startsWith('ssh-rsa '))
|
||||
|
||||
async function submit() {
|
||||
submitting.value = true
|
||||
try {
|
||||
await createConsoleConnection(props.cfgId, props.instanceId, publicKey.value.trim(), props.region)
|
||||
message.success('控制台连接已创建,稍候列表刷新出连接串')
|
||||
emit('update:show', false)
|
||||
emit('created')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '创建失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormModal
|
||||
:show="show"
|
||||
title="创建控制台连接"
|
||||
:width="560"
|
||||
:submitting="submitting"
|
||||
:submit-disabled="!isRsa"
|
||||
submit-text="创建"
|
||||
@update:show="emit('update:show', $event)"
|
||||
@submit="submit"
|
||||
>
|
||||
<FormField
|
||||
label="SSH 公钥(仅 RSA)"
|
||||
required
|
||||
hint="生成示例:ssh-keygen -t rsa -b 4096 -f console_key"
|
||||
:error="
|
||||
publicKey.trim() && !isRsa
|
||||
? '必须以 ssh-rsa 开头 —— OCI 控制台连接不接受 ed25519 等其他算法'
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
<NInput
|
||||
v-model:value="publicKey"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
class="mono"
|
||||
placeholder="ssh-rsa AAAAB3…"
|
||||
/>
|
||||
</FormField>
|
||||
<div class="text-xs leading-relaxed text-ink-3">
|
||||
创建后用对应私钥执行连接串:串口直接执行 connectionString;VNC 执行 vncConnectionString
|
||||
建立隧道后,用 VNC 客户端连 localhost:5900。
|
||||
</div>
|
||||
</FormModal>
|
||||
</template>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import { NInput, useMessage } from 'naive-ui'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { createInstances } from '@/api/instances'
|
||||
import AppInputNumber from '@/components/AppInputNumber.vue'
|
||||
import FormField from '@/components/FormField.vue'
|
||||
import FormModal from '@/components/FormModal.vue'
|
||||
import InstanceSpecForm from '@/components/instance/InstanceSpecForm.vue'
|
||||
import { defaultResourceName } from '@/composables/useFormat'
|
||||
|
||||
/** 租户 / 区间 / 区域直接使用父页面当前值,不在表单中出现 */
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
cfgId: number | null
|
||||
compartmentId?: string
|
||||
region?: string
|
||||
}>()
|
||||
const emit = defineEmits<{ 'update:show': [boolean]; created: [] }>()
|
||||
|
||||
const message = useMessage()
|
||||
const submitting = ref(false)
|
||||
const specForm = ref<InstanceType<typeof InstanceSpecForm> | null>(null)
|
||||
|
||||
const form = reactive({ displayName: '', count: 1 })
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(v) => {
|
||||
if (!v) return
|
||||
form.displayName = defaultResourceName('instance')
|
||||
form.count = 1
|
||||
specForm.value?.reset()
|
||||
},
|
||||
)
|
||||
|
||||
const canSubmit = computed(
|
||||
() =>
|
||||
props.cfgId !== null &&
|
||||
Boolean(form.displayName.trim()) &&
|
||||
(specForm.value?.valid ?? false),
|
||||
)
|
||||
|
||||
async function submit() {
|
||||
if (props.cfgId === null || !specForm.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const result = await createInstances(props.cfgId, {
|
||||
region: props.region || undefined,
|
||||
compartmentId: props.compartmentId || undefined,
|
||||
displayName: form.displayName.trim(),
|
||||
count: form.count,
|
||||
...specForm.value.buildSpec(),
|
||||
})
|
||||
// 后端 errors / instances 为空时序列化为 null,判空防炸
|
||||
const ok = result.instances?.length ?? 0
|
||||
const failed = result.errors?.length ?? 0
|
||||
if (failed) message.warning(`${ok} 台创建成功,${failed} 台失败:${result.errors![0]}`)
|
||||
else message.success(`已创建 ${ok} 台实例`)
|
||||
emit('update:show', false)
|
||||
emit('created')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '创建失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormModal
|
||||
:show="show"
|
||||
title="创建实例"
|
||||
:width="600"
|
||||
:submitting="submitting"
|
||||
:submit-disabled="!canSubmit"
|
||||
submit-text="创建"
|
||||
@update:show="emit('update:show', $event)"
|
||||
@submit="submit"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
|
||||
<FormField label="实例名" required>
|
||||
<NInput v-model:value="form.displayName" placeholder="instance-20260703-2136" />
|
||||
</FormField>
|
||||
<FormField label="数量" hint="批量创建,名称自动追加序号">
|
||||
<AppInputNumber v-model:value="form.count" :min="1" :max="10" class="w-full" />
|
||||
</FormField>
|
||||
</div>
|
||||
<InstanceSpecForm
|
||||
ref="specForm"
|
||||
sectioned
|
||||
:cfg-id="cfgId"
|
||||
:region="region"
|
||||
:compartment-id="compartmentId"
|
||||
/>
|
||||
<div
|
||||
class="mt-3 flex flex-wrap items-center justify-between gap-2 rounded-lg border border-line-soft bg-wash px-3 py-2 text-xs leading-relaxed text-ink-3"
|
||||
>
|
||||
<span v-if="specForm?.summary" class="font-medium text-ink-2">
|
||||
预估规格:{{ form.count }} 台 · {{ specForm.summary }}
|
||||
</span>
|
||||
<span>容量不足(Out of host capacity)时可改用抢机任务反复尝试</span>
|
||||
</div>
|
||||
</FormModal>
|
||||
</template>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
import AppInputNumber from '@/components/AppInputNumber.vue'
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
|
||||
import { updateBootVolume } from '@/api/bootVolumes'
|
||||
import FormField from '@/components/FormField.vue'
|
||||
import FormModal from '@/components/FormModal.vue'
|
||||
import type { BootVolume } from '@/types/api'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
cfgId: number
|
||||
region?: string
|
||||
bootVolume: BootVolume | null
|
||||
}>()
|
||||
const emit = defineEmits<{ 'update:show': [boolean]; updated: [] }>()
|
||||
|
||||
const message = useMessage()
|
||||
const submitting = ref(false)
|
||||
const form = reactive({ sizeInGBs: 50, vpusPerGB: 10 })
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(v) => {
|
||||
if (!v || !props.bootVolume) return
|
||||
form.sizeInGBs = props.bootVolume.sizeInGBs
|
||||
form.vpusPerGB = props.bootVolume.vpusPerGB
|
||||
},
|
||||
)
|
||||
|
||||
async function submit() {
|
||||
const bv = props.bootVolume
|
||||
if (!bv) return
|
||||
if (form.sizeInGBs < bv.sizeInGBs) {
|
||||
message.error('引导卷仅支持扩容,不能缩小')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await updateBootVolume(
|
||||
props.cfgId,
|
||||
bv.id,
|
||||
{
|
||||
sizeInGBs: form.sizeInGBs === bv.sizeInGBs ? 0 : form.sizeInGBs,
|
||||
vpusPerGB: form.vpusPerGB === bv.vpusPerGB ? 0 : form.vpusPerGB,
|
||||
},
|
||||
props.region,
|
||||
)
|
||||
message.success('修改已提交;扩容后需在实例 OS 内扩展文件系统')
|
||||
emit('update:show', false)
|
||||
emit('updated')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormModal
|
||||
:show="show"
|
||||
title="调整引导卷"
|
||||
:submitting="submitting"
|
||||
submit-text="保存"
|
||||
@update:show="emit('update:show', $event)"
|
||||
@submit="submit"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
|
||||
<FormField label="大小(GB)" hint="仅支持扩容;扩容后需在 OS 内扩展文件系统">
|
||||
<AppInputNumber
|
||||
v-model:value="form.sizeInGBs"
|
||||
:min="bootVolume?.sizeInGBs ?? 47"
|
||||
:max="32768"
|
||||
class="w-full"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="性能(VPU/GB)" hint="10 均衡 – 120 超高性能,可在线调整">
|
||||
<AppInputNumber v-model:value="form.vpusPerGB" :min="10" :max="120" :step="10" class="w-full" />
|
||||
</FormField>
|
||||
</div>
|
||||
</FormModal>
|
||||
</template>
|
||||
@@ -0,0 +1,535 @@
|
||||
<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'
|
||||
|
||||
/** 实例规格表单(创建实例弹窗与抢机任务共用):
|
||||
* Shape / 可用域 / 规格 / 镜像 / VCN 子网 / 引导卷 / 登录方式 / IP 分配。
|
||||
* 实例名、数量、租户 / 区域 / 区间由父表单提供,经 props 传入作数据源;
|
||||
* sectioned 为 true 时渲染「规格 / 网络与存储 / 登录与 IP」分区标题。 */
|
||||
const props = defineProps<{
|
||||
cfgId: number | null
|
||||
region?: string
|
||||
compartmentId?: string
|
||||
sectioned?: boolean
|
||||
/** 可用域字段说明;抢机任务留空为轮询语义,与手动创建(固定 AD-1)不同 */
|
||||
adHint?: string
|
||||
}>()
|
||||
|
||||
/** VCN 选择的「自动创建」哨兵:提交时不传 subnetId,由后端建 VCN 与子网 */
|
||||
const AUTO_VCN = '__auto__'
|
||||
|
||||
const blank = {
|
||||
availabilityDomain: null as string | null,
|
||||
shape: 'VM.Standard.A1.Flex',
|
||||
ocpus: 4,
|
||||
memoryInGBs: 24,
|
||||
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
|
||||
},
|
||||
)
|
||||
|
||||
/** shape → OCI compute 配额名(官方 Limits by Service 命名);已知映射用于配额标注,
|
||||
* 清单外的 shape 不判定配额。AD 维度多行求和 > 0 才认为有配额。 */
|
||||
const SHAPE_QUOTAS: Record<string, string> = {
|
||||
'VM.Standard.A1.Flex': 'standard-a1-core-count',
|
||||
'VM.Standard.A2.Flex': 'standard-a2-core-count',
|
||||
'VM.Standard.A4.Flex': 'standard-a4-core-count',
|
||||
'VM.Standard.E2.1.Micro': 'standard-e2-micro-core-count',
|
||||
'VM.Standard.E3.Flex': 'standard-e3-core-ad-count',
|
||||
'VM.Standard.E4.Flex': 'standard-e4-core-count',
|
||||
'VM.Standard.E5.Flex': 'standard-e5-core-count',
|
||||
'VM.Standard.E6.Flex': 'standard-e6-core-count',
|
||||
'VM.Standard3.Flex': 'standard3-core-count',
|
||||
}
|
||||
|
||||
const BILLING_LABEL: Record<string, string> = {
|
||||
ALWAYS_FREE: '永久免费',
|
||||
LIMITED_FREE: '免费额度',
|
||||
}
|
||||
|
||||
/** 已知映射 shape 的核数配额合计;未知映射或配额未加载返回 null(不判定) */
|
||||
function quotaOf(shape: string): number | null {
|
||||
const quota = SHAPE_QUOTAS[shape]
|
||||
const items = limits.data.value ?? []
|
||||
if (!quota || !items.length) return null
|
||||
return items.filter((l) => l.name === quota).reduce((sum, l) => sum + (l.value > 0 ? l.value : 0), 0)
|
||||
}
|
||||
|
||||
/** 排序:免费 shape 优先 → VM.Standard 系列 → 其他 VM → 裸金属等 */
|
||||
function shapeRank(s: { billingType: string; name: string }): number {
|
||||
if (BILLING_LABEL[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 模式)
|
||||
const list = shapes.data.value ?? []
|
||||
return [...list]
|
||||
.sort((a, b) => shapeRank(a) - shapeRank(b) || a.name.localeCompare(b.name))
|
||||
.map((s) => {
|
||||
const free = BILLING_LABEL[s.billingType]
|
||||
const quota = quotaOf(s.name)
|
||||
const suffix = (free ? `(${free})` : '') + (quota === 0 ? ' · 无配额' : '')
|
||||
return { label: s.name + suffix, value: s.name, disabled: quota === 0 }
|
||||
})
|
||||
})
|
||||
|
||||
// 清单 / 配额加载后,当前 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 }))
|
||||
})
|
||||
|
||||
const adOptions = computed(
|
||||
() => ads.data.value?.map((ad) => ({ label: shortAd(ad), value: ad })) ?? [],
|
||||
)
|
||||
|
||||
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`)
|
||||
const free = selectedShape.value && BILLING_LABEL[selectedShape.value.billingType]
|
||||
if (free) parts.push(free)
|
||||
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>
|
||||
@@ -0,0 +1,140 @@
|
||||
<script setup lang="ts">
|
||||
import { LineChart } from 'echarts/charts'
|
||||
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { NSelect, NSpin } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import VChart from 'vue-echarts'
|
||||
|
||||
import { getInstanceTraffic } from '@/api/analytics'
|
||||
import EmptyCard from '@/components/EmptyCard.vue'
|
||||
import FootNote from '@/components/FootNote.vue'
|
||||
import { fmtBytes } from '@/composables/useFormat'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { darkTokens, tokens } from '@/theme/tokens'
|
||||
|
||||
use([LineChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer])
|
||||
|
||||
/** echarts 不认 CSS 变量,色板按明暗主题从 tokens 取 */
|
||||
const app = useAppStore()
|
||||
const t = computed(() => (app.dark ? darkTokens : tokens))
|
||||
|
||||
const props = defineProps<{ cfgId: number; instanceId: string; region?: string }>()
|
||||
|
||||
const days = ref(7)
|
||||
|
||||
const traffic = useAsync(() =>
|
||||
getInstanceTraffic(props.cfgId, props.instanceId, days.value, props.region),
|
||||
)
|
||||
|
||||
watch(days, () => void traffic.run())
|
||||
|
||||
const daysOptions = [
|
||||
{ label: '近 7 天', value: 7 },
|
||||
{ label: '近 30 天', value: 30 },
|
||||
]
|
||||
|
||||
const series = computed(() => {
|
||||
const vnic = traffic.data.value?.vnics[0]
|
||||
if (!vnic) return null
|
||||
const labels = vnic.outbound.map((p) => p.timestamp.slice(5, 10))
|
||||
return {
|
||||
labels,
|
||||
outbound: vnic.outbound.map((p) => +(p.bytes / 1024 ** 3).toFixed(2)),
|
||||
inbound: vnic.inbound.map((p) => +(p.bytes / 1024 ** 3).toFixed(2)),
|
||||
}
|
||||
})
|
||||
|
||||
const option = computed(() => ({
|
||||
grid: { left: 44, right: 16, top: 36, bottom: 26 },
|
||||
legend: { top: 4, left: 8, textStyle: { color: t.value.ink2, fontSize: 12 }, itemWidth: 14 },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: t.value.ink,
|
||||
borderWidth: 0,
|
||||
textStyle: { color: t.value.bg, fontSize: 12 },
|
||||
valueFormatter: (v: number) => `${v} GB`,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: series.value?.labels ?? [],
|
||||
boundaryGap: false,
|
||||
axisLine: { lineStyle: { color: t.value.line } },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: t.value.ink3, fontSize: 10.5 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
splitLine: { lineStyle: { color: t.value.lineSoft } },
|
||||
axisLabel: { color: t.value.ink3, fontSize: 10.5, formatter: '{value}G' },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '流出',
|
||||
type: 'line',
|
||||
data: series.value?.outbound ?? [],
|
||||
lineStyle: { color: t.value.chart, width: 2 },
|
||||
itemStyle: { color: t.value.chart },
|
||||
symbol: 'circle',
|
||||
symbolSize: 5,
|
||||
},
|
||||
{
|
||||
name: '流入',
|
||||
type: 'line',
|
||||
data: series.value?.inbound ?? [],
|
||||
lineStyle: { color: t.value.info, width: 2 },
|
||||
itemStyle: { color: t.value.info },
|
||||
symbol: 'circle',
|
||||
symbolSize: 5,
|
||||
},
|
||||
],
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<div class="flex flex-wrap items-center gap-2.5 border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="text-sm font-semibold">流量</div>
|
||||
<div class="flex-1" />
|
||||
<div class="w-28">
|
||||
<NSelect
|
||||
v-model:value="days"
|
||||
size="small"
|
||||
:options="daysOptions"
|
||||
:loading="traffic.loading.value"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="traffic.loading.value && !traffic.data.value"
|
||||
class="flex items-center justify-center py-14"
|
||||
>
|
||||
<NSpin size="small" />
|
||||
</div>
|
||||
<template v-else-if="traffic.data.value">
|
||||
<div class="flex flex-wrap gap-5 px-4.5 pt-3 text-[12.5px] text-ink-2">
|
||||
<span>
|
||||
<span class="mr-1.5 inline-block h-2 w-2 rounded-full" style="background: var(--color-chart)" />
|
||||
流出 · 合计 {{ fmtBytes(traffic.data.value.outboundBytes) }}
|
||||
</span>
|
||||
<span>
|
||||
<span class="mr-1.5 inline-block h-2 w-2 rounded-full" style="background: var(--color-info)" />
|
||||
流入 · 合计 {{ fmtBytes(traffic.data.value.inboundBytes) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="px-3 pb-2">
|
||||
<VChart class="w-full" style="height: 210px" :option="option" autoresize />
|
||||
</div>
|
||||
</template>
|
||||
<EmptyCard
|
||||
v-else
|
||||
title="暂无流量数据"
|
||||
note="该实例暂无 Monitoring 数据点(新建实例有数分钟延迟,终止实例不再产生数据)"
|
||||
/>
|
||||
<FootNote>
|
||||
数据来自 Monitoring 指标(VnicToNetworkBytes 发送 / VnicFromNetworkBytes 接收),按日聚合
|
||||
</FootNote>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { NSelect, useMessage } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { listBootVolumes } from '@/api/bootVolumes'
|
||||
import { replaceBootVolume } from '@/api/instances'
|
||||
import FormField from '@/components/FormField.vue'
|
||||
import FormModal from '@/components/FormModal.vue'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
cfgId: number
|
||||
instanceId: string
|
||||
region?: string
|
||||
availabilityDomain: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:show': [boolean]; replaced: [] }>()
|
||||
|
||||
const message = useMessage()
|
||||
const submitting = ref(false)
|
||||
const bootVolumeId = ref<string | null>(null)
|
||||
|
||||
const volumes = useAsync(
|
||||
() => listBootVolumes(props.cfgId, props.availabilityDomain, undefined, props.region),
|
||||
false,
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(v) => {
|
||||
if (v) {
|
||||
bootVolumeId.value = null
|
||||
void volumes.run()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const options = computed(
|
||||
() =>
|
||||
volumes.data.value
|
||||
?.filter((v) => !v.attachedInstanceId)
|
||||
.map((v) => ({ label: `${v.displayName}(${v.sizeInGBs} GB)`, value: v.id })) ?? [],
|
||||
)
|
||||
|
||||
async function submit() {
|
||||
if (!bootVolumeId.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await replaceBootVolume(props.cfgId, props.instanceId, bootVolumeId.value, props.region)
|
||||
message.success('替换请求已提交:分离旧引导卷并挂载所选引导卷')
|
||||
emit('update:show', false)
|
||||
emit('replaced')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '替换失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormModal
|
||||
:show="show"
|
||||
title="替换引导卷"
|
||||
:submitting="submitting"
|
||||
:submit-disabled="!bootVolumeId"
|
||||
submit-text="替换"
|
||||
@update:show="emit('update:show', $event)"
|
||||
@submit="submit"
|
||||
>
|
||||
<FormField
|
||||
label="目标引导卷"
|
||||
required
|
||||
hint="仅列出同可用域、未挂载的引导卷"
|
||||
:error="
|
||||
!volumes.loading.value && !options.length
|
||||
? '同可用域内没有未挂载的引导卷,无法替换'
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
<NSelect
|
||||
v-model:value="bootVolumeId"
|
||||
:options="options"
|
||||
:loading="volumes.loading.value"
|
||||
placeholder="选择未挂载的引导卷"
|
||||
/>
|
||||
</FormField>
|
||||
<div class="text-xs leading-relaxed text-ink-3">
|
||||
原引导卷分离后保留、不会删除;实例保持 STOPPED,替换完成后可启动。
|
||||
</div>
|
||||
</FormModal>
|
||||
</template>
|
||||
@@ -0,0 +1,146 @@
|
||||
<script setup lang="ts">
|
||||
import { NSelect, useMessage } from 'naive-ui'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { listShapes, updateInstance } from '@/api/instances'
|
||||
import AppInputNumber from '@/components/AppInputNumber.vue'
|
||||
import FormField from '@/components/FormField.vue'
|
||||
import FormModal from '@/components/FormModal.vue'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
cfgId: number
|
||||
instanceId: string
|
||||
region?: string
|
||||
shape: string
|
||||
ocpus: number
|
||||
memoryInGBs: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:show': [boolean]; resized: [] }>()
|
||||
|
||||
const message = useMessage()
|
||||
const submitting = ref(false)
|
||||
const form = reactive({ shape: props.shape, ocpus: props.ocpus, memoryInGBs: props.memoryInGBs })
|
||||
|
||||
const shapes = useAsync(() => listShapes(props.cfgId, props.region).catch(() => []), false)
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(v) => {
|
||||
if (v) {
|
||||
Object.assign(form, { shape: props.shape, ocpus: props.ocpus, memoryInGBs: props.memoryInGBs })
|
||||
void shapes.run()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const BILLING_LABEL: Record<string, string> = {
|
||||
ALWAYS_FREE: '永久免费',
|
||||
LIMITED_FREE: '免费额度',
|
||||
}
|
||||
|
||||
const shapeOptions = computed(() => {
|
||||
const list = shapes.data.value ?? []
|
||||
if (!list.length) return [{ label: form.shape, value: form.shape }]
|
||||
return list.map((s) => {
|
||||
const free = BILLING_LABEL[s.billingType]
|
||||
return { label: s.name + (free ? `(${free})` : ''), value: s.name }
|
||||
})
|
||||
})
|
||||
|
||||
const selectedShape = computed(
|
||||
() => shapes.data.value?.find((s) => s.name === form.shape) ?? null,
|
||||
)
|
||||
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)
|
||||
})
|
||||
|
||||
const changed = computed(
|
||||
() =>
|
||||
form.shape !== props.shape ||
|
||||
(isFlex.value && (form.ocpus !== props.ocpus || form.memoryInGBs !== props.memoryInGBs)),
|
||||
)
|
||||
|
||||
async function submit() {
|
||||
submitting.value = true
|
||||
try {
|
||||
await updateInstance(
|
||||
props.cfgId,
|
||||
props.instanceId,
|
||||
{
|
||||
shape: form.shape !== props.shape ? form.shape : undefined,
|
||||
ocpus: isFlex.value ? form.ocpus : undefined,
|
||||
memoryInGBs: isFlex.value ? form.memoryInGBs : undefined,
|
||||
},
|
||||
props.region,
|
||||
)
|
||||
message.success('规格调整已提交,运行中实例将自动重启')
|
||||
emit('update:show', false)
|
||||
emit('resized')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '调整失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormModal
|
||||
:show="show"
|
||||
title="调整规格"
|
||||
:submitting="submitting"
|
||||
:submit-disabled="!changed"
|
||||
submit-text="提交调整"
|
||||
@update:show="emit('update:show', $event)"
|
||||
@submit="submit"
|
||||
>
|
||||
<FormField label="Shape" required hint="可更换为租户当前区域提供的其他 shape,需目标容量可用">
|
||||
<NSelect
|
||||
v-model:value="form.shape"
|
||||
filterable
|
||||
tag
|
||||
:options="shapeOptions"
|
||||
:loading="shapes.loading.value"
|
||||
/>
|
||||
</FormField>
|
||||
<div v-if="isFlex" class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
|
||||
<FormField label="OCPU" required :hint="`可选 ${ocpuRange.min}–${ocpuRange.max}`">
|
||||
<AppInputNumber
|
||||
v-model:value="form.ocpus"
|
||||
:min="ocpuRange.min"
|
||||
:max="ocpuRange.max"
|
||||
class="w-full"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="内存(GB)" required :hint="`可选 ${memRange.min}–${memRange.max} GB`">
|
||||
<AppInputNumber
|
||||
v-model:value="form.memoryInGBs"
|
||||
:min="memRange.min"
|
||||
:max="memRange.max"
|
||||
class="w-full"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<div class="text-xs leading-relaxed text-ink-3">
|
||||
运行中实例 OCI 会自动重启完成变更;更换 shape 要求镜像与目标 shape 架构兼容(如 aarch64
|
||||
镜像不能换到 x86 shape)。A1 免费额度上限 4 OCPU / 24 GB。
|
||||
</div>
|
||||
</FormModal>
|
||||
</template>
|
||||
@@ -0,0 +1,332 @@
|
||||
<script setup lang="ts">
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { NSpin, useMessage } from 'naive-ui'
|
||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import { consoleSessionWsUrl, createConsoleSession, deleteConsoleSession } from '@/api/instances'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
cfgId: number
|
||||
instanceId: string
|
||||
region?: string
|
||||
instanceName?: string
|
||||
rootPassword?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:show': [boolean] }>()
|
||||
|
||||
const message = useMessage()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const termEl = ref<HTMLDivElement | null>(null)
|
||||
const phase = ref<'idle' | 'creating' | 'connecting' | 'connected' | 'closed'>('idle')
|
||||
const error = ref('')
|
||||
const minimized = ref(false)
|
||||
const fullscreen = ref(false)
|
||||
const height = ref(360)
|
||||
|
||||
let term: Terminal | null = null
|
||||
let fit: FitAddon | null = null
|
||||
let ws: WebSocket | null = null
|
||||
let ro: ResizeObserver | null = null
|
||||
let sessionId = ''
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
async function open() {
|
||||
phase.value = 'creating'
|
||||
error.value = ''
|
||||
height.value = Math.max(280, Math.round(window.innerHeight * 0.42))
|
||||
try {
|
||||
const res = await createConsoleSession(props.cfgId, props.instanceId, 'serial', props.region)
|
||||
sessionId = res.sessionId
|
||||
} catch (e) {
|
||||
phase.value = 'closed'
|
||||
error.value = e instanceof Error ? e.message : '创建串行会话失败'
|
||||
return
|
||||
}
|
||||
phase.value = 'connecting'
|
||||
await nextTick()
|
||||
mountTerm()
|
||||
connectWs()
|
||||
}
|
||||
|
||||
/** 深色终端主题固定不随明暗切换(对齐 Cloud Shell 观感) */
|
||||
function newTerminal(): Terminal {
|
||||
return new Terminal({
|
||||
fontFamily: "ui-monospace, 'SF Mono', Menlo, Consolas, monospace",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.2,
|
||||
cursorBlink: true,
|
||||
scrollback: 5000,
|
||||
theme: {
|
||||
background: '#1c1b1a',
|
||||
foreground: '#eceae4',
|
||||
cursor: '#d97757',
|
||||
selectionBackground: 'rgba(217, 119, 87, 0.35)',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function mountTerm() {
|
||||
if (term || !termEl.value) return
|
||||
term = newTerminal()
|
||||
fit = new FitAddon()
|
||||
term.loadAddon(fit)
|
||||
term.open(termEl.value)
|
||||
fit.fit()
|
||||
term.onData((d) => wsSendText(d))
|
||||
term.onResize(({ cols, rows }) => wsSendCtl({ type: 'resize', cols, rows }))
|
||||
// 选中即复制:终端里 Ctrl+C 保留 SIGINT 语义,复制交给选区
|
||||
term.onSelectionChange(() => {
|
||||
const sel = term?.getSelection()
|
||||
if (sel) void navigator.clipboard.writeText(sel).catch(() => undefined)
|
||||
})
|
||||
term.attachCustomKeyEventHandler(copyKeyHandler)
|
||||
ro = new ResizeObserver(() => fit?.fit())
|
||||
ro.observe(termEl.value)
|
||||
}
|
||||
|
||||
/** Cmd+C / Ctrl+Shift+C 在有选区时作复制,其余按键交还终端 */
|
||||
function copyKeyHandler(ev: KeyboardEvent): boolean {
|
||||
if (ev.type !== 'keydown') return true
|
||||
const combo = (ev.metaKey && !ev.ctrlKey) || (ev.ctrlKey && ev.shiftKey)
|
||||
if (combo && ev.key.toLowerCase() === 'c' && term?.hasSelection()) {
|
||||
void navigator.clipboard.writeText(term.getSelection()).catch(() => undefined)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function connectWs() {
|
||||
if (!term) return
|
||||
const url = consoleSessionWsUrl(sessionId, auth.token, `rows=${term.rows}&cols=${term.cols}`)
|
||||
ws = new WebSocket(url)
|
||||
ws.binaryType = 'arraybuffer'
|
||||
ws.onopen = () => {
|
||||
phase.value = 'connected'
|
||||
term?.focus()
|
||||
}
|
||||
ws.onmessage = (ev) => {
|
||||
if (ev.data instanceof ArrayBuffer) term?.write(new Uint8Array(ev.data))
|
||||
}
|
||||
ws.onclose = (ev) => {
|
||||
if (phase.value === 'idle' || phase.value === 'closed') return
|
||||
phase.value = 'closed'
|
||||
if (!error.value) error.value = mapCloseReason(ev.reason)
|
||||
}
|
||||
}
|
||||
|
||||
function mapCloseReason(reason: string): string {
|
||||
if (!reason) return '连接已断开'
|
||||
if (reason.includes('administratively prohibited'))
|
||||
return '串行控制台已被占用:同一时刻仅允许一路连接(可能有本地 ssh 或其他会话)'
|
||||
return reason
|
||||
}
|
||||
|
||||
function wsSendText(data: string) {
|
||||
if (ws?.readyState === WebSocket.OPEN) ws.send(encoder.encode(data))
|
||||
}
|
||||
|
||||
function wsSendCtl(ctl: { type: string; cols: number; rows: number }) {
|
||||
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(ctl))
|
||||
}
|
||||
|
||||
async function pasteFromClipboard() {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText()
|
||||
if (text) term?.paste(text)
|
||||
} catch {
|
||||
message.warning('无法读取剪贴板,请直接按 Cmd/Ctrl+V 粘贴')
|
||||
}
|
||||
term?.focus()
|
||||
}
|
||||
|
||||
/** 串口 tty 收不到 SIGWINCH,向 shell 注入 stty 同步行列数(需处于提示符) */
|
||||
function syncWinsize() {
|
||||
if (!term) return
|
||||
wsSendText(`stty rows ${term.rows} cols ${term.cols}\r`)
|
||||
message.info('已发送 stty 同步命令(需处于 shell 提示符)')
|
||||
term.focus()
|
||||
}
|
||||
|
||||
async function copyRootPassword() {
|
||||
await navigator.clipboard.writeText(props.rootPassword ?? '')
|
||||
message.success('已复制 root 密码')
|
||||
term?.focus()
|
||||
}
|
||||
|
||||
function reconnect() {
|
||||
teardown()
|
||||
void open()
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
fullscreen.value = !fullscreen.value
|
||||
minimized.value = false
|
||||
void nextTick(() => fit?.fit())
|
||||
}
|
||||
|
||||
function toggleMinimize() {
|
||||
minimized.value = !minimized.value
|
||||
if (!minimized.value) void nextTick(() => fit?.fit())
|
||||
}
|
||||
|
||||
function startDrag(e: MouseEvent) {
|
||||
const startY = e.clientY
|
||||
const startH = height.value
|
||||
const move = (ev: MouseEvent) => {
|
||||
height.value = Math.min(Math.max(startH + (startY - ev.clientY), 200), window.innerHeight - 72)
|
||||
}
|
||||
const up = () => {
|
||||
window.removeEventListener('mousemove', move)
|
||||
window.removeEventListener('mouseup', up)
|
||||
}
|
||||
window.addEventListener('mousemove', move)
|
||||
window.addEventListener('mouseup', up)
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
ro?.disconnect()
|
||||
ro = null
|
||||
if (ws) {
|
||||
ws.onclose = null
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
if (sessionId) {
|
||||
void deleteConsoleSession(sessionId).catch(() => undefined)
|
||||
sessionId = ''
|
||||
}
|
||||
term?.dispose()
|
||||
term = null
|
||||
fit = null
|
||||
phase.value = 'idle'
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
function close() {
|
||||
teardown()
|
||||
minimized.value = false
|
||||
fullscreen.value = false
|
||||
emit('update:show', false)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(v) => {
|
||||
if (v) void open()
|
||||
else teardown()
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(teardown)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-x-0 bottom-0 z-[1500] flex flex-col border-t border-[#3a3835] shadow-[0_-6px_24px_rgba(0,0,0,0.35)]"
|
||||
:class="fullscreen && !minimized ? 'top-0' : ''"
|
||||
>
|
||||
<div
|
||||
v-if="!fullscreen && !minimized"
|
||||
class="h-[3px] shrink-0 cursor-ns-resize bg-[#141413] transition-colors hover:bg-[#d97757]"
|
||||
@mousedown="startDrag"
|
||||
/>
|
||||
<!-- 标题栏(Cloud Shell 风格) -->
|
||||
<div class="flex h-9 shrink-0 items-center gap-2 bg-[#141413] px-3 text-[12.5px] text-[#a8a69e]">
|
||||
<span
|
||||
class="inline-block h-2 w-2 rounded-full"
|
||||
:style="{ background: phase === 'connected' ? '#8fa574' : phase === 'closed' ? '#d06060' : '#c29135' }"
|
||||
/>
|
||||
<span class="truncate font-medium text-[#eceae4]">
|
||||
{{ instanceName || instanceId }} · 串行控制台
|
||||
</span>
|
||||
<span v-if="phase === 'creating'" class="text-xs">准备连接中…</span>
|
||||
<div class="flex-1" />
|
||||
<button v-if="rootPassword" class="ser-btn" @click="copyRootPassword">复制 root 密码</button>
|
||||
<button class="ser-btn" :disabled="phase !== 'connected'" @click="pasteFromClipboard">
|
||||
粘贴
|
||||
</button>
|
||||
<button
|
||||
class="ser-btn"
|
||||
:disabled="phase !== 'connected'"
|
||||
title="向 shell 发送 stty 命令同步终端行列数"
|
||||
@click="syncWinsize"
|
||||
>
|
||||
同步窗口
|
||||
</button>
|
||||
<button v-if="phase === 'closed'" class="ser-btn text-[#d97757]" @click="reconnect">
|
||||
重连
|
||||
</button>
|
||||
<div class="mx-1 h-4 w-px bg-[#3a3835]" />
|
||||
<button class="ser-btn" :title="minimized ? '还原' : '最小化'" @click="toggleMinimize">
|
||||
{{ minimized ? '◱' : '─' }}
|
||||
</button>
|
||||
<button class="ser-btn" :title="fullscreen ? '退出全屏' : '全屏'" @click="toggleFullscreen">
|
||||
⛶
|
||||
</button>
|
||||
<button class="ser-btn" title="关闭并结束会话" @click="close">✕</button>
|
||||
</div>
|
||||
<!-- 终端区 -->
|
||||
<div
|
||||
v-show="!minimized"
|
||||
class="relative min-h-0 bg-[#1c1b1a]"
|
||||
:class="fullscreen ? 'flex-1' : ''"
|
||||
:style="fullscreen ? {} : { height: height + 'px' }"
|
||||
>
|
||||
<div ref="termEl" class="absolute inset-0 py-1 pl-2" />
|
||||
<div
|
||||
v-if="phase !== 'connected'"
|
||||
class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-[#1c1b1a]/92 text-[13px] text-[#a8a69e]"
|
||||
>
|
||||
<template v-if="phase === 'creating' || phase === 'connecting'">
|
||||
<NSpin size="small" />
|
||||
<div>
|
||||
{{
|
||||
phase === 'creating'
|
||||
? '正在清理旧连接并创建控制台连接(约 30-90 秒)…'
|
||||
: '正在建立串行隧道…'
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="phase === 'closed'">
|
||||
<div class="max-w-[80%] break-all text-center">{{ error || '连接已关闭' }}</div>
|
||||
<div class="max-w-[80%] text-center text-xs leading-relaxed">
|
||||
串口登录需实例本地账户密码(SSH 公钥无效);创建实例时若启用随机 root
|
||||
密码可从顶栏复制。无密码也可查看启动日志或经 GRUB 单用户模式救援。
|
||||
</div>
|
||||
<button class="ser-btn !border !border-[#3a3835] !px-3 !py-1" @click="reconnect">
|
||||
重新连接
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ser-btn {
|
||||
border-radius: 4px;
|
||||
padding: 2px 7px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: inherit;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
.ser-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #eceae4;
|
||||
}
|
||||
.ser-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NPopconfirm, NSpin, useMessage } from 'naive-ui'
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { addVnicIpv6, detachVnic, listVnics, removeIpv6 } from '@/api/instances'
|
||||
import FootNote from '@/components/FootNote.vue'
|
||||
import AttachVnicModal from '@/components/instance/AttachVnicModal.vue'
|
||||
import LifecycleBadge from '@/components/LifecycleBadge.vue'
|
||||
import OcidText from '@/components/OcidText.vue'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { ATTACHMENT_TRANSIENT_STATES, useTransientPoll } from '@/composables/useTransientPoll'
|
||||
|
||||
/** 网卡(VNIC)区块:每卡一主行 + IPv6 副行;IPv6 增删按卡操作。
|
||||
* 删除复用实例级接口(后端按地址遍历全部网卡),添加走 per-VNIC 接口。 */
|
||||
const props = defineProps<{
|
||||
cfgId: number
|
||||
instanceId: string
|
||||
region?: string
|
||||
/** 实例已终止 / 终止中:全部变更操作不可用 */
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
|
||||
const message = useMessage()
|
||||
const vnics = useAsync(() => listVnics(props.cfgId, props.instanceId, props.region))
|
||||
|
||||
useTransientPoll(
|
||||
() => vnics.data.value,
|
||||
(list) => !!list?.some((v) => ATTACHMENT_TRANSIENT_STATES.includes(v.lifecycleState)),
|
||||
() => void vnics.run({ silent: true }),
|
||||
)
|
||||
|
||||
const showAttach = ref(false)
|
||||
const detaching = ref('')
|
||||
const addingIpv6 = ref('')
|
||||
const removingIpv6 = ref('')
|
||||
const busy = computed(() => !!detaching.value || !!addingIpv6.value || !!removingIpv6.value)
|
||||
|
||||
function refresh() {
|
||||
void vnics.run()
|
||||
emit('changed')
|
||||
}
|
||||
|
||||
async function doDetach(attachmentId: string) {
|
||||
detaching.value = attachmentId
|
||||
try {
|
||||
await detachVnic(props.cfgId, attachmentId, props.region)
|
||||
message.success('分离请求已提交')
|
||||
refresh()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '分离失败')
|
||||
} finally {
|
||||
detaching.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function doAddIpv6(vnicId: string) {
|
||||
addingIpv6.value = vnicId
|
||||
try {
|
||||
await addVnicIpv6(props.cfgId, vnicId, '', props.region)
|
||||
message.success('IPv6 已添加')
|
||||
refresh()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '添加失败,请确认子网已启用 IPv6')
|
||||
} finally {
|
||||
addingIpv6.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function doRemoveIpv6(addr: string) {
|
||||
removingIpv6.value = addr
|
||||
try {
|
||||
await removeIpv6(props.cfgId, props.instanceId, addr, props.region)
|
||||
message.success('IPv6 已取消分配')
|
||||
refresh()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败')
|
||||
} finally {
|
||||
removingIpv6.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ refresh: () => vnics.run() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<div class="flex items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold">网卡(VNIC)</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
实例的全部虚拟网卡;每张网卡可各自附加多个 IPv6
|
||||
</div>
|
||||
</div>
|
||||
<NButton size="small" type="primary" :disabled="disabled" @click="showAttach = true">
|
||||
附加 VNIC
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="vnics.loading.value && !vnics.data.value"
|
||||
class="flex items-center justify-center py-8"
|
||||
>
|
||||
<NSpin size="small" />
|
||||
</div>
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="w-full min-w-[760px] border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr class="text-left text-xs text-ink-3">
|
||||
<th class="w-[26%] border-b border-line-soft py-2 pr-3 pl-4.5 font-medium">名称</th>
|
||||
<th class="border-b border-line-soft px-3 py-2 font-medium">私网 IP</th>
|
||||
<th class="border-b border-line-soft px-3 py-2 font-medium">公网 IP</th>
|
||||
<th class="border-b border-line-soft px-3 py-2 font-medium">子网</th>
|
||||
<th class="w-28 border-b border-line-soft px-3 py-2 font-medium">状态</th>
|
||||
<th class="w-18 border-b border-line-soft py-2 pr-4.5 pl-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="v in vnics.data.value ?? []" :key="v.attachmentId">
|
||||
<tr>
|
||||
<td class="py-2.5 pr-3 pb-1.5 pl-4.5">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="truncate font-medium" :title="v.displayName">
|
||||
{{ v.displayName || '(未命名)' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="v.isPrimary"
|
||||
class="flex-none rounded bg-accent/10 px-1.5 py-px text-[10.5px] font-semibold text-accent"
|
||||
>
|
||||
主网卡
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="mono px-3 py-2.5 pb-1.5">{{ v.privateIp || '—' }}</td>
|
||||
<td class="mono px-3 py-2.5 pb-1.5">{{ v.publicIp || '—' }}</td>
|
||||
<td class="px-3 py-2.5 pb-1.5">
|
||||
<span v-if="v.subnetName" class="truncate" :title="v.subnetName">
|
||||
{{ v.subnetName }}
|
||||
</span>
|
||||
<OcidText v-else-if="v.subnetId" :ocid="v.subnetId" />
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
<td class="px-3 py-2.5 pb-1.5"><LifecycleBadge :state="v.lifecycleState" /></td>
|
||||
<td class="py-2.5 pr-4.5 pb-1.5 pl-3">
|
||||
<NButton
|
||||
v-if="v.isPrimary"
|
||||
size="tiny"
|
||||
quaternary
|
||||
disabled
|
||||
title="主网卡不可分离"
|
||||
>
|
||||
分离
|
||||
</NButton>
|
||||
<NPopconfirm v-else @positive-click="doDetach(v.attachmentId)">
|
||||
<template #trigger>
|
||||
<NButton
|
||||
size="tiny"
|
||||
quaternary
|
||||
type="error"
|
||||
:loading="detaching === v.attachmentId"
|
||||
:disabled="disabled || v.lifecycleState !== 'ATTACHED' || busy"
|
||||
>
|
||||
分离
|
||||
</NButton>
|
||||
</template>
|
||||
分离该网卡?其上全部 IP(含 IPv6)将一并释放
|
||||
</NPopconfirm>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b border-line-soft last:border-b-0">
|
||||
<td colspan="6" class="px-4.5 pt-0 pb-2.5">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span class="text-[10.5px] font-medium tracking-wider text-ink-3">IPV6</span>
|
||||
<template v-if="v.vnicId">
|
||||
<span
|
||||
v-for="addr in v.ipv6Addresses ?? []"
|
||||
:key="addr"
|
||||
class="mono inline-flex max-w-full items-center gap-1 rounded border border-line bg-white px-1.5 py-0.5 text-xs"
|
||||
>
|
||||
<span class="truncate">{{ addr }}</span>
|
||||
<button
|
||||
class="flex-none cursor-pointer text-ink-3 hover:text-err disabled:cursor-wait"
|
||||
:aria-label="`删除 ${addr}`"
|
||||
:disabled="disabled || busy"
|
||||
@click="doRemoveIpv6(addr)"
|
||||
>
|
||||
{{ removingIpv6 === addr ? '…' : '×' }}
|
||||
</button>
|
||||
</span>
|
||||
<span v-if="!(v.ipv6Addresses ?? []).length" class="text-xs text-ink-3">
|
||||
无
|
||||
</span>
|
||||
<NButton
|
||||
size="tiny"
|
||||
quaternary
|
||||
type="primary"
|
||||
:loading="addingIpv6 === v.vnicId"
|
||||
:disabled="disabled || busy"
|
||||
@click="doAddIpv6(v.vnicId)"
|
||||
>
|
||||
+ IPv6
|
||||
</NButton>
|
||||
</template>
|
||||
<span v-else class="text-xs text-ink-3">无 · 附加完成后可添加</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
v-if="!(vnics.data.value ?? []).length && !vnics.loading.value"
|
||||
class="py-6 text-center text-[13px] text-ink-3"
|
||||
>
|
||||
{{ vnics.error.value || '无网卡记录' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FootNote>
|
||||
附加须实例运行中且 shape 有空余 VNIC 配额 · 主网卡不可分离 · 每卡 IPv6
|
||||
可多个(要求所在子网已启用 IPv6)· 次要网卡在 OS 内需自行配置 IP(dhclient / netplan)
|
||||
</FootNote>
|
||||
|
||||
<AttachVnicModal
|
||||
v-model:show="showAttach"
|
||||
:cfg-id="cfgId"
|
||||
:instance-id="instanceId"
|
||||
:region="region"
|
||||
:attached-count="(vnics.data.value ?? []).length"
|
||||
@attached="refresh"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user