Files
ApiServer-Web-admin_dashboa…/src/views/virtualization/HostDetail.vue
T

1503 lines
73 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>