879 lines
30 KiB
Vue
879 lines
30 KiB
Vue
<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,
|
||
terminatingInstances,
|
||
terminatingKey,
|
||
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 { useIsMobile } from '@/composables/useIsMobile'
|
||
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()
|
||
|
||
/** 移动端 hero 元信息默认收起省首屏 */
|
||
const isMobile = useIsMobile()
|
||
const heroExpanded = ref(false)
|
||
|
||
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 : '操作失败')
|
||
}
|
||
}
|
||
|
||
/** 电源操作在飞行中的动作,对应按钮 loading、整组防连点 */
|
||
const powering = ref<'' | PowerAction>('')
|
||
|
||
async function power(action: PowerAction) {
|
||
if (powering.value || terminating.value) return
|
||
powering.value = action
|
||
try {
|
||
await act(
|
||
() => instanceAction(cfgId.value, instanceId.value, action, region.value),
|
||
`${action} 已提交`,
|
||
)
|
||
} finally {
|
||
powering.value = ''
|
||
}
|
||
}
|
||
|
||
const moreOptions = [
|
||
{ label: 'SOFTSTOP(系统内关机)', key: 'SOFTSTOP' },
|
||
{ label: 'SOFTRESET(系统内重启)', key: 'SOFTRESET' },
|
||
]
|
||
|
||
/** 终止飞行中(api 层共享锁):跨 Dialog/视图/重挂载互斥 */
|
||
const terminating = computed(() =>
|
||
terminatingInstances.has(terminatingKey(cfgId.value, instanceId.value)),
|
||
)
|
||
|
||
/** 首次点击即锁定两个互斥分支,防慢请求期间「删除/保留引导卷」并发提交 */
|
||
function terminate() {
|
||
if (terminating.value) {
|
||
message.warning('终止请求正在处理中')
|
||
return
|
||
}
|
||
let fired = false
|
||
const submit = (preserve: boolean) => {
|
||
if (fired || terminating.value) return false
|
||
fired = true
|
||
d.positiveButtonProps = { disabled: true }
|
||
d.negativeButtonProps = { disabled: true }
|
||
return act(
|
||
() => terminateInstance(cfgId.value, instanceId.value, preserve, region.value),
|
||
preserve ? '终止请求已提交(保留引导卷)' : '终止请求已提交',
|
||
)
|
||
}
|
||
const d = dialog.warning({
|
||
title: '终止实例',
|
||
content: '终止后实例不可恢复。是否同时保留引导卷?',
|
||
positiveText: '终止并删除引导卷',
|
||
negativeText: '终止但保留引导卷',
|
||
onPositiveClick: () => submit(false),
|
||
onNegativeClick: () => submit(true),
|
||
})
|
||
}
|
||
|
||
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" />
|
||
<!-- 操作按钮组:移动端整组独立成行,不随标题换行散落 -->
|
||
<div class="flex items-center gap-2 max-md:w-full">
|
||
<NButton
|
||
v-if="isStopped"
|
||
size="small"
|
||
:loading="powering === 'START'"
|
||
:disabled="isEnded || !!powering || terminating"
|
||
@click="power('START')"
|
||
>
|
||
启动
|
||
</NButton>
|
||
<ConfirmPop
|
||
v-else
|
||
title="停止实例?"
|
||
content="实例将关机,其上服务与连接中断。"
|
||
kind="warn"
|
||
confirm-text="停止"
|
||
:on-confirm="() => power('STOP')"
|
||
>
|
||
<template #trigger>
|
||
<NButton size="small" :loading="powering === 'STOP'" :disabled="isEnded || !!powering || terminating">
|
||
停止
|
||
</NButton>
|
||
</template>
|
||
</ConfirmPop>
|
||
<ConfirmPop
|
||
title="重启实例?"
|
||
content="实例将立即重启,期间服务中断。"
|
||
kind="warn"
|
||
confirm-text="重启"
|
||
:on-confirm="() => power('RESET')"
|
||
>
|
||
<template #trigger>
|
||
<NButton size="small" :loading="powering === 'RESET'" :disabled="isEnded || !!powering || terminating">重启</NButton>
|
||
</template>
|
||
</ConfirmPop>
|
||
<NButton
|
||
size="small"
|
||
type="error"
|
||
ghost
|
||
:loading="terminating"
|
||
:disabled="isEnded || !!powering || terminating"
|
||
@click="terminate"
|
||
>
|
||
终止
|
||
</NButton>
|
||
<NDropdown
|
||
:options="moreOptions"
|
||
:disabled="isEnded || !!powering || terminating"
|
||
@select="(key: string) => power(key as PowerAction)"
|
||
>
|
||
<NButton size="small" quaternary :disabled="isEnded || !!powering || terminating">⋯</NButton>
|
||
</NDropdown>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
v-if="isMobile"
|
||
class="mt-2 flex cursor-pointer items-center gap-1 text-xs text-ink-3"
|
||
@click="heroExpanded = !heroExpanded"
|
||
>
|
||
{{ heroExpanded ? '收起详情' : '展开详情' }}
|
||
<svg
|
||
class="h-3 w-3 transition-transform"
|
||
:class="heroExpanded ? 'rotate-180' : ''"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<path d="m6 9 6 6 6-6" />
|
||
</svg>
|
||
</button>
|
||
<div
|
||
v-if="!isMobile || heroExpanded"
|
||
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 flex-wrap items-center justify-between gap-y-2 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">
|
||
主网卡地址快捷视图
|
||
</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 flex-wrap items-center justify-between gap-y-2 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 flex-wrap items-center justify-between gap-y-2 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 flex-wrap items-center justify-between gap-y-2 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)
|
||
</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>
|