a5f8a9ef13
Co-authored-by: Cursor <cursoragent@cursor.com>
1503 lines
73 KiB
Vue
1503 lines
73 KiB
Vue
<template>
|
||
<div class="host-detail-page">
|
||
<!-- 顶部返回栏 -->
|
||
<div class="page-header">
|
||
<div class="header-left">
|
||
<el-button @click="goBack" link class="back-btn"><el-icon><ArrowLeft /></el-icon> 返回宿主机列表</el-button>
|
||
</div>
|
||
<div class="header-right">
|
||
<el-button plain :icon="Refresh" @click="loadDetail" :loading="loading">刷新</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="main-content" v-loading="loading">
|
||
<!-- 实例概览栏 -->
|
||
<div class="instance-overview" v-if="detail">
|
||
<div class="overview-left">
|
||
<h2 class="instance-name">{{ detail.name || '-' }} <span class="instance-id">{{ detail.id }}</span></h2>
|
||
</div>
|
||
<div class="overview-actions">
|
||
<!-- <el-button type="warning" plain @click="openTokenDialog"><el-icon><Key /></el-icon>创建注册令牌</el-button> -->
|
||
<el-button type="primary" plain @click="handleEdit">编辑宿主机</el-button>
|
||
<el-button type="danger" plain @click="handleDelete">删除</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 状态概览条 -->
|
||
<div class="status-bar" v-if="detail">
|
||
<div class="status-item">
|
||
<span class="status-label">状态</span>
|
||
<span class="status-value">
|
||
<span class="status-dot" :class="detail.is_active ? 'dot-running' : 'dot-other'"></span>
|
||
{{ detail.is_active ? '启用' : '禁用' }}
|
||
</span>
|
||
</div>
|
||
<div class="status-item">
|
||
<span class="status-label">IP 地址</span>
|
||
<span class="status-value">{{ detail.ip || '-' }}</span>
|
||
</div>
|
||
<div class="status-item">
|
||
<span class="status-label">CPU</span>
|
||
<span class="status-value">{{ detail.max_cpu || 0 }}核</span>
|
||
</div>
|
||
<div class="status-item">
|
||
<span class="status-label">内存</span>
|
||
<span class="status-value">{{ formatMemKB(detail.max_memory) }}</span>
|
||
</div>
|
||
<div class="status-item">
|
||
<span class="status-label">磁盘</span>
|
||
<span class="status-value">{{ formatDiskGB(detail.max_disk) }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="status-bar" v-if="detail">
|
||
<div class="status-item">
|
||
<span class="status-label">带宽</span>
|
||
<span class="status-value">↓{{ detail.rx_bandwidth || 0 }} / ↑{{ detail.tx_bandwidth || 0 }} Mbps</span>
|
||
</div>
|
||
<div class="status-item">
|
||
<span class="status-label">创建时间</span>
|
||
<span class="status-value">{{ formatTimestamp(detail.created_at) }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 标签页 -->
|
||
<el-tabs v-model="activeTab" class="detail-tabs" v-if="detail">
|
||
<el-tab-pane label="基本信息" name="info">
|
||
<div class="section-block">
|
||
<h3 class="section-title">配置信息</h3>
|
||
<div class="config-grid">
|
||
<div class="config-row">
|
||
<div class="config-cell">
|
||
<span class="config-label">服务地址</span>
|
||
<span class="config-value mono-text">{{ detail.base_url || '-' }}</span>
|
||
</div>
|
||
<div class="config-cell">
|
||
<span class="config-label">SSH 端口</span>
|
||
<span class="config-value">{{ detail.port || '-' }}</span>
|
||
</div>
|
||
<div class="config-cell">
|
||
<span class="config-label">SSH 用户</span>
|
||
<span class="config-value">{{ detail.user || '-' }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="config-row">
|
||
<div class="config-cell">
|
||
<span class="config-label">认证Token</span>
|
||
<span class="config-value secret-cell">
|
||
<template v-if="detail.token">
|
||
<code>{{ showToken ? detail.token : '••••••••••••' }}</code>
|
||
<el-button link type="primary" size="small" @click="showToken = !showToken">{{ showToken ? '隐藏' : '显示' }}</el-button>
|
||
<el-button v-if="showToken" link type="primary" size="small" @click="copyText(detail.token)">复制</el-button>
|
||
</template>
|
||
<span v-else class="text-muted">未设置</span>
|
||
</span>
|
||
</div>
|
||
<div class="config-cell">
|
||
<span class="config-label">SSH 密码</span>
|
||
<span class="config-value secret-cell">
|
||
<template v-if="detail.password">
|
||
<code>{{ showPassword ? detail.password : '••••••••' }}</code>
|
||
<el-button link type="primary" size="small" @click="showPassword = !showPassword">{{ showPassword ? '隐藏' : '显示' }}</el-button>
|
||
<el-button v-if="showPassword" link type="primary" size="small" @click="copyText(detail.password)">复制</el-button>
|
||
</template>
|
||
<span v-else class="text-muted">未设置</span>
|
||
</span>
|
||
</div>
|
||
<div class="config-cell">
|
||
<span class="config-label">私钥</span>
|
||
<span class="config-value secret-cell">
|
||
<template v-if="detail.private_key">
|
||
<code>{{ showPrivateKey ? detail.private_key.substring(0, 40) + '...' : '••••••••••••' }}</code>
|
||
<el-button link type="primary" size="small" @click="showPrivateKey = !showPrivateKey">{{ showPrivateKey ? '隐藏' : '显示' }}</el-button>
|
||
<el-button v-if="showPrivateKey" link type="primary" size="small" @click="copyText(detail.private_key)">复制</el-button>
|
||
</template>
|
||
<span v-else class="text-muted">未设置</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="config-row">
|
||
<div class="config-cell">
|
||
<span class="config-label">宿主机组</span>
|
||
<span class="config-value">{{ detail.host_group_id ? `#${detail.host_group_id}` : '-' }}</span>
|
||
</div>
|
||
<div class="config-cell">
|
||
<span class="config-label">介绍</span>
|
||
<span class="config-value">{{ detail.description || '-' }}</span>
|
||
</div>
|
||
<div class="config-cell">
|
||
<span class="config-label">更新时间</span>
|
||
<span class="config-value">{{ formatTimestamp(detail.updated_at) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="section-block">
|
||
<h3 class="section-title clickable" @click="showDetailDiskIo = !showDetailDiskIo">
|
||
硬盘 IO 限制
|
||
<el-icon class="section-arrow" :class="{ expanded: showDetailDiskIo }"><ArrowRight /></el-icon>
|
||
</h3>
|
||
<div v-show="showDetailDiskIo" class="config-grid">
|
||
<div class="config-row">
|
||
<div class="config-cell" v-for="f in diskIoBwFields" :key="f.key">
|
||
<span class="config-label">{{ f.label }}带宽</span>
|
||
<span class="config-value mono-text">{{ formatDiskIoVal(detail[f.key], true) }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="config-row">
|
||
<div class="config-cell" v-for="f in diskIoIopsFields" :key="f.key">
|
||
<span class="config-label">{{ f.label }} IOPS</span>
|
||
<span class="config-value mono-text">{{ formatDiskIoVal(detail[f.key], false) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<el-tab-pane label="额度统计" name="quotaStats">
|
||
<div class="section-block">
|
||
<div class="section-header">
|
||
<h3 class="section-title">资源额度统计</h3>
|
||
<el-button size="small" :icon="Refresh" @click="loadQuotaStats" :loading="quotaStatsLoading">刷新</el-button>
|
||
</div>
|
||
<template v-if="quotaStats">
|
||
<el-descriptions :column="3" border size="small" style="margin-top:12px">
|
||
<el-descriptions-item label="虚拟机数量">{{ quotaStats.vm_count ?? '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="规划 CPU">{{ quotaStats.planned_cpu ?? '-' }} 核</el-descriptions-item>
|
||
<el-descriptions-item label="已分配 CPU">{{ quotaStats.allocated_cpu ?? '-' }} 核</el-descriptions-item>
|
||
<el-descriptions-item label="规划内存">{{ formatQuotaMem(quotaStats.planned_memory) }}</el-descriptions-item>
|
||
<el-descriptions-item label="已分配内存">{{ formatQuotaMem(quotaStats.allocated_memory) }}</el-descriptions-item>
|
||
<el-descriptions-item label="实时内存">{{ formatQuotaBytes(quotaStats.actual_memory_used) }} / {{ formatQuotaBytes(quotaStats.actual_memory_total) }}</el-descriptions-item>
|
||
<el-descriptions-item label="规划磁盘">{{ quotaStats.planned_disk ?? '-' }} GB</el-descriptions-item>
|
||
<el-descriptions-item label="已分配磁盘">{{ quotaStats.allocated_disk ?? '-' }} GB</el-descriptions-item>
|
||
<el-descriptions-item label="实时 CPU">{{ quotaStats.actual_cpu_percent != null ? quotaStats.actual_cpu_percent.toFixed(1) + '%' : '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="规划下行带宽">{{ quotaStats.planned_rx_bandwidth ?? '-' }} Mbps</el-descriptions-item>
|
||
<el-descriptions-item label="已分配下行带宽">{{ quotaStats.allocated_rx_bandwidth ?? '-' }} Mbps</el-descriptions-item>
|
||
<el-descriptions-item label="规划上行带宽">{{ quotaStats.planned_tx_bandwidth ?? '-' }} Mbps</el-descriptions-item>
|
||
<el-descriptions-item label="已分配上行带宽">{{ quotaStats.allocated_tx_bandwidth ?? '-' }} Mbps</el-descriptions-item>
|
||
</el-descriptions>
|
||
<template v-if="quotaStatsDisk.length">
|
||
<h4 style="margin:16px 0 8px;font-size:13px;color:#606266">磁盘使用</h4>
|
||
<el-table :data="quotaStatsDisk" size="small" stripe>
|
||
<el-table-column prop="path" label="路径" min-width="160" />
|
||
<el-table-column label="总量" width="100"><template #default="{row}">{{ formatQuotaBytes(row.total) }}</template></el-table-column>
|
||
<el-table-column label="已用" width="100"><template #default="{row}">{{ formatQuotaBytes(row.used) }}</template></el-table-column>
|
||
<el-table-column label="使用率" width="100"><template #default="{row}">{{ row.total ? ((row.used / row.total) * 100).toFixed(1) + '%' : '-' }}</template></el-table-column>
|
||
</el-table>
|
||
</template>
|
||
</template>
|
||
<el-empty v-else-if="!quotaStatsLoading" description="暂无额度统计数据" :image-size="60" />
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<el-tab-pane label="监控" name="monitor">
|
||
<div class="section-block">
|
||
<div class="section-header">
|
||
<h3 class="section-title">监控指标</h3>
|
||
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap">
|
||
<el-date-picker
|
||
v-model="monitorDateRange"
|
||
type="datetimerange"
|
||
range-separator="至"
|
||
start-placeholder="开始时间"
|
||
end-placeholder="结束时间"
|
||
size="small"
|
||
style="width: 360px"
|
||
:shortcuts="monitorShortcuts"
|
||
@change="loadHistoricalMetrics"
|
||
/>
|
||
<span style="font-size:12px;color:#909399;white-space:nowrap">粒度: {{ currentIntervalLabel }}</span>
|
||
<el-button size="small" :icon="Refresh" @click="loadHistoricalMetrics" :loading="historicalMetricsLoading">刷新</el-button>
|
||
</div>
|
||
</div>
|
||
<template v-if="latestMetrics">
|
||
<div class="metric-summary-row">
|
||
<div class="metric-summary-card">
|
||
<div class="metric-summary-label">CPU 使用率</div>
|
||
<div class="metric-summary-value">{{ latestMetrics.cpu_usage?.toFixed(1) }}%</div>
|
||
<div class="metric-summary-sub">{{ latestMetrics.cpu_count }} 核</div>
|
||
</div>
|
||
<div class="metric-summary-card">
|
||
<div class="metric-summary-label">内存使用率</div>
|
||
<div class="metric-summary-value">{{ latestMetrics.mem_percent?.toFixed(1) }}%</div>
|
||
<div class="metric-summary-sub">{{ formatBytesRaw(latestMetrics.mem_used) }} / {{ formatBytesRaw(latestMetrics.mem_total) }}</div>
|
||
</div>
|
||
<div class="metric-summary-card">
|
||
<div class="metric-summary-label">公网流量</div>
|
||
<div class="metric-summary-value">↓{{ formatNetLabel(latestMetrics.inet_rx) }}</div>
|
||
<div class="metric-summary-sub">↑{{ formatNetLabel(latestMetrics.inet_tx) }}</div>
|
||
</div>
|
||
<div class="metric-summary-card">
|
||
<div class="metric-summary-label">内网流量</div>
|
||
<div class="metric-summary-value">↓{{ formatBytesRaw(latestMetrics.net_rx) }}</div>
|
||
<div class="metric-summary-sub">↑{{ formatBytesRaw(latestMetrics.net_tx) }}</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<template v-if="historicalMetricsData">
|
||
<el-row :gutter="16">
|
||
<el-col :span="12">
|
||
<el-card shadow="hover" class="metrics-card">
|
||
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> CPU 使用率</span></template>
|
||
<div ref="cpuChartRef" class="chart-container"></div>
|
||
</el-card>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-card shadow="hover" class="metrics-card">
|
||
<template #header><span class="metrics-title"><el-icon><Coin /></el-icon> 内存使用率</span></template>
|
||
<div ref="memChartRef" class="chart-container"></div>
|
||
</el-card>
|
||
</el-col>
|
||
</el-row>
|
||
<el-row :gutter="16" style="margin-top: 16px">
|
||
<el-col :span="12">
|
||
<el-card shadow="hover" class="metrics-card">
|
||
<template #header><span class="metrics-title"><el-icon><Connection /></el-icon> 公网流量</span></template>
|
||
<div ref="inetChartRef" class="chart-container"></div>
|
||
</el-card>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-card shadow="hover" class="metrics-card">
|
||
<template #header><span class="metrics-title"><el-icon><Connection /></el-icon> 内网流量</span></template>
|
||
<div ref="netChartRef" class="chart-container"></div>
|
||
</el-card>
|
||
</el-col>
|
||
</el-row>
|
||
</template>
|
||
<el-empty v-else-if="!historicalMetricsLoading" description="加载监控数据中..." :image-size="80" />
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<el-tab-pane label="镜像管理" name="image">
|
||
<ImageManage v-if="hostTabLoaded['image']" ref="imageManageRef" />
|
||
</el-tab-pane>
|
||
<el-tab-pane label="网络管理" name="network">
|
||
<NetworkManage v-if="hostTabLoaded['network']" ref="networkManageRef" />
|
||
</el-tab-pane>
|
||
<el-tab-pane label="数据卷管理" name="volume">
|
||
<VolumeManage v-if="hostTabLoaded['volume']" ref="volumeManageRef" />
|
||
</el-tab-pane>
|
||
<el-tab-pane label="虚拟机管理" name="vm">
|
||
<VmManage v-if="hostTabLoaded['vm']" ref="vmManageRef" />
|
||
</el-tab-pane>
|
||
<el-tab-pane label="快照管理" name="snapshot">
|
||
<SnapshotManage v-if="hostTabLoaded['snapshot']" ref="snapshotManageRef" />
|
||
</el-tab-pane>
|
||
<el-tab-pane label="备份管理" name="backup">
|
||
<BackupManage v-if="hostTabLoaded['backup']" ref="backupManageRef" />
|
||
</el-tab-pane>
|
||
|
||
<el-tab-pane label="组网管理" name="networking">
|
||
<div class="section-block">
|
||
<div class="section-header">
|
||
<h3 class="section-title">用户组网列表</h3>
|
||
<div style="display: flex; gap: 8px; align-items: center">
|
||
<el-input v-model="nwFilterUserId" placeholder="按用户ID筛选" style="width: 140px" size="small" clearable @clear="loadNetworkingList" @keyup.enter="loadNetworkingList" />
|
||
<el-input v-model="nwKeyword" placeholder="关键词搜索" style="width: 160px" size="small" clearable @clear="loadNetworkingList" @keyup.enter="loadNetworkingList" />
|
||
<el-button size="small" :icon="Search" @click="loadNetworkingList">搜索</el-button>
|
||
<el-button size="small" type="primary" @click="handleNwCreate">创建组网</el-button>
|
||
<el-button size="small" :icon="Refresh" @click="loadNetworkingList" :loading="nwLoading">刷新</el-button>
|
||
</div>
|
||
</div>
|
||
<el-table :data="nwList" v-loading="nwLoading" stripe size="small">
|
||
<el-table-column prop="id" label="ID" width="60" />
|
||
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
|
||
<el-table-column prop="user_id" label="用户ID" width="80" />
|
||
<el-table-column prop="host_id" label="宿主机ID" width="90" />
|
||
<el-table-column label="网桥" width="120">
|
||
<template #default="{ row }">{{ row.bridge_name || '-' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="网关" min-width="140">
|
||
<template #default="{ row }"><span class="mono-text">{{ row.gateway || '-' }}</span></template>
|
||
</el-table-column>
|
||
<el-table-column label="创建时间" width="170">
|
||
<template #default="{ row }">{{ formatTimestamp(row.created_at) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="200" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button link type="primary" size="small" @click="handleNwDetail(row)">详情</el-button>
|
||
<el-button link type="success" size="small" @click="handleNwAssign(row)">分配IP</el-button>
|
||
<el-button link type="danger" size="small" @click="handleNwDelete(row)">删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<el-empty v-if="!nwList.length && !nwLoading" description="暂无组网" :image-size="60" />
|
||
<div class="pagination-wrapper" v-if="nwTotal > 0">
|
||
<el-pagination v-model:current-page="nwPage" v-model:page-size="nwPageSize"
|
||
:page-sizes="[10, 20, 50]" :total="nwTotal" layout="total, sizes, prev, pager, next" small
|
||
@size-change="s => { nwPageSize = s; nwPage = 1; loadNetworkingList() }"
|
||
@current-change="p => { nwPage = p; loadNetworkingList() }" />
|
||
</div>
|
||
</div>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
|
||
<!-- 组网详情弹窗 -->
|
||
<el-dialog v-model="nwDetailVisible" title="组网详情" width="800px" destroy-on-close>
|
||
<el-descriptions :column="2" border v-if="nwDetailData" style="margin-bottom: 16px">
|
||
<el-descriptions-item label="ID">{{ nwDetailData.id }}</el-descriptions-item>
|
||
<el-descriptions-item label="名称">{{ nwDetailData.name }}</el-descriptions-item>
|
||
<el-descriptions-item label="用户ID">{{ nwDetailData.user_id }}</el-descriptions-item>
|
||
<el-descriptions-item label="宿主机ID">{{ nwDetailData.host_id }}</el-descriptions-item>
|
||
<el-descriptions-item label="网桥">{{ nwDetailData.bridge_name || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="网关">{{ nwDetailData.gateway || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="创建时间">{{ formatTimestamp(nwDetailData.created_at) }}</el-descriptions-item>
|
||
<el-descriptions-item label="更新时间">{{ formatTimestamp(nwDetailData.updated_at) }}</el-descriptions-item>
|
||
</el-descriptions>
|
||
<h4 style="margin: 0 0 12px">组网下的网络 (已分配)</h4>
|
||
<el-table :data="nwDetailNetworks" size="small" stripe>
|
||
<el-table-column label="网络ID" width="70">
|
||
<template #default="{ row }">{{ row.network?.id || '-' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="IP地址" min-width="150">
|
||
<template #default="{ row }"><span class="mono-text">{{ row.network?.address || '-' }}</span></template>
|
||
</el-table-column>
|
||
<el-table-column label="MAC地址" min-width="160">
|
||
<template #default="{ row }"><span class="mono-text">{{ row.network?.mac_address || '-' }}</span></template>
|
||
</el-table-column>
|
||
<el-table-column label="类型" width="80">
|
||
<template #default="{ row }"><el-tag size="small">{{ row.network?.type || '-' }}</el-tag></template>
|
||
</el-table-column>
|
||
<el-table-column label="虚拟机ID" width="90">
|
||
<template #default="{ row }">{{ row.network?.vm_id || '-' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="80">
|
||
<template #default="{ row }">
|
||
<el-button link type="danger" size="small" @click="handleNwRemoveNet(row)">移除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<el-empty v-if="!nwDetailNetworks.length" description="暂无分配的网络" :image-size="50" />
|
||
<template #footer><el-button @click="nwDetailVisible = false">关闭</el-button></template>
|
||
</el-dialog>
|
||
|
||
<!-- 创建组网弹窗 -->
|
||
<el-dialog v-model="nwCreateVisible" title="创建组网" width="480px" destroy-on-close class="tk-dialog">
|
||
<el-form ref="nwCreateFormRef" :model="nwCreateForm" :rules="nwCreateRules" label-width="100px">
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">组网信息</div>
|
||
<el-form-item label="用户" prop="user_id">
|
||
<div style="display: flex; gap: 8px; width: 100%">
|
||
<el-input :model-value="nwCreateForm.user_id ? `${nwCreateUserName} (ID: ${nwCreateForm.user_id})` : '未选择'" disabled style="flex: 1" />
|
||
<el-button type="primary" @click="showNwUserSelector = true">选择</el-button>
|
||
<el-button v-if="nwCreateForm.user_id" @click="nwCreateForm.user_id = 0; nwCreateUserName = ''">清除</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="网桥名称">
|
||
<el-input v-model="nwCreateForm.bridge_name" placeholder="可选" />
|
||
</el-form-item>
|
||
<el-form-item label="网关">
|
||
<el-input v-model="nwCreateForm.gateway" placeholder="可选,如 10.0.0.1" />
|
||
</el-form-item>
|
||
</div>
|
||
</el-form>
|
||
<template #footer>
|
||
<div class="tk-dialog-footer">
|
||
<el-button @click="nwCreateVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="nwSubmitLoading" @click="submitNwCreate">创建</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 分配IP弹窗 -->
|
||
<el-dialog v-model="nwAssignVisible" title="为虚拟机分配组网IP" width="480px" destroy-on-close class="tk-dialog">
|
||
<el-form label-width="100px">
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">分配信息</div>
|
||
<el-form-item label="组网">{{ nwAssignTarget?.name || '-' }} (ID: {{ nwAssignTarget?.id }})</el-form-item>
|
||
<el-form-item label="虚拟机" required>
|
||
<div style="display: flex; gap: 8px; width: 100%">
|
||
<el-input :model-value="nwAssignVmId ? `${nwAssignVmName} (ID: ${nwAssignVmId})` : '未选择'" disabled style="flex: 1" />
|
||
<el-button type="primary" @click="showNwVmSelector = true">选择</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="指定IP">
|
||
<el-input v-model="nwAssignIp" placeholder="留空自动分配" />
|
||
</el-form-item>
|
||
</div>
|
||
</el-form>
|
||
<template #footer>
|
||
<div class="tk-dialog-footer">
|
||
<el-button @click="nwAssignVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="nwSubmitLoading" @click="submitNwAssign" :disabled="!nwAssignVmId">分配</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<UserListSelector v-model="showNwUserSelector" @confirm="handleNwUserSelected" />
|
||
<VmSelectorPopup v-model="showNwVmSelector" :service-id="serviceId" :host-id="hostId" @confirm="handleNwVmSelected" />
|
||
</div>
|
||
|
||
<!-- 编辑弹窗 -->
|
||
<el-dialog v-model="editDialogVisible" title="编辑宿主机" width="890px" destroy-on-close class="tk-dialog">
|
||
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">基本信息</div>
|
||
<el-form-item label="名称" prop="name"><el-input v-model="formData.name" /></el-form-item>
|
||
<el-form-item label="服务地址" prop="base_url"><el-input v-model="formData.base_url" /></el-form-item>
|
||
<el-form-item label="IP 地址" prop="ip"><el-input v-model="formData.ip" /></el-form-item>
|
||
<el-form-item label="认证Token"><el-input v-model="formData.token" show-password /></el-form-item>
|
||
</div>
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">SSH 配置</div>
|
||
<el-form-item label="端口"><el-input-number v-model="formData.port" :min="0" :max="65535" controls-position="right" style="width: 100%" /></el-form-item>
|
||
<el-form-item label="用户名"><el-input v-model="formData.user" /></el-form-item>
|
||
<el-form-item label="密码"><el-input v-model="formData.password" show-password /></el-form-item>
|
||
<el-form-item label="私钥"><el-input v-model="formData.private_key" type="textarea" :rows="4" placeholder="SSH 私钥内容" /></el-form-item>
|
||
</div>
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">资源限制</div>
|
||
<div class="tk-resource-grid">
|
||
<el-form-item label="CPU"><el-input-number v-model="formData.max_cpu" :min="0" controls-position="right" /><span class="tk-res-unit">核</span></el-form-item>
|
||
<el-form-item label="内存">
|
||
<el-input-number v-model="memoryDisplay" :min="0" controls-position="right" />
|
||
<el-select v-model="memoryUnit" class="tk-unit-select">
|
||
<el-option v-for="u in memoryUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="磁盘">
|
||
<el-input-number v-model="diskDisplay" :min="0" controls-position="right" />
|
||
<el-select v-model="diskUnit" class="tk-unit-select">
|
||
<el-option v-for="u in diskUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="下行带宽"><el-input-number v-model="formData.rx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span></el-form-item>
|
||
<el-form-item label="上行带宽"><el-input-number v-model="formData.tx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span></el-form-item>
|
||
</div>
|
||
</div>
|
||
<div class="tk-section">
|
||
<div class="tk-section-title clickable" @click="showDiskIoSection = !showDiskIoSection">
|
||
硬盘 IO 限制
|
||
<el-icon class="section-arrow" :class="{ expanded: showDiskIoSection }"><ArrowRight /></el-icon>
|
||
<span class="section-hint">可选,不展开则使用默认值</span>
|
||
</div>
|
||
<div v-show="showDiskIoSection">
|
||
<div class="io-sub-title">
|
||
带宽限制
|
||
<el-select v-model="ioBwUnit" class="tk-unit-select" style="width: 90px; margin-left: 8px">
|
||
<el-option v-for="u in ioBwUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||
</el-select>
|
||
</div>
|
||
<div class="tk-resource-grid">
|
||
<el-form-item v-for="f in diskIoBwFields" :key="f.key" :label="f.label">
|
||
<el-input-number :model-value="+(formData[f.key] / getIoBwFactor()).toFixed(2)" @update:model-value="v => formData[f.key] = Math.round((v || 0) * getIoBwFactor())" :min="0" controls-position="right" />
|
||
</el-form-item>
|
||
</div>
|
||
<div class="io-sub-title">IOPS 限制</div>
|
||
<div class="tk-resource-grid">
|
||
<el-form-item v-for="f in diskIoIopsFields" :key="f.key" :label="f.label">
|
||
<el-input-number v-model="formData[f.key]" :min="0" controls-position="right" />
|
||
</el-form-item>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">其他配置</div>
|
||
<el-form-item label="宿主机组">
|
||
<div style="display: flex; gap: 8px; width: 100%">
|
||
<el-input :model-value="formData.host_group_id ? `宿主机组 #${formData.host_group_id}` : '未选择'" disabled style="flex: 1" />
|
||
<el-button type="primary" @click="showGroupSelector = true">选择</el-button>
|
||
<el-button v-if="formData.host_group_id" @click="formData.host_group_id = 0">清除</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="介绍"><el-input v-model="formData.description" type="textarea" :rows="3" /></el-form-item>
|
||
</div>
|
||
</el-form>
|
||
<template #footer>
|
||
<div class="tk-dialog-footer">
|
||
<el-button @click="editDialogVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<HostGroupSelectorPopup v-model="showGroupSelector" :service-id="serviceId" :current-id="formData.host_group_id" @confirm="g => formData.host_group_id = g.id" />
|
||
|
||
<!-- 创建注册令牌弹窗 -->
|
||
<el-dialog v-model="tokenDialogVisible" title="创建宿主机注册令牌" width="700px" destroy-on-close class="token-dialog">
|
||
<el-form ref="tokenFormRef" :model="tokenForm" :rules="tokenRules" label-width="120px">
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">基本信息</div>
|
||
<el-form-item label="宿主机名称" prop="name">
|
||
<el-input v-model="tokenForm.name" placeholder="为该宿主机命名" />
|
||
</el-form-item>
|
||
<el-form-item label="所属宿主机组" prop="host_group_id">
|
||
<div style="display: flex; gap: 8px; width: 100%">
|
||
<el-input :model-value="tokenForm.host_group_id ? `宿主机组 #${tokenForm.host_group_id}` : ''" placeholder="请选择宿主机组" disabled style="flex: 1" />
|
||
<el-button type="primary" @click="showTokenGroupSelector = true">选择</el-button>
|
||
<el-button v-if="tokenForm.host_group_id" @click="tokenForm.host_group_id = 0">清除</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="宿主机描述">
|
||
<el-input v-model="tokenForm.description" type="textarea" :rows="2" placeholder="宿主机描述(可选)" />
|
||
</el-form-item>
|
||
</div>
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">资源配额</div>
|
||
<div class="tk-resource-grid">
|
||
<el-form-item label="CPU" prop="max_cpu" class="tk-res-item">
|
||
<el-input-number v-model="tokenForm.max_cpu" :min="1" controls-position="right" /><span class="tk-res-unit">核</span>
|
||
</el-form-item>
|
||
<el-form-item label="内存" prop="max_memory" class="tk-res-item">
|
||
<el-input-number v-model="tokenMemDisplay" :min="0" controls-position="right" />
|
||
<el-select v-model="tokenMemUnit" class="tk-unit-select">
|
||
<el-option v-for="u in memoryUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="磁盘" prop="max_disk" class="tk-res-item">
|
||
<el-input-number v-model="tokenDiskDisplay" :min="0" controls-position="right" />
|
||
<el-select v-model="tokenDiskUnit" class="tk-unit-select">
|
||
<el-option v-for="u in diskUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="下行带宽" class="tk-res-item">
|
||
<el-input-number v-model="tokenForm.rx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
|
||
</el-form-item>
|
||
<el-form-item label="上行带宽" class="tk-res-item">
|
||
<el-input-number v-model="tokenForm.tx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
|
||
</el-form-item>
|
||
</div>
|
||
</div>
|
||
<div class="tk-section">
|
||
<div class="tk-section-title clickable" @click="showTokenDiskIo = !showTokenDiskIo">
|
||
硬盘 IO 限制
|
||
<el-icon class="section-arrow" :class="{ expanded: showTokenDiskIo }"><ArrowRight /></el-icon>
|
||
<span class="section-hint">可选,不展开则使用默认值</span>
|
||
</div>
|
||
<div v-show="showTokenDiskIo">
|
||
<div class="io-sub-title">
|
||
带宽限制
|
||
<el-select v-model="tokenIoBwUnit" class="tk-unit-select" style="width: 90px; margin-left: 8px">
|
||
<el-option v-for="u in ioBwUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||
</el-select>
|
||
</div>
|
||
<div class="tk-resource-grid">
|
||
<el-form-item v-for="f in diskIoBwFields" :key="f.key" :label="f.label" class="tk-res-item">
|
||
<el-input-number :model-value="+(tokenForm[f.key] / getTokenIoBwFactor()).toFixed(2)" @update:model-value="v => tokenForm[f.key] = Math.round((v || 0) * getTokenIoBwFactor())" :min="0" controls-position="right" />
|
||
</el-form-item>
|
||
</div>
|
||
<div class="io-sub-title">IOPS 限制</div>
|
||
<div class="tk-resource-grid">
|
||
<el-form-item v-for="f in diskIoIopsFields" :key="f.key" :label="f.label" class="tk-res-item">
|
||
<el-input-number v-model="tokenForm[f.key]" :min="0" controls-position="right" />
|
||
</el-form-item>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">令牌有效期</div>
|
||
<el-form-item label="有效期" prop="expire_hours">
|
||
<el-input-number v-model="tokenForm.expire_hours" :min="1" :max="8760" controls-position="right" style="width: 100%" />
|
||
<div class="form-hint">单位:小时。默认 24 小时,最大 8760 小时(365天)</div>
|
||
</el-form-item>
|
||
</div>
|
||
</el-form>
|
||
<template #footer>
|
||
<div class="tk-dialog-footer">
|
||
<el-button @click="tokenDialogVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="tokenSubmitLoading" @click="handleTokenSubmit">
|
||
<el-icon><Key /></el-icon>创建令牌
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 令牌结果弹窗 -->
|
||
<el-dialog v-model="tokenResultVisible" title="注册令牌已生成" width="560px" :close-on-click-modal="false" class="token-result-dialog">
|
||
<div class="tk-result-wrapper">
|
||
<div class="tk-result-header">
|
||
<el-icon class="tk-result-icon"><Key /></el-icon>
|
||
<div>
|
||
<div class="tk-result-name">{{ tokenResultInfo.name }}</div>
|
||
<div class="tk-result-meta">有效期 {{ tokenResultInfo.expire_hours }} 小时</div>
|
||
</div>
|
||
</div>
|
||
<el-alert type="warning" :closable="false" show-icon style="margin-bottom: 16px">
|
||
<template #title>请立即复制并保存此令牌,关闭后将无法再次查看</template>
|
||
</el-alert>
|
||
<div class="tk-token-block">
|
||
<div class="tk-token-label">后端地址</div>
|
||
<div class="tk-token-value">{{ baseUrl }}</div>
|
||
</div>
|
||
<div class="tk-token-block">
|
||
<div class="tk-token-label">service_id(主控服务ID)</div>
|
||
<div class="tk-token-value">{{ tokenResultInfo.service_id }}</div>
|
||
</div>
|
||
<div class="tk-token-block">
|
||
<div class="tk-token-label">注册令牌</div>
|
||
<div class="tk-token-value">{{ tokenResultInfo.token }}</div>
|
||
</div>
|
||
<el-button type="primary" class="tk-copy-btn" @click="copyToken">
|
||
<el-icon><CopyDocument /></el-icon>复制令牌到剪贴板
|
||
</el-button>
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="tokenResultVisible = false">关闭</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 令牌用宿主机组选择器 -->
|
||
<HostGroupSelectorPopup v-model="showTokenGroupSelector" :service-id="serviceId" :current-id="tokenForm.host_group_id" @confirm="handleTokenGroupSelected" />
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, onBeforeUnmount, watch, nextTick, provide } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { ArrowLeft, ArrowRight, Refresh, Edit, Delete, Monitor, Coin, Connection, Search, Plus, Key, CopyDocument } from '@element-plus/icons-vue'
|
||
import {
|
||
getRemoteHostDetail, updateRemoteHost, deleteRemoteHost,
|
||
getUserNetworkingList, getUserNetworkingDetail, createUserNetworking, deleteUserNetworking,
|
||
assignUserNetworking, removeUserNetworkingNetwork,
|
||
createHostToken, getMetricsHistory, getHostQuotaStats
|
||
} from '@/api/admin/kvmService'
|
||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||
import { baseUrl } from '@/config/env'
|
||
import HostGroupSelectorPopup from '@/components/admin/HostGroupSelectorPopup.vue'
|
||
import ImageManage from '@/views/virtualization/ImageManage.vue'
|
||
import NetworkManage from '@/views/virtualization/NetworkManage.vue'
|
||
import VolumeManage from '@/views/virtualization/VolumeManage.vue'
|
||
import VmManage from '@/views/virtualization/VmManage.vue'
|
||
import SnapshotManage from '@/views/virtualization/SnapshotManage.vue'
|
||
import BackupManage from '@/views/virtualization/BackupManage.vue'
|
||
import { useTagsViewStore } from '@/store/tagsViewStore'
|
||
import UserListSelector from '@/components/admin/UserListSelector.vue'
|
||
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
|
||
import * as echarts from 'echarts'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const tagsViewStore = useTagsViewStore()
|
||
|
||
const serviceId = computed(() => parseInt(route.query.service_id) || 0)
|
||
const serviceName = computed(() => route.query.service_name || '')
|
||
const hostId = computed(() => parseInt(route.query.id) || 0)
|
||
|
||
const activeTab = ref('info')
|
||
const hostTabLoaded = reactive({ image: false, network: false, volume: false, vm: false, snapshot: false, backup: false, networking: false })
|
||
|
||
const imageManageRef = ref(null)
|
||
const networkManageRef = ref(null)
|
||
const volumeManageRef = ref(null)
|
||
const vmManageRef = ref(null)
|
||
const snapshotManageRef = ref(null)
|
||
const backupManageRef = ref(null)
|
||
const tabRefMap = { image: imageManageRef, network: networkManageRef, volume: volumeManageRef, vm: vmManageRef, snapshot: snapshotManageRef, backup: backupManageRef }
|
||
|
||
watch(activeTab, (tab) => {
|
||
if (!['info', 'monitor', 'networking'].includes(tab)) {
|
||
if (!hostTabLoaded[tab]) {
|
||
hostTabLoaded[tab] = true
|
||
} else {
|
||
nextTick(() => { tabRefMap[tab]?.value?.loadList?.() })
|
||
}
|
||
}
|
||
if (tab === 'monitor' && detail.value) {
|
||
if (!historicalMetricsData.value) {
|
||
loadHistoricalMetrics()
|
||
}
|
||
}
|
||
if (tab === 'networking') loadNetworkingList()
|
||
if (tab === 'quotaStats' && !quotaStats.value) loadQuotaStats()
|
||
})
|
||
|
||
const loading = ref(false)
|
||
const submitLoading = ref(false)
|
||
const detail = ref(null)
|
||
|
||
provide('embedded', true)
|
||
provide('serviceId', serviceId)
|
||
provide('serviceName', serviceName)
|
||
provide('hostId', hostId)
|
||
provide('hostDetail', detail)
|
||
const showToken = ref(false)
|
||
const showPassword = ref(false)
|
||
const showPrivateKey = ref(false)
|
||
const copyText = (text) => {
|
||
if (!text) return
|
||
if (navigator.clipboard?.writeText) {
|
||
navigator.clipboard.writeText(text).then(() => ElMessage.success('已复制到剪贴板')).catch(() => fallbackCopy(text))
|
||
} else {
|
||
fallbackCopy(text)
|
||
}
|
||
}
|
||
const fallbackCopy = (text) => {
|
||
const ta = document.createElement('textarea')
|
||
ta.value = text
|
||
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px'
|
||
document.body.appendChild(ta)
|
||
ta.select()
|
||
try {
|
||
document.execCommand('copy')
|
||
ElMessage.success('已复制到剪贴板')
|
||
} catch { ElMessage.error('复制失败') }
|
||
document.body.removeChild(ta)
|
||
}
|
||
const historicalMetricsData = ref(null)
|
||
const historicalMetricsLoading = ref(false)
|
||
const editDialogVisible = ref(false)
|
||
const showGroupSelector = ref(false)
|
||
const formRef = ref(null)
|
||
|
||
const diskIoDefaults = {
|
||
read_bytes_sec: 314572800, write_bytes_sec: 314572800,
|
||
read_iops_sec: 1000, write_iops_sec: 1000,
|
||
read_bytes_sec_max: 314572800, write_bytes_sec_max: 314572800,
|
||
read_iops_sec_max: 1000, write_iops_sec_max: 1000
|
||
}
|
||
const diskIoBwFields = [
|
||
{ key: 'read_bytes_sec', label: '读取' },
|
||
{ key: 'write_bytes_sec', label: '写入' },
|
||
{ key: 'read_bytes_sec_max', label: '突发读取' },
|
||
{ key: 'write_bytes_sec_max', label: '突发写入' }
|
||
]
|
||
const diskIoIopsFields = [
|
||
{ key: 'read_iops_sec', label: '读取' },
|
||
{ key: 'write_iops_sec', label: '写入' },
|
||
{ key: 'read_iops_sec_max', label: '突发读取' },
|
||
{ key: 'write_iops_sec_max', label: '突发写入' }
|
||
]
|
||
const diskIoFields = [
|
||
...diskIoBwFields.map(f => ({ ...f, isBandwidth: true })),
|
||
...diskIoIopsFields.map(f => ({ ...f, isBandwidth: false }))
|
||
]
|
||
const formatDiskIoVal = (val, isBandwidth) => {
|
||
if (!val && val !== 0) return '-'
|
||
val = Number(val)
|
||
if (!isBandwidth) return val.toLocaleString() + ' IOPS'
|
||
if (val >= 1073741824) return (val / 1073741824).toFixed(1) + ' GB/s'
|
||
if (val >= 1048576) return (val / 1048576).toFixed(0) + ' MB/s'
|
||
if (val >= 1024) return (val / 1024).toFixed(0) + ' KB/s'
|
||
return val + ' B/s'
|
||
}
|
||
const ioBwUnitOptions = [
|
||
{ label: 'B/s', factor: 1 },
|
||
{ label: 'KB/s', factor: 1024 },
|
||
{ label: 'MB/s', factor: 1048576 },
|
||
{ label: 'GB/s', factor: 1073741824 }
|
||
]
|
||
const ioBwUnit = ref('MB/s')
|
||
const tokenIoBwUnit = ref('MB/s')
|
||
const getIoBwFactor = () => ioBwUnitOptions.find(u => u.label === ioBwUnit.value)?.factor || 1048576
|
||
const getTokenIoBwFactor = () => ioBwUnitOptions.find(u => u.label === tokenIoBwUnit.value)?.factor || 1048576
|
||
|
||
const showDiskIoSection = ref(false)
|
||
const showTokenDiskIo = ref(false)
|
||
const showDetailDiskIo = ref(false)
|
||
|
||
const formData = reactive({
|
||
name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', private_key: '',
|
||
max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '',
|
||
...diskIoDefaults
|
||
})
|
||
const formRules = {
|
||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||
base_url: [{ required: true, message: '请输入服务地址', trigger: 'blur' }],
|
||
ip: [{ required: true, message: '请输入IP', trigger: 'blur' }]
|
||
}
|
||
|
||
const formatTimestamp = (ts) => {
|
||
if (!ts) return '-'
|
||
if (typeof ts === 'object' && ts.seconds) return new Date(Number(ts.seconds) * 1000).toLocaleString('zh-CN')
|
||
if (typeof ts === 'string' || typeof ts === 'number') { const d = new Date(ts); return isNaN(d.getTime()) ? String(ts) : d.toLocaleString('zh-CN') }
|
||
return '-'
|
||
}
|
||
const formatMemKB = (v) => {
|
||
if (!v) return '-'; v = Number(v)
|
||
if (v >= 1073741824) return (v / 1073741824).toFixed(1) + ' TB'
|
||
if (v >= 1048576) return (v / 1048576).toFixed(1) + ' GB'
|
||
if (v >= 1024) return (v / 1024).toFixed(1) + ' MB'
|
||
return v + ' KB'
|
||
}
|
||
const formatDiskGB = (v) => {
|
||
if (!v) return '-'; v = Number(v)
|
||
if (v >= 1024) return (v / 1024).toFixed(1) + ' TB'
|
||
return v.toFixed(1) + ' GB'
|
||
}
|
||
|
||
const memoryUnit = ref('GB')
|
||
const diskUnit = ref('GB')
|
||
const memoryUnitOptions = [
|
||
{ label: 'KB', factor: 1 },
|
||
{ label: 'MB', factor: 1024 },
|
||
{ label: 'GB', factor: 1048576 },
|
||
{ label: 'TB', factor: 1073741824 }
|
||
]
|
||
const diskUnitOptions = [
|
||
{ label: 'GB', factor: 1 },
|
||
{ label: 'TB', factor: 1024 }
|
||
]
|
||
const getMemFactor = () => memoryUnitOptions.find(u => u.label === memoryUnit.value)?.factor || 1048576
|
||
const getDiskFactor = () => diskUnitOptions.find(u => u.label === diskUnit.value)?.factor || 1
|
||
|
||
const memoryDisplay = computed({
|
||
get: () => formData.max_memory ? +(formData.max_memory / getMemFactor()).toFixed(2) : 0,
|
||
set: (v) => { formData.max_memory = Math.round((v || 0) * getMemFactor()) }
|
||
})
|
||
const diskDisplay = computed({
|
||
get: () => formData.max_disk ? +(formData.max_disk / getDiskFactor()).toFixed(2) : 0,
|
||
set: (v) => { formData.max_disk = Math.round((v || 0) * getDiskFactor()) }
|
||
})
|
||
const formatBytesRaw = (val) => {
|
||
if (!val && val !== 0) return '-'; val = Number(val)
|
||
if (val >= 1099511627776) return (val / 1099511627776).toFixed(2) + ' TB'
|
||
if (val >= 1073741824) return (val / 1073741824).toFixed(2) + ' GB'
|
||
if (val >= 1048576) return (val / 1048576).toFixed(2) + ' MB'
|
||
if (val >= 1024) return (val / 1024).toFixed(1) + ' KB'
|
||
return val + ' B'
|
||
}
|
||
|
||
const loadDetail = async () => {
|
||
if (!serviceId.value || !hostId.value) return
|
||
loading.value = true
|
||
try {
|
||
const res = await getRemoteHostDetail({ service_id: serviceId.value, id: hostId.value })
|
||
const body = res?.data
|
||
if (body?.code === 200 && body?.data) {
|
||
detail.value = body.data.host ?? body.data.data ?? body.data
|
||
} else { ElMessage.error(extractApiError(body, '加载失败')) }
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '加载失败')) } finally { loading.value = false }
|
||
}
|
||
|
||
// ---- 额度统计 ----
|
||
const quotaStats = ref(null)
|
||
const quotaStatsLoading = ref(false)
|
||
const quotaStatsDisk = computed(() => {
|
||
if (!quotaStats.value?.actual_disk_json) return []
|
||
try { return JSON.parse(quotaStats.value.actual_disk_json) } catch { return [] }
|
||
})
|
||
|
||
const loadQuotaStats = async () => {
|
||
if (!serviceId.value || !hostId.value) return
|
||
quotaStatsLoading.value = true
|
||
try {
|
||
const res = await getHostQuotaStats({ service_id: serviceId.value, host_id: hostId.value })
|
||
if (res?.data?.code === 200) quotaStats.value = res.data.data
|
||
else quotaStats.value = null
|
||
} catch { quotaStats.value = null } finally { quotaStatsLoading.value = false }
|
||
}
|
||
|
||
const formatQuotaMem = (mb) => {
|
||
if (!mb && mb !== 0) return '-'
|
||
if (mb >= 1024) return (mb / 1024).toFixed(1) + ' GB'
|
||
return mb + ' MB'
|
||
}
|
||
|
||
const formatQuotaBytes = (bytes) => {
|
||
if (!bytes && bytes !== 0) return '-'
|
||
const n = Number(bytes)
|
||
if (n >= 1073741824) return (n / 1073741824).toFixed(2) + ' GB'
|
||
if (n >= 1048576) return (n / 1048576).toFixed(1) + ' MB'
|
||
if (n >= 1024) return (n / 1024).toFixed(0) + ' KB'
|
||
return n + ' B'
|
||
}
|
||
|
||
const cpuChartRef = ref(null)
|
||
const memChartRef = ref(null)
|
||
const netChartRef = ref(null)
|
||
const inetChartRef = ref(null)
|
||
let cpuChart = null
|
||
let memChart = null
|
||
let netChart = null
|
||
let inetChart = null
|
||
let isPageActive = false
|
||
|
||
const latestMetrics = computed(() => {
|
||
const arr = historicalMetricsData.value
|
||
if (!Array.isArray(arr) || !arr.length) return null
|
||
return arr[arr.length - 1]
|
||
})
|
||
|
||
const makeDefaultRange = () => {
|
||
const now = new Date()
|
||
return [new Date(now.getTime() - 10 * 60 * 1000), now]
|
||
}
|
||
const monitorDateRange = ref(makeDefaultRange())
|
||
|
||
const monitorShortcuts = [
|
||
{ text: '最近10分钟', value: () => { const n = new Date(); return [new Date(n.getTime() - 10 * 60000), n] } },
|
||
{ text: '最近30分钟', value: () => { const n = new Date(); return [new Date(n.getTime() - 30 * 60000), n] } },
|
||
{ text: '最近1小时', value: () => { const n = new Date(); return [new Date(n.getTime() - 3600000), n] } },
|
||
{ text: '最近6小时', value: () => { const n = new Date(); return [new Date(n.getTime() - 6 * 3600000), n] } },
|
||
{ text: '最近12小时', value: () => { const n = new Date(); return [new Date(n.getTime() - 12 * 3600000), n] } },
|
||
{ text: '最近1天', value: () => { const n = new Date(); return [new Date(n.getTime() - 86400000), n] } },
|
||
{ text: '最近7天', value: () => { const n = new Date(); return [new Date(n.getTime() - 7 * 86400000), n] } },
|
||
]
|
||
|
||
function calcInterval(startTime, endTime) {
|
||
const spanMin = (endTime.getTime() - startTime.getTime()) / 60000
|
||
if (spanMin < 30) return '1m'
|
||
if (spanMin < 60) return '3m'
|
||
if (spanMin < 360) return '5m'
|
||
if (spanMin < 720) return '15m'
|
||
if (spanMin < 1440) return '30m'
|
||
if (spanMin < 4320) return '1h'
|
||
if (spanMin < 10080) return '2h'
|
||
if (spanMin < 43200) return '6h'
|
||
if (spanMin < 129600) return '12h'
|
||
return '1d'
|
||
}
|
||
|
||
const intervalLabelMap = { '1m': '1分钟', '3m': '3分钟', '5m': '5分钟', '15m': '15分钟', '30m': '30分钟', '1h': '1小时', '2h': '2小时', '6h': '6小时', '12h': '12小时', '1d': '1天' }
|
||
const currentIntervalLabel = computed(() => {
|
||
if (!monitorDateRange.value || monitorDateRange.value.length < 2) return '-'
|
||
const iv = calcInterval(new Date(monitorDateRange.value[0]), new Date(monitorDateRange.value[1]))
|
||
return intervalLabelMap[iv] || iv
|
||
})
|
||
|
||
const loadHistoricalMetrics = async () => {
|
||
if (!serviceId.value || !hostId.value) return
|
||
if (!monitorDateRange.value || monitorDateRange.value.length < 2) return
|
||
historicalMetricsLoading.value = true
|
||
try {
|
||
const startTime = new Date(monitorDateRange.value[0])
|
||
const endTime = new Date(monitorDateRange.value[1])
|
||
const interval = calcInterval(startTime, endTime)
|
||
const params = {
|
||
service_id: serviceId.value,
|
||
host_id: hostId.value,
|
||
start: startTime.toISOString(),
|
||
end_time: endTime.toISOString(),
|
||
interval
|
||
}
|
||
|
||
const res = await getMetricsHistory(params)
|
||
const body = res?.data
|
||
if (body?.code === 200 && body?.data) {
|
||
historicalMetricsData.value = Array.isArray(body.data) ? body.data : (body.data.data || [])
|
||
await nextTick()
|
||
renderHistoricalCharts()
|
||
} else {
|
||
ElMessage.error(extractApiError(body, '加载历史指标失败'))
|
||
}
|
||
} catch (e) {
|
||
ElMessage.error(extractApiError(e?.response?.data, '加载历史指标失败'))
|
||
} finally {
|
||
historicalMetricsLoading.value = false
|
||
}
|
||
}
|
||
|
||
|
||
const formatNetLabel = (v) => {
|
||
if (!v) return '0 B/s'
|
||
if (v >= 1073741824) return (v / 1073741824).toFixed(1) + ' GB/s'
|
||
if (v >= 1048576) return (v / 1048576).toFixed(1) + ' MB/s'
|
||
if (v >= 1024) return (v / 1024).toFixed(1) + ' KB/s'
|
||
return v + ' B/s'
|
||
}
|
||
|
||
// 渲染历史指标图表
|
||
const renderHistoricalCharts = () => {
|
||
const metrics = historicalMetricsData.value
|
||
if (!Array.isArray(metrics) || !metrics.length) return
|
||
|
||
const spanMs = monitorDateRange.value ? (new Date(monitorDateRange.value[1]).getTime() - new Date(monitorDateRange.value[0]).getTime()) : 0
|
||
const showDate = spanMs >= 12 * 3600 * 1000
|
||
const symbolType = spanMs >= 7 * 86400 * 1000 ? 'circle' : 'none'
|
||
const labelRotate = showDate ? 45 : 0
|
||
|
||
const times = metrics.map(m => {
|
||
const date = new Date(m.bucket)
|
||
if (showDate) return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||
return date.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' })
|
||
})
|
||
|
||
const cpuData = metrics.map(m => m.cpu_usage ?? 0)
|
||
const memData = metrics.map(m => m.mem_percent ?? 0)
|
||
const inetRxData = metrics.map(m => m.inet_rx ?? 0)
|
||
const inetTxData = metrics.map(m => m.inet_tx ?? 0)
|
||
|
||
const netRxRate = []
|
||
const netTxRate = []
|
||
for (let i = 0; i < metrics.length; i++) {
|
||
if (i === 0) { netRxRate.push(0); netTxRate.push(0); continue }
|
||
const dt = (new Date(metrics[i].bucket) - new Date(metrics[i - 1].bucket)) / 1000
|
||
if (dt > 0) {
|
||
netRxRate.push(Math.max(0, ((metrics[i].net_rx ?? 0) - (metrics[i - 1].net_rx ?? 0)) / dt))
|
||
netTxRate.push(Math.max(0, ((metrics[i].net_tx ?? 0) - (metrics[i - 1].net_tx ?? 0)) / dt))
|
||
} else {
|
||
netRxRate.push(0); netTxRate.push(0)
|
||
}
|
||
}
|
||
|
||
const baseGrid = { top: 10, right: 16, bottom: 24, left: 50 }
|
||
const makeXAxis = () => ({ type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 10, rotate: labelRotate } })
|
||
const makeSeries = (name, data, color) => ({ name, type: 'line', smooth: true, symbol: symbolType, areaStyle: { opacity: 0.15 }, lineStyle: { width: 2, color }, itemStyle: { color }, data })
|
||
|
||
if (cpuChartRef.value) {
|
||
if (!cpuChart) cpuChart = echarts.init(cpuChartRef.value)
|
||
cpuChart.setOption({
|
||
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} CPU: ${p[0].value.toFixed(1)}%` },
|
||
grid: baseGrid, xAxis: makeXAxis(),
|
||
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { fontSize: 10, formatter: v => v + '%' } },
|
||
series: [makeSeries('CPU', cpuData, '#409eff')]
|
||
}, true)
|
||
}
|
||
|
||
if (memChartRef.value) {
|
||
if (!memChart) memChart = echarts.init(memChartRef.value)
|
||
memChart.setOption({
|
||
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} 内存: ${p[0].value.toFixed(1)}%` },
|
||
grid: baseGrid, xAxis: makeXAxis(),
|
||
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { fontSize: 10, formatter: v => v + '%' } },
|
||
series: [makeSeries('内存', memData, '#67c23a')]
|
||
}, true)
|
||
}
|
||
|
||
if (inetChartRef.value) {
|
||
if (!inetChart) inetChart = echarts.init(inetChartRef.value)
|
||
inetChart.setOption({
|
||
tooltip: { trigger: 'axis', formatter: (params) => {
|
||
let s = params[0].axisValue
|
||
params.forEach(p => { s += `<br/>${p.marker} ${p.seriesName}: ${formatNetLabel(p.value)}` })
|
||
return s
|
||
}},
|
||
grid: baseGrid, xAxis: makeXAxis(),
|
||
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: formatNetLabel } },
|
||
series: [makeSeries('接收', inetRxData, '#409eff'), makeSeries('发送', inetTxData, '#e6a23c')]
|
||
}, true)
|
||
}
|
||
|
||
if (netChartRef.value) {
|
||
if (!netChart) netChart = echarts.init(netChartRef.value)
|
||
netChart.setOption({
|
||
tooltip: { trigger: 'axis', formatter: (params) => {
|
||
let s = params[0].axisValue
|
||
params.forEach(p => { s += `<br/>${p.marker} ${p.seriesName}: ${formatNetLabel(p.value)}` })
|
||
return s
|
||
}},
|
||
grid: baseGrid, xAxis: makeXAxis(),
|
||
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: formatNetLabel } },
|
||
series: [makeSeries('接收', netRxRate, '#409eff'), makeSeries('发送', netTxRate, '#e6a23c')]
|
||
}, true)
|
||
}
|
||
}
|
||
|
||
const disposeCharts = () => {
|
||
cpuChart?.dispose(); cpuChart = null
|
||
memChart?.dispose(); memChart = null
|
||
netChart?.dispose(); netChart = null
|
||
inetChart?.dispose(); inetChart = null
|
||
}
|
||
|
||
const handleEdit = () => {
|
||
if (!detail.value) return
|
||
const d = detail.value
|
||
Object.assign(formData, {
|
||
name: d.name || '', base_url: d.base_url || '', ip: d.ip || '', token: d.token || '',
|
||
port: d.port || 22, user: d.user || '', password: d.password || '', private_key: d.private_key || '',
|
||
max_cpu: d.max_cpu || 0, max_memory: d.max_memory || 0, max_disk: d.max_disk || 0,
|
||
rx_bandwidth: d.rx_bandwidth || 0, tx_bandwidth: d.tx_bandwidth || 0,
|
||
host_group_id: d.host_group_id || 0, description: d.description || '',
|
||
...Object.fromEntries(diskIoFields.map(f => [f.key, d[f.key] ?? diskIoDefaults[f.key]]))
|
||
})
|
||
showDiskIoSection.value = diskIoFields.some(f => d[f.key] && d[f.key] !== diskIoDefaults[f.key])
|
||
editDialogVisible.value = true
|
||
}
|
||
|
||
const handleSubmit = () => {
|
||
formRef.value?.validate(async (valid) => {
|
||
if (!valid) return
|
||
submitLoading.value = true
|
||
try {
|
||
const payload = { ...formData, service_id: serviceId.value, id: hostId.value }
|
||
if (!payload.token) delete payload.token
|
||
if (!payload.password) delete payload.password
|
||
if (!payload.private_key) delete payload.private_key
|
||
if (!payload.description) delete payload.description
|
||
if (!payload.host_group_id) delete payload.host_group_id
|
||
const res = await updateRemoteHost(payload)
|
||
if (res?.data?.code === 200) { ElMessage.success('修改成功'); editDialogVisible.value = false; loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, '修改失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '修改失败')) } finally { submitLoading.value = false }
|
||
})
|
||
}
|
||
|
||
const handleDelete = () => {
|
||
ElMessageBox.confirm(`确定要删除宿主机「${detail.value?.name}」吗?`, '删除确认', {
|
||
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
|
||
}).then(async () => {
|
||
try {
|
||
const res = await deleteRemoteHost({ service_id: serviceId.value, id: hostId.value })
|
||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); goBack() }
|
||
else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
|
||
}).catch(() => {})
|
||
}
|
||
|
||
// ========== 创建注册令牌 ==========
|
||
const tokenDialogVisible = ref(false)
|
||
const tokenSubmitLoading = ref(false)
|
||
const tokenResultVisible = ref(false)
|
||
const showTokenGroupSelector = ref(false)
|
||
const tokenFormRef = ref(null)
|
||
const tokenMemUnit = ref('GB')
|
||
const tokenDiskUnit = ref('GB')
|
||
|
||
const tokenForm = reactive({
|
||
name: '', host_group_id: 0, max_cpu: 4,
|
||
max_memory: 4194304, max_disk: 100,
|
||
rx_bandwidth: 100, tx_bandwidth: 100,
|
||
description: '', expire_hours: 24,
|
||
...diskIoDefaults
|
||
})
|
||
const tokenResultInfo = reactive({ name: '', expire_hours: 24, token: '', service_id: 0 })
|
||
const tokenRules = {
|
||
name: [{ required: true, message: '请输入宿主机名称', trigger: 'blur' }],
|
||
host_group_id: [{ required: true, type: 'number', min: 1, message: '请选择宿主机组', trigger: 'change' }],
|
||
max_cpu: [{ required: true, type: 'number', min: 1, message: '请设置最大CPU核数', trigger: 'change' }],
|
||
max_memory: [{ required: true, type: 'number', min: 1, message: '请设置最大内存', trigger: 'change' }],
|
||
max_disk: [{ required: true, type: 'number', min: 1, message: '请设置最大磁盘', trigger: 'change' }],
|
||
expire_hours: [{ required: true, type: 'number', min: 1, message: '请设置有效期', trigger: 'change' }]
|
||
}
|
||
|
||
const getTokenMemFactor = () => memoryUnitOptions.find(u => u.label === tokenMemUnit.value)?.factor || 1048576
|
||
const getTokenDiskFactor = () => diskUnitOptions.find(u => u.label === tokenDiskUnit.value)?.factor || 1
|
||
const tokenMemDisplay = computed({
|
||
get: () => tokenForm.max_memory ? +(tokenForm.max_memory / getTokenMemFactor()).toFixed(2) : 0,
|
||
set: (v) => { tokenForm.max_memory = Math.round((v || 0) * getTokenMemFactor()) }
|
||
})
|
||
const tokenDiskDisplay = computed({
|
||
get: () => tokenForm.max_disk ? +(tokenForm.max_disk / getTokenDiskFactor()).toFixed(2) : 0,
|
||
set: (v) => { tokenForm.max_disk = Math.round((v || 0) * getTokenDiskFactor()) }
|
||
})
|
||
|
||
const openTokenDialog = () => {
|
||
const d = detail.value
|
||
Object.assign(tokenForm, {
|
||
name: '', host_group_id: d?.host_group_id || 0,
|
||
max_cpu: d?.max_cpu || 4,
|
||
max_memory: d?.max_memory || 4194304,
|
||
max_disk: d?.max_disk || 100,
|
||
rx_bandwidth: d?.rx_bandwidth || 100,
|
||
tx_bandwidth: d?.tx_bandwidth || 100,
|
||
description: '', expire_hours: 24,
|
||
...Object.fromEntries(diskIoFields.map(f => [f.key, d?.[f.key] ?? diskIoDefaults[f.key]]))
|
||
})
|
||
tokenMemUnit.value = 'GB'
|
||
tokenDiskUnit.value = 'GB'
|
||
showTokenDiskIo.value = false
|
||
tokenIoBwUnit.value = 'MB/s'
|
||
tokenDialogVisible.value = true
|
||
}
|
||
|
||
const handleTokenGroupSelected = (group) => {
|
||
tokenForm.host_group_id = group.id
|
||
}
|
||
|
||
const handleTokenSubmit = () => {
|
||
tokenFormRef.value?.validate(async (valid) => {
|
||
if (!valid) return
|
||
tokenSubmitLoading.value = true
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('name', tokenForm.name)
|
||
fd.append('host_group_id', tokenForm.host_group_id)
|
||
fd.append('max_cpu', tokenForm.max_cpu)
|
||
fd.append('max_memory', tokenForm.max_memory)
|
||
fd.append('max_disk', tokenForm.max_disk)
|
||
fd.append('rx_bandwidth', tokenForm.rx_bandwidth)
|
||
fd.append('tx_bandwidth', tokenForm.tx_bandwidth)
|
||
diskIoFields.forEach(f => { if (tokenForm[f.key] !== undefined) fd.append(f.key, tokenForm[f.key]) })
|
||
fd.append('description', tokenForm.description || '')
|
||
fd.append('expire_hours', tokenForm.expire_hours)
|
||
const res = await createHostToken(fd)
|
||
const body = res?.data
|
||
if (body?.code === 200 && body?.data) {
|
||
tokenResultInfo.name = tokenForm.name
|
||
tokenResultInfo.expire_hours = tokenForm.expire_hours
|
||
tokenResultInfo.token = body.data.token || body.data.Token || JSON.stringify(body.data)
|
||
tokenResultInfo.service_id = serviceId.value
|
||
tokenDialogVisible.value = false
|
||
tokenResultVisible.value = true
|
||
ElMessage.success('注册令牌创建成功')
|
||
} else {
|
||
ElMessage.error(extractApiError(body, '创建令牌失败'))
|
||
}
|
||
} catch (e) {
|
||
ElMessage.error(extractApiError(e?.response?.data, '创建令牌失败'))
|
||
} finally {
|
||
tokenSubmitLoading.value = false
|
||
}
|
||
})
|
||
}
|
||
|
||
const copyToken = async () => {
|
||
const text = `后端地址:${baseUrl}\nservice_id:${tokenResultInfo.service_id}\n注册令牌:${tokenResultInfo.token}`
|
||
try {
|
||
await navigator.clipboard.writeText(text)
|
||
ElMessage.success('令牌信息已复制到剪贴板')
|
||
} catch {
|
||
const ta = document.createElement('textarea')
|
||
ta.value = text
|
||
document.body.appendChild(ta)
|
||
ta.select()
|
||
document.execCommand('copy')
|
||
document.body.removeChild(ta)
|
||
ElMessage.success('令牌信息已复制到剪贴板')
|
||
}
|
||
}
|
||
|
||
const goBack = () => {
|
||
tagsViewStore.delVisitedView(route)
|
||
router.push({ path: '/virtualization/kvm-service-detail', query: { service_id: serviceId.value, service_name: serviceName.value } })
|
||
}
|
||
|
||
// ---- 组网管理 ----
|
||
const nwLoading = ref(false)
|
||
const nwSubmitLoading = ref(false)
|
||
const nwList = ref([])
|
||
const nwTotal = ref(0)
|
||
const nwPage = ref(1)
|
||
const nwPageSize = ref(10)
|
||
const nwFilterUserId = ref('')
|
||
const nwKeyword = ref('')
|
||
|
||
const nwDetailVisible = ref(false)
|
||
const nwDetailData = ref(null)
|
||
const nwDetailNetworks = ref([])
|
||
const nwDetailLoading = ref(false)
|
||
|
||
const nwCreateVisible = ref(false)
|
||
const nwCreateFormRef = ref(null)
|
||
const nwCreateForm = reactive({ user_id: 0, bridge_name: '', gateway: '' })
|
||
const nwCreateUserName = ref('')
|
||
const showNwUserSelector = ref(false)
|
||
const nwCreateRules = {
|
||
user_id: [{ required: true, message: '请选择用户', trigger: 'change', type: 'number', min: 1 }]
|
||
}
|
||
|
||
const nwAssignVisible = ref(false)
|
||
const nwAssignTarget = ref(null)
|
||
const nwAssignVmId = ref(0)
|
||
const nwAssignVmName = ref('')
|
||
const nwAssignIp = ref('')
|
||
const showNwVmSelector = ref(false)
|
||
|
||
const loadNetworkingList = async () => {
|
||
nwLoading.value = true
|
||
try {
|
||
const params = {
|
||
service_id: serviceId.value,
|
||
page: nwPage.value,
|
||
count: nwPageSize.value,
|
||
host_id: hostId.value
|
||
}
|
||
if (nwFilterUserId.value) params.user_id = parseInt(nwFilterUserId.value)
|
||
if (nwKeyword.value) params.keyword = nwKeyword.value
|
||
const res = await getUserNetworkingList(params)
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
const inner = res.data.data
|
||
nwList.value = Array.isArray(inner) ? inner : (inner.data || [])
|
||
nwTotal.value = inner.meta?.count ?? inner.total ?? nwList.value.length
|
||
}
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取组网列表失败')) }
|
||
finally { nwLoading.value = false }
|
||
}
|
||
|
||
const handleNwDetail = async (row) => {
|
||
nwDetailVisible.value = true
|
||
nwDetailData.value = row
|
||
nwDetailNetworks.value = []
|
||
nwDetailLoading.value = true
|
||
try {
|
||
const res = await getUserNetworkingDetail({ service_id: serviceId.value, networking_id: row.id })
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
const inner = res.data.data
|
||
nwDetailData.value = inner.data ?? inner
|
||
nwDetailNetworks.value = inner.networks || []
|
||
}
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取详情失败')) }
|
||
finally { nwDetailLoading.value = false }
|
||
}
|
||
|
||
const handleNwCreate = () => {
|
||
Object.assign(nwCreateForm, { user_id: 0, bridge_name: '', gateway: '' })
|
||
nwCreateUserName.value = ''
|
||
nwCreateVisible.value = true
|
||
}
|
||
|
||
const handleNwUserSelected = (user) => {
|
||
nwCreateForm.user_id = user.user_id || user.id
|
||
nwCreateUserName.value = user.user_name || user.name || ''
|
||
}
|
||
|
||
const submitNwCreate = async () => {
|
||
if (!nwCreateForm.user_id) { ElMessage.warning('请选择用户'); return }
|
||
nwSubmitLoading.value = true
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('host_id', hostId.value)
|
||
fd.append('user_id', nwCreateForm.user_id)
|
||
if (nwCreateForm.bridge_name) fd.append('bridge_name', nwCreateForm.bridge_name)
|
||
if (nwCreateForm.gateway) fd.append('gateway', nwCreateForm.gateway)
|
||
const res = await createUserNetworking(fd)
|
||
if (res?.data?.code === 200) {
|
||
ElMessage.success('创建成功')
|
||
nwCreateVisible.value = false
|
||
loadNetworkingList()
|
||
} else ElMessage.error(extractApiError(res?.data, '创建失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) }
|
||
finally { nwSubmitLoading.value = false }
|
||
}
|
||
|
||
const handleNwDelete = (row) => {
|
||
ElMessageBox.confirm(`确定删除组网「${row.name || row.id}」?该操作不可撤销。`, '删除确认', { type: 'warning' })
|
||
.then(async () => {
|
||
try {
|
||
const res = await deleteUserNetworking({ service_id: serviceId.value, networking_id: row.id })
|
||
if (res?.data?.code === 200) { ElMessage.success('已删除'); loadNetworkingList() }
|
||
else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
|
||
}).catch(() => {})
|
||
}
|
||
|
||
const handleNwAssign = (row) => {
|
||
nwAssignTarget.value = row
|
||
nwAssignVmId.value = 0
|
||
nwAssignVmName.value = ''
|
||
nwAssignIp.value = ''
|
||
nwAssignVisible.value = true
|
||
}
|
||
|
||
const handleNwVmSelected = (vm) => {
|
||
nwAssignVmId.value = vm.id
|
||
nwAssignVmName.value = vm.name || ''
|
||
}
|
||
|
||
const submitNwAssign = async () => {
|
||
if (!nwAssignVmId.value || !nwAssignTarget.value) { ElMessage.warning('请选择虚拟机'); return }
|
||
nwSubmitLoading.value = true
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('networking_id', nwAssignTarget.value.id)
|
||
fd.append('vm_id', nwAssignVmId.value)
|
||
if (nwAssignIp.value.trim()) fd.append('ip', nwAssignIp.value.trim())
|
||
const res = await assignUserNetworking(fd)
|
||
if (res?.data?.code === 200) {
|
||
ElMessage.success('分配成功')
|
||
nwAssignVisible.value = false
|
||
if (nwDetailVisible.value && nwDetailData.value?.id === nwAssignTarget.value.id) {
|
||
handleNwDetail(nwAssignTarget.value)
|
||
}
|
||
} else ElMessage.error(extractApiError(res?.data, '分配失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '分配失败')) }
|
||
finally { nwSubmitLoading.value = false }
|
||
}
|
||
|
||
const handleNwRemoveNet = (netItem) => {
|
||
const net = netItem.network || netItem
|
||
if (!net.id || !nwDetailData.value?.id) { ElMessage.warning('缺少网络信息'); return }
|
||
ElMessageBox.confirm(`确定移除网络 (ID: ${net.id}, VM: ${net.vm_id || '-'}) ?`, '移除确认', { type: 'warning' })
|
||
.then(async () => {
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('networking_id', nwDetailData.value.id)
|
||
fd.append('network_id', net.id)
|
||
fd.append('vm_id', net.vm_id || 0)
|
||
const res = await removeUserNetworkingNetwork(fd)
|
||
if (res?.data?.code === 200) {
|
||
ElMessage.success('已移除')
|
||
handleNwDetail(nwDetailData.value)
|
||
} else ElMessage.error(extractApiError(res?.data, '移除失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '移除失败')) }
|
||
}).catch(() => {})
|
||
}
|
||
|
||
let loadedHostId = null
|
||
|
||
const initPage = () => {
|
||
if (!hostId.value || loadedHostId === hostId.value) return
|
||
loadedHostId = hostId.value
|
||
activeTab.value = 'info'
|
||
Object.keys(hostTabLoaded).forEach(k => hostTabLoaded[k] = false)
|
||
detail.value = null
|
||
showToken.value = false
|
||
showPassword.value = false
|
||
showPrivateKey.value = false
|
||
historicalMetricsData.value = null
|
||
disposeCharts()
|
||
loadDetail()
|
||
if (activeTab.value === 'monitor') loadHistoricalMetrics()
|
||
}
|
||
|
||
watch(hostId, () => { if (isPageActive) initPage() })
|
||
onActivated(() => {
|
||
isPageActive = true
|
||
if (loadedHostId !== hostId.value) initPage()
|
||
})
|
||
onMounted(() => { isPageActive = true; initPage() })
|
||
onDeactivated(() => { isPageActive = false })
|
||
onBeforeUnmount(() => { isPageActive = false; disposeCharts() })
|
||
</script>
|
||
|
||
<style scoped>
|
||
.host-detail-page { padding: 0; }
|
||
.page-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 24px; background: #fff; border-bottom: 1px solid #e8e8e8; }
|
||
.header-left { display: flex; align-items: center; }
|
||
.back-btn { font-size: 14px; color: #606266; }
|
||
.back-btn:hover { color: #409eff; }
|
||
.header-right { display: flex; gap: 8px; }
|
||
|
||
.main-content { padding: 20px 24px; }
|
||
|
||
.instance-overview { display: flex; justify-content: space-between; align-items: center; background: #fff; padding: 20px 24px; border-radius: 4px 4px 0 0; border: 1px solid #e8e8e8; border-bottom: none; }
|
||
.instance-name { margin: 0; font-size: 18px; font-weight: 600; color: #1d2129; }
|
||
.instance-id { font-size: 14px; font-weight: 400; color: #86909c; margin-left: 8px; }
|
||
.overview-actions { display: flex; gap: 8px; }
|
||
|
||
.status-bar { display: flex; background: #fff; padding: 16px 24px; border: 1px solid #e8e8e8; border-top: 1px solid #f0f0f0; border-radius: 0 0 4px 4px; margin-bottom: 16px; }
|
||
.status-item { flex: 1; display: flex; flex-direction: column; gap: 4px; }
|
||
.status-item + .status-item { border-left: 1px solid #e8e8e8; padding-left: 24px; }
|
||
.status-label { font-size: 12px; color: #86909c; }
|
||
.status-value { font-size: 14px; color: #1d2129; font-weight: 500; display: flex; align-items: center; gap: 6px; }
|
||
.status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||
.dot-running { background: #00b42a; }
|
||
.dot-other { background: #c9cdd4; }
|
||
|
||
.detail-tabs { background: #fff; border-radius: 4px; border: 1px solid #e8e8e8; padding: 0 24px; }
|
||
:deep(.detail-tabs > .el-tabs__header) { margin-bottom: 0; }
|
||
:deep(.detail-tabs > .el-tabs__content) { padding: 0 0 24px; }
|
||
|
||
.section-block { margin-top: 20px; }
|
||
.section-title { font-size: 15px; font-weight: 600; color: #1d2129; margin: 0 0 16px; }
|
||
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||
.section-header .section-title { margin-bottom: 0; }
|
||
|
||
.config-grid { border: 1px solid #e8e8e8; border-radius: 4px; overflow: hidden; }
|
||
.config-row { display: flex; border-bottom: 1px solid #e8e8e8; }
|
||
.config-row:last-child { border-bottom: none; }
|
||
.config-cell { flex: 1; padding: 12px 16px; border-right: 1px solid #e8e8e8; }
|
||
.config-cell:last-child { border-right: none; }
|
||
.config-label { display: block; font-size: 12px; color: #86909c; margin-bottom: 4px; }
|
||
.config-value { display: block; font-size: 14px; color: #1d2129; word-break: break-all; }
|
||
.secret-cell { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
|
||
.secret-cell code { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.mono-text { font-family: 'Consolas', 'Monaco', monospace; }
|
||
|
||
.metrics-card { margin-bottom: 0; }
|
||
.metrics-title { font-weight: 600; font-size: 13px; display: inline-flex; align-items: center; gap: 6px; }
|
||
.metrics-title .el-icon { font-size: 16px; color: #409eff; }
|
||
.chart-container { width: 100%; height: 220px; }
|
||
|
||
.metric-summary-row { display: flex; gap: 16px; margin-bottom: 16px; }
|
||
.metric-summary-card { flex: 1; min-width: 0; background: #f7f8fa; border-radius: 6px; padding: 14px 16px; border: 1px solid #e8e8e8; display: flex; flex-direction: column; }
|
||
.metric-summary-label { font-size: 12px; color: #86909c; margin-bottom: 8px; }
|
||
.metric-summary-value { font-size: 22px; font-weight: 600; color: #1d2129; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.metric-summary-sub { font-size: 12px; color: #86909c; margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
|
||
.clickable { cursor: pointer; user-select: none; display: flex; align-items: center; gap: 6px; }
|
||
.clickable:hover { color: #409eff; }
|
||
.section-arrow { transition: transform 0.2s; font-size: 14px; }
|
||
.section-arrow.expanded { transform: rotate(90deg); }
|
||
.section-hint { font-size: 12px; color: #909399; font-weight: 400; }
|
||
.tk-section-title.clickable { cursor: pointer; user-select: none; display: flex; align-items: center; gap: 6px; }
|
||
.tk-section-title.clickable:hover { color: #409eff; }
|
||
.io-sub-title { font-size: 13px; font-weight: 500; color: #606266; margin: 12px 0 8px; display: flex; align-items: center; }
|
||
|
||
</style>
|