Files
oci-portal-dash/src/views/InstanceDetailView.vue
T

793 lines
28 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 {
NButton,
NDataTable,
NDropdown,
NSpin,
useDialog,
type DataTableColumns,
} from 'naive-ui'
import { computed, h, ref, watch } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { getBootVolume } from '@/api/bootVolumes'
import { getConfig } from '@/api/configs'
import {
deleteConsoleConnection,
detachBootVolume,
detachVolume,
getImage,
getInstance,
instanceAction,
listBootVolumeAttachments,
listConsoleConnections,
listVolumeAttachments,
terminateInstance,
updateInstance,
} from '@/api/instances'
import EmptyCard from '@/components/EmptyCard.vue'
import FootNote from '@/components/FootNote.vue'
import AttachVolumeModal from '@/components/instance/AttachVolumeModal.vue'
import ConsoleConnModal from '@/components/instance/ConsoleConnModal.vue'
import EditBootVolumeModal from '@/components/instance/EditBootVolumeModal.vue'
import InstanceTrafficPanel from '@/components/instance/InstanceTrafficPanel.vue'
import ReplaceBootVolumeModal from '@/components/instance/ReplaceBootVolumeModal.vue'
import ResizeModal from '@/components/instance/ResizeModal.vue'
import VnicPanel from '@/components/instance/VnicPanel.vue'
import { useConsoleStore } from '@/stores/console'
import ConfirmPop from '@/components/ConfirmPop.vue'
import LifecycleBadge from '@/components/LifecycleBadge.vue'
import OcidText from '@/components/OcidText.vue'
import RenameModal from '@/components/RenameModal.vue'
import { fmtTime, shortAd, truncOcid, vpusLabel } from '@/composables/useFormat'
import { useRegionAlias } from '@/composables/useRegionAlias'
import { useAsync } from '@/composables/useAsync'
import {
ATTACHMENT_TRANSIENT_STATES,
INSTANCE_TRANSIENT_STATES,
useTransientPoll,
} from '@/composables/useTransientPoll'
import type { BootVolume, ConsoleConnection, PowerAction, VolumeAttachment } from '@/types/api'
import { useToast } from '@/composables/useToast'
const route = useRoute()
const router = useRouter()
const message = useToast()
const dialog = useDialog()
const { alias: regionAlias } = useRegionAlias()
const cfgId = computed(() => Number(route.params.cfgId))
const instanceId = computed(() => String(route.params.instanceId))
/** 实例所在区域:列表页跳转时经 query 传入,缺失时回退实例对象上的短名(后端已归一化) */
const region = computed(
() => (route.query.region as string) || instance.data.value?.region || undefined,
)
const config = useAsync(() => getConfig(cfgId.value))
const instance = useAsync(() =>
getInstance(cfgId.value, instanceId.value, (route.query.region as string) || undefined),
)
const bvAtts = useAsync(() =>
listBootVolumeAttachments(cfgId.value, instanceId.value, region.value),
)
const volAtts = useAsync(() => listVolumeAttachments(cfgId.value, instanceId.value, region.value))
const consoles = useAsync(() =>
listConsoleConnections(cfgId.value, instanceId.value, region.value),
)
/** 按 imageId 补镜像名称,查询失败时回退 OCID 展示 */
const image = useAsync(
() => {
const inst = instance.data.value
if (!inst?.imageId) return Promise.resolve(null)
return getImage(cfgId.value, inst.imageId, inst.region).catch(() => null)
},
false,
)
watch(
() => instance.data.value?.imageId,
(v) => {
if (v) void image.run()
},
)
/** 引导卷挂载补齐卷详情(名称 / 大小 / VPU),支持就地调整 */
const bvDetails = useAsync<BootVolume[]>(async () => {
const atts = bvAtts.data.value ?? []
const list = await Promise.all(
atts.map((a) => getBootVolume(cfgId.value, a.bootVolumeId, region.value).catch(() => null)),
)
return list.filter((b): b is BootVolume => b !== null)
}, false)
watch(
() => bvAtts.data.value,
() => void bvDetails.run(),
)
const bvById = computed(() => new Map((bvDetails.data.value ?? []).map((b) => [b.id, b])))
// 电源 / 挂载操作提交后 OCI 状态异步迁移:过渡态期间每 5 秒静默刷新直到稳态
useTransientPoll(
() => instance.data.value,
(v) => !!v && INSTANCE_TRANSIENT_STATES.includes(v.lifecycleState),
() => void instance.run({ silent: true }),
)
useTransientPoll(
() => bvAtts.data.value,
(list) => !!list?.some((a) => ATTACHMENT_TRANSIENT_STATES.includes(a.lifecycleState)),
() => void bvAtts.run({ silent: true }),
)
useTransientPoll(
() => volAtts.data.value,
(list) => !!list?.some((a) => ATTACHMENT_TRANSIENT_STATES.includes(a.lifecycleState)),
() => void volAtts.run({ silent: true }),
)
useTransientPoll(
() => consoles.data.value,
(list) => !!list?.some((c) => ['CREATING', 'DELETING'].includes(c.lifecycleState)),
() => void consoles.run({ silent: true }),
)
const isStopped = computed(() => instance.data.value?.lifecycleState === 'STOPPED')
/** 终止 / 终止中:一切变更类操作不可用 */
const isEnded = computed(() =>
['TERMINATED', 'TERMINATING'].includes(instance.data.value?.lifecycleState ?? ''),
)
// 区块数据首次加载中禁用对应头部按钮(loading && !data:静默轮询刷新不闪禁用态)
const bvLoading = computed(
() => (bvAtts.loading.value || bvDetails.loading.value) && !bvDetails.data.value,
)
const volLoading = computed(() => volAtts.loading.value && !volAtts.data.value)
const consolesLoading = computed(() => consoles.loading.value && !consoles.data.value)
const attachedVolumeIds = computed(() => (volAtts.data.value ?? []).map((a) => a.volumeId))
/** 创建时若选了随机密码,密码写在 TAG RootPassword 中,这里回显 */
const rootPassword = computed(() => instance.data.value?.freeformTags?.RootPassword ?? '')
// 密码默认掩码(S-01),点击查看后本页会话内保持可见
const pwdRevealed = ref(false)
function revealRootPassword() {
pwdRevealed.value = true
}
async function copyRootPassword() {
await navigator.clipboard.writeText(rootPassword.value)
message.success('已复制 ROOT 密码')
}
async function copyImageOcid() {
await navigator.clipboard.writeText(instance.data.value?.imageId ?? '')
message.success('已复制镜像 OCID')
}
const showRename = ref(false)
const renaming = ref(false)
const showResize = ref(false)
const showConsole = ref(false)
const showReplaceBv = ref(false)
const showAttachVol = ref(false)
const showEditBv = ref(false)
const editingBv = ref<BootVolume | null>(null)
function openEditBv(bv: BootVolume) {
editingBv.value = bv
showEditBv.value = true
}
const consoleStore = useConsoleStore()
/** VNC 与串行共用实例唯一的控制台连接资源(创建时互相清理),同时只开一个 */
function openVnc() {
consoleStore.close()
const href = router.resolve({
name: 'vnc-console',
params: { cfgId: cfgId.value, instanceId: instanceId.value },
query: { region: region.value, name: instance.data.value?.displayName },
}).href
window.open(href, '_blank')
}
function openSerial() {
void consoleStore.openSerial({
cfgId: cfgId.value,
instanceId: instanceId.value,
region: region.value,
instanceName: instance.data.value?.displayName,
})
}
async function doRename(name: string) {
renaming.value = true
try {
await updateInstance(cfgId.value, instanceId.value, { displayName: name }, region.value)
message.success('已重命名')
showRename.value = false
void instance.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '重命名失败')
} finally {
renaming.value = false
}
}
async function act(fn: () => Promise<unknown>, ok: string) {
try {
await fn()
message.success(ok)
void instance.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败')
}
}
function power(action: PowerAction) {
void act(
() => instanceAction(cfgId.value, instanceId.value, action, region.value),
`${action} 已提交`,
)
}
const moreOptions = [
{ label: 'SOFTSTOP(系统内关机)', key: 'SOFTSTOP' },
{ label: 'SOFTRESET(系统内重启)', key: 'SOFTRESET' },
]
function terminate() {
dialog.warning({
title: '终止实例',
content: '终止后实例不可恢复。是否同时保留引导卷?',
positiveText: '终止并删除引导卷',
negativeText: '终止但保留引导卷',
onPositiveClick: () =>
act(
() => terminateInstance(cfgId.value, instanceId.value, false, region.value),
'终止请求已提交',
),
onNegativeClick: () =>
act(
() => terminateInstance(cfgId.value, instanceId.value, true, region.value),
'终止请求已提交(保留引导卷)',
),
})
}
const volColumns: DataTableColumns<VolumeAttachment> = [
{
title: '块卷',
key: 'volumeName',
minWidth: 170,
render: (r) =>
h('div', { class: 'min-w-0' }, [
h(
'div',
{ class: 'font-medium' },
r.volumeName || r.displayName || truncOcid(r.volumeId, 18),
),
r.volumeId.includes('.bootvolume.')
? h('div', { class: 'text-xs text-ink-3 mt-px' }, '引导卷 · 作数据卷附加')
: null,
]),
},
{
title: '设备路径',
key: 'device',
minWidth: 200,
render: (r) => h('span', { class: 'mono' }, r.device || '—'),
},
{
title: '读写',
key: 'isReadOnly',
width: 80,
render: (r) => h('span', { class: 'text-[13px]' }, r.isReadOnly ? '只读' : '读写'),
},
{
title: '状态',
key: 'lifecycleState',
width: 120,
render: (r) => h(LifecycleBadge, { state: r.lifecycleState }),
},
{
title: '操作',
key: 'actions',
width: 90,
render: (r) =>
h(
ConfirmPop,
{
title: '分离该块卷?',
content: 'OS 内请先卸载文件系统。',
confirmText: '分离',
onConfirm: () => act(() => detachVolume(cfgId.value, r.id, region.value), '分离请求已提交'),
},
{
trigger: () => h(NButton, { size: 'tiny', quaternary: true }, { default: () => '分离' }),
},
),
},
]
/** DELETED 是 OCI 保留的终态记录,不可操作也无展示价值,直接过滤 */
const liveConsoles = computed(() =>
(consoles.data.value ?? []).filter((c) => c.lifecycleState !== 'DELETED'),
)
const deletingConn = ref('')
async function doDeleteConsoleConn(id: string) {
deletingConn.value = id
try {
await deleteConsoleConnection(cfgId.value, id, region.value)
message.success('删除请求已提交')
void consoles.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败')
} finally {
deletingConn.value = ''
}
}
/** 连接串行:类型标签 + 截断命令 + 行内复制,避免整串撑爆表格 */
function connStringRow(label: string, value: string): ReturnType<typeof h> {
return h('div', { class: 'flex min-w-0 max-w-[640px] items-center gap-1.5' }, [
h('span', { class: 'shrink-0 rounded bg-wash px-1.5 text-[11px] leading-5 text-ink-2' }, label),
h('code', { class: 'mono min-w-0 flex-1 truncate text-xs', title: value }, value),
h(
NButton,
{
size: 'tiny',
quaternary: true,
onClick: async () => {
await navigator.clipboard.writeText(value)
message.success(`已复制${label}连接串`)
},
},
{ default: () => '复制' },
),
])
}
const consoleColumns: DataTableColumns<ConsoleConnection> = [
{
title: '状态',
key: 'lifecycleState',
width: 110,
render: (r) => h(LifecycleBadge, { state: r.lifecycleState }),
},
{
title: '连接串',
key: 'connectionString',
minWidth: 320,
render: (r) =>
h('div', { class: 'flex min-w-0 flex-col gap-0.5' }, [
connStringRow('串口', r.connectionString),
connStringRow('VNC', r.vncConnectionString),
]),
},
{
title: '操作',
key: 'actions',
width: 90,
render: (r) =>
h(
ConfirmPop,
{
title: '删除控制台连接?',
confirmText: '删除',
onConfirm: () => doDeleteConsoleConn(r.id),
},
{
trigger: () =>
h(
NButton,
{
size: 'tiny',
quaternary: true,
type: 'error',
loading: deletingConn.value === r.id,
disabled: deletingConn.value !== '' && deletingConn.value !== r.id,
},
{ default: () => '删除' },
),
},
),
},
]
</script>
<template>
<div class="flex flex-col gap-4 p-6 pb-8 max-md:p-3">
<div
v-if="instance.loading.value && !instance.data.value"
class="panel flex items-center justify-center py-20"
>
<NSpin size="small" />
</div>
<div v-else-if="instance.error.value" class="panel">
<EmptyCard title="实例加载失败" :note="instance.error.value" />
</div>
<div v-if="instance.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: 'instances' }"
>
返回实例列表
</RouterLink>
<div class="flex flex-wrap items-center gap-3">
<h1 class="page-title">{{ instance.data.value.displayName }}</h1>
<LifecycleBadge :state="instance.data.value.lifecycleState" />
<div class="flex-1" />
<NButton v-if="isStopped" size="small" :disabled="isEnded" @click="power('START')">
启动
</NButton>
<NButton v-else size="small" :disabled="isEnded" @click="power('STOP')">停止</NButton>
<NButton size="small" :disabled="isEnded" @click="power('RESET')">重启</NButton>
<NButton size="small" type="error" ghost :disabled="isEnded" @click="terminate">
终止
</NButton>
<NDropdown
:options="moreOptions"
:disabled="isEnded"
@select="(key: string) => power(key as PowerAction)"
>
<NButton size="small" quaternary :disabled="isEnded"></NButton>
</NDropdown>
</div>
<div class="mt-3 grid grid-cols-5 gap-4 max-lg:grid-cols-3 max-md:grid-cols-1">
<div class="min-w-0">
<div class="text-xs text-ink-3">租户</div>
<div class="mt-0.5 truncate text-[13px]" :title="config.data.value?.tenancyName">
{{ config.data.value?.tenancyName || config.data.value?.alias || '…' }}
</div>
</div>
<div class="min-w-0">
<div class="text-xs text-ink-3">区域 / AD</div>
<div class="mt-0.5 truncate text-[13px]">
{{ regionAlias(instance.data.value.region) }} ·
{{ shortAd(instance.data.value.availabilityDomain) }}
</div>
</div>
<div class="min-w-0">
<div class="text-xs text-ink-3">Shape</div>
<div class="mono mt-0.5 truncate text-[13px]">{{ instance.data.value.shape }}</div>
</div>
<div class="min-w-0">
<div class="text-xs text-ink-3">规格</div>
<div class="mt-0.5 text-[13px]">
{{ instance.data.value.ocpus }} OCPU · {{ instance.data.value.memoryInGBs }} GB
</div>
</div>
<div class="min-w-0">
<div class="text-xs text-ink-3">创建时间</div>
<div class="mt-0.5 text-[13px]">{{ fmtTime(instance.data.value.timeCreated) }}</div>
</div>
</div>
</div>
<div class="grid grid-cols-2 items-start gap-4 max-lg:grid-cols-1">
<!-- 基本信息 -->
<div v-if="instance.data.value" 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">基本信息</div>
<NButton size="tiny" quaternary :disabled="isEnded" @click="showRename = true">
重命名
</NButton>
</div>
<div class="flex flex-col px-4.5 py-1.5">
<div class="flex gap-3 border-b border-line-soft py-2.5 max-md:flex-col max-md:gap-0.5">
<div class="w-22 flex-none text-xs text-ink-3 max-md:w-auto">OCID</div>
<div class="min-w-0 flex-1"><OcidText :ocid="instance.data.value.id" /></div>
</div>
<div class="flex gap-3 border-b border-line-soft py-2.5 max-md:flex-col max-md:gap-0.5">
<div class="w-22 flex-none text-xs text-ink-3 max-md:w-auto">镜像</div>
<div class="flex min-w-0 flex-1 items-center gap-1.5">
<span class="truncate text-[13px]" :title="image.data.value?.displayName">
{{ image.data.value?.displayName ?? (image.loading.value ? '…' : '未知镜像') }}
</span>
<NButton size="tiny" quaternary type="primary" @click="copyImageOcid">复制</NButton>
</div>
</div>
<div
v-if="rootPassword"
class="flex gap-3 border-b border-line-soft py-2.5 max-md:flex-col max-md:gap-0.5"
>
<div class="w-22 flex-none text-xs text-ink-3 max-md:w-auto">ROOT 密码</div>
<div class="flex min-w-0 flex-1 items-center gap-1.5">
<template v-if="pwdRevealed">
<code class="mono truncate text-[13px]" :title="rootPassword">{{ rootPassword }}</code>
<NButton size="tiny" quaternary type="primary" @click="copyRootPassword">
复制
</NButton>
</template>
<template v-else>
<code class="mono text-[13px] tracking-widest select-none">••••••••</code>
<NButton
size="tiny"
quaternary
type="primary"
title="验证身份后查看"
@click="revealRootPassword"
>
<svg class="mr-1 h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
查看
</NButton>
</template>
</div>
</div>
<div class="flex gap-3 py-2.5 max-md:flex-col max-md:gap-0.5">
<div class="w-22 flex-none text-xs text-ink-3 max-md:w-auto">规格调整</div>
<div class="min-w-0 flex-1 text-[13px]">
{{ instance.data.value.ocpus }} OCPU · {{ instance.data.value.memoryInGBs }} GB
<NButton
size="tiny"
quaternary
type="primary"
:disabled="isEnded"
@click="showResize = true"
>
调整
</NButton>
<div class="mt-0.5 text-xs text-ink-3">
可更换 shape 或调整 Flex 规格 · 运行中调整会自动重启
</div>
</div>
</div>
</div>
</div>
<!-- 网络 -->
<div v-if="instance.data.value" class="panel">
<div class="border-b border-line-soft px-4.5 py-3.5">
<div class="text-sm font-semibold">网络</div>
<div class="mt-0.5 text-xs text-ink-3">
主网卡地址快捷视图全部网卡与各卡 IPv6 管理见下方网卡VNIC)」
</div>
</div>
<div class="flex flex-col px-4.5 py-1.5">
<div class="flex gap-3 border-b border-line-soft py-2.5 max-md:flex-col max-md:gap-0.5">
<div class="w-22 flex-none text-xs text-ink-3 max-md:w-auto">公网 IP</div>
<div class="min-w-0 flex-1">
<span class="mono">{{ instance.data.value.publicIp || '—' }}</span>
<div class="mt-0.5 text-xs text-ink-3">
IP 在下方网卡(VNIC)对应行操作
</div>
</div>
</div>
<div class="flex gap-3 border-b border-line-soft py-2.5 max-md:flex-col max-md:gap-0.5">
<div class="w-22 flex-none text-xs text-ink-3 max-md:w-auto">私有 IP</div>
<div class="mono min-w-0 flex-1">{{ instance.data.value.privateIp || '—' }}</div>
</div>
<div class="flex gap-3 border-b border-line-soft py-2.5 max-md:flex-col max-md:gap-0.5">
<div class="w-22 flex-none text-xs text-ink-3 max-md:w-auto">IPv6</div>
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
<span
v-for="addr in instance.data.value.ipv6Addresses"
:key="addr"
class="mono inline-flex max-w-full items-center rounded border border-line bg-white px-1.5 py-0.5 text-xs"
>
<span class="truncate">{{ addr }}</span>
</span>
<span v-if="!instance.data.value.ipv6Addresses?.length" class="text-[13px]"></span>
<div class="w-full text-xs text-ink-3">
主网卡 IPv6 · 添加 / 删除在下方网卡VNIC区块按卡操作
</div>
</div>
</div>
<div class="flex gap-3 py-2.5 max-md:flex-col max-md:gap-0.5">
<div class="w-22 flex-none text-xs text-ink-3 max-md:w-auto">子网</div>
<div class="min-w-0 flex-1"><OcidText :ocid="instance.data.value.subnetId" /></div>
</div>
</div>
</div>
</div>
<!-- 网卡(VNIC):每卡一主行 + IPv6 副行;变化后刷实例(主网卡快捷视图跟随) -->
<VnicPanel
v-if="instance.data.value"
:cfg-id="cfgId"
:instance-id="instanceId"
:region="region"
:disabled="isEnded"
@changed="instance.run({ silent: true })"
/>
<!-- 引导卷 -->
<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">引导卷</div>
<NButton
size="small"
:disabled="!isStopped || isEnded || bvLoading"
:title="isStopped ? '' : '替换引导卷须实例处于 STOPPED'"
@click="showReplaceBv = true"
>
替换引导卷
</NButton>
</div>
<div
v-if="(bvAtts.loading.value || bvDetails.loading.value) && !bvDetails.data.value"
class="flex items-center justify-center py-8"
>
<NSpin size="small" />
</div>
<div v-else class="px-4.5 py-1.5">
<div
v-for="att in bvAtts.data.value ?? []"
:key="att.id"
class="flex flex-wrap items-center gap-3 border-b border-line-soft py-2.5 last:border-b-0"
>
<div class="min-w-0">
<div class="font-medium text-ink">
{{ bvById.get(att.bootVolumeId)?.displayName ?? truncOcid(att.bootVolumeId, 26) }}
</div>
<div class="text-xs text-ink-3">
<template v-if="bvById.get(att.bootVolumeId)">
{{ bvById.get(att.bootVolumeId)!.sizeInGBs }} GB · VPU
{{ vpusLabel(bvById.get(att.bootVolumeId)!.vpusPerGB) }}
</template>
<span v-else class="mono">{{ truncOcid(att.bootVolumeId, 26) }}</span>
</div>
</div>
<LifecycleBadge :state="att.lifecycleState" />
<div class="flex-1" />
<NButton
v-if="bvById.get(att.bootVolumeId)"
size="tiny"
quaternary
type="primary"
:disabled="isEnded"
@click="openEditBv(bvById.get(att.bootVolumeId)!)"
>
调整大小 / VPU
</NButton>
<ConfirmPop
title="分离引导卷?"
content="实例将无法启动,直到挂载新引导卷。"
confirm-text="分离"
:on-confirm="() => act(() => detachBootVolume(cfgId, att.id, region), '分离请求已提交')"
>
<template #trigger>
<NButton size="tiny" quaternary :disabled="!isStopped || isEnded">分离</NButton>
</template>
</ConfirmPop>
</div>
<div
v-if="!(bvAtts.data.value ?? []).length"
class="py-6 text-center text-[13px] text-ink-3"
>
无挂载引导卷
</div>
</div>
<FootNote>
大小仅支持扩容(在线生效,OS 内需扩展文件系统)· 挂载 / 分离 / 替换要求实例处于 STOPPED
</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">块存储卷</div>
<NButton
size="small"
type="primary"
:disabled="isEnded || volLoading"
@click="showAttachVol = true"
>
附加块卷
</NButton>
</div>
<NDataTable
:columns="volColumns"
:data="volAtts.data.value ?? []"
:loading="volAtts.loading.value"
:scroll-x="700"
:row-key="(r: VolumeAttachment) => r.id"
/>
<FootNote>
半虚拟化附加,运行中实例可热插拔;附加后仍需在实例 OS 内分区挂载文件系统
</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">控制台连接(VNC / 串口)</div>
<div class="flex items-center gap-2">
<NButton
size="small"
type="primary"
:disabled="isEnded || consolesLoading"
@click="openSerial"
>
串行终端
</NButton>
<NButton size="small" :disabled="isEnded || consolesLoading" @click="openVnc">
网页 VNC
</NButton>
<NButton size="small" :disabled="isEnded || consolesLoading" @click="showConsole = true">
创建连接
</NButton>
</div>
</div>
<NDataTable
:columns="consoleColumns"
:data="liveConsoles"
:loading="consoles.loading.value"
:scroll-x="640"
:row-key="(r: ConsoleConnection) => r.id"
/>
<FootNote>
串行终端 / 网页 VNC 在浏览器内直连(创建会话时自动清理旧连接,二者同时只能开一个)·
「创建连接」用于本地 ssh / VNC 客户端:公钥仅支持 RSA,串口直接执行串口连接串,VNC
执行后客户端连 localhost:5900 · 串口登录需实例本地账户密码
</FootNote>
</div>
<!-- 流量 -->
<InstanceTrafficPanel
v-if="instance.data.value"
:cfg-id="cfgId"
:instance-id="instanceId"
:region="region"
/>
<RenameModal
v-model:show="showRename"
title="重命名实例"
:current="instance.data.value?.displayName ?? ''"
:submitting="renaming"
@submit="doRename"
/>
<ResizeModal
v-if="instance.data.value"
v-model:show="showResize"
:cfg-id="cfgId"
:instance-id="instanceId"
:region="region"
:shape="instance.data.value.shape"
:ocpus="instance.data.value.ocpus"
:memory-in-g-bs="instance.data.value.memoryInGBs"
@resized="instance.run()"
/>
<ConsoleConnModal
v-model:show="showConsole"
:cfg-id="cfgId"
:instance-id="instanceId"
:region="region"
@created="consoles.run()"
/>
<ReplaceBootVolumeModal
v-if="instance.data.value"
v-model:show="showReplaceBv"
:cfg-id="cfgId"
:instance-id="instanceId"
:region="region"
:availability-domain="instance.data.value.availabilityDomain"
@replaced="bvAtts.run()"
/>
<AttachVolumeModal
v-if="instance.data.value"
v-model:show="showAttachVol"
:cfg-id="cfgId"
:instance-id="instanceId"
:region="region"
:availability-domain="instance.data.value.availabilityDomain"
:attached-volume-ids="attachedVolumeIds"
@attached="volAtts.run()"
/>
<EditBootVolumeModal
v-model:show="showEditBv"
:cfg-id="cfgId"
:region="region"
:boot-volume="editingBv"
@updated="bvDetails.run()"
/>
</div>
</template>