Compare commits
16 Commits
7394afb83f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 928d14aada | |||
| 1b44186e44 | |||
| 765f925482 | |||
| 9974a82ac8 | |||
| 61d777af8e | |||
| a443e4f147 | |||
| a5f8a9ef13 | |||
| 564e6cc017 | |||
| 98678859cb | |||
| dc63020943 | |||
| 59c5d16082 | |||
| ea571563e0 | |||
| e38ea4cc32 | |||
| 0dcce0822d | |||
| 802eaa396b | |||
| 3d783cd224 |
@@ -28,12 +28,15 @@ jobs:
|
||||
run: |
|
||||
pnpm build
|
||||
|
||||
- name: Compress artifacts
|
||||
run: |
|
||||
tar -czf dist.tar.gz -C ./dist .
|
||||
|
||||
- name: Save artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: vue3-build
|
||||
path: |
|
||||
./dist
|
||||
path: dist.tar.gz
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
@@ -49,11 +52,11 @@ jobs:
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.PUBLICT_PRIVATE_KEY }}" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan -H ${{ vars.WEB_SERVICE_SERVER_IP }} >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Deploy to server
|
||||
run: |
|
||||
ssh-keyscan -H ${{ vars.WEB_SERVICE_SERVER_IP_1 }} >> ~/.ssh/known_hosts
|
||||
scp -o StrictHostKeyChecking=no -r ./* ${{ vars.ROOT_USER_NAME }}@${{ vars.WEB_SERVICE_SERVER_IP_1 }}:/home/www/admin.007yjs.com/
|
||||
ssh-keyscan -H ${{ vars.WEB_SERVICE_SERVER_IP_2 }} >> ~/.ssh/known_hosts
|
||||
scp -o StrictHostKeyChecking=no -r ./* ${{ vars.ROOT_USER_NAME }}@${{ vars.WEB_SERVICE_SERVER_IP_2 }}:/home/www/admin.007yjs.com/
|
||||
|
||||
DEPLOY_DIR="/home/www/web-online/admin.007yjs.com/"
|
||||
ssh ${{ vars.ROOT_USER_NAME }}@${{ vars.WEB_SERVICE_SERVER_IP }} "mkdir -p $DEPLOY_DIR"
|
||||
scp -o StrictHostKeyChecking=no dist.tar.gz ${{ vars.ROOT_USER_NAME }}@${{ vars.WEB_SERVICE_SERVER_IP }}:$DEPLOY_DIR
|
||||
ssh ${{ vars.ROOT_USER_NAME }}@${{ vars.WEB_SERVICE_SERVER_IP }} "cd $DEPLOY_DIR && tar -xzf dist.tar.gz && rm -f dist.tar.gz"
|
||||
|
||||
@@ -24,12 +24,15 @@ jobs:
|
||||
run: |
|
||||
pnpm build
|
||||
|
||||
- name: Compress artifacts
|
||||
run: |
|
||||
tar -czf dist.tar.gz -C ./dist .
|
||||
|
||||
- name: Save artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: vue3-build
|
||||
path: |
|
||||
./dist
|
||||
path: dist.tar.gz
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
@@ -49,5 +52,7 @@ jobs:
|
||||
|
||||
- name: Deploy to server
|
||||
run: |
|
||||
scp -o StrictHostKeyChecking=no -r ./* ${{ vars.ROOT_USER_NAME }}@${{ vars.WEB_TEST_SERVER_IP }}:/www/wwwroot/apiserver_admin.s1f.ren/
|
||||
|
||||
DEPLOY_DIR="/www/wwwroot/apiserver_admin.s1f.ren/"
|
||||
ssh ${{ vars.ROOT_USER_NAME }}@${{ vars.WEB_TEST_SERVER_IP }} "mkdir -p $DEPLOY_DIR"
|
||||
scp -o StrictHostKeyChecking=no dist.tar.gz ${{ vars.ROOT_USER_NAME }}@${{ vars.WEB_TEST_SERVER_IP }}:$DEPLOY_DIR
|
||||
ssh ${{ vars.ROOT_USER_NAME }}@${{ vars.WEB_TEST_SERVER_IP }} "cd $DEPLOY_DIR && tar -xzf dist.tar.gz && rm -f dist.tar.gz"
|
||||
|
||||
@@ -284,6 +284,56 @@ export const deleteNetwork = (params) => {
|
||||
return http2.delete('/api/v1/admin/server/host_service/point/network/delete', { params })
|
||||
}
|
||||
|
||||
/** 设置主IP */
|
||||
export const setNetworkPrimary = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/network/set_primary', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 重置虚拟机MAC地址 */
|
||||
export const resetVmMac = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/vm/reset_mac', data, {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 断开虚拟机外部网络 */
|
||||
export const disconnectVmNetwork = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/vm/disconnect_network', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 恢复虚拟机外部网络 */
|
||||
export const connectVmNetwork = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/vm/connect_network', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 查询虚拟机每小时流量 */
|
||||
export const getVmTrafficHourly = (params) => {
|
||||
return http2.get('/api/v1/admin/server/host_service/point/vm/traffic_hourly', { params })
|
||||
}
|
||||
|
||||
/** 获取宿主机额度统计 */
|
||||
export const getHostQuotaStats = (params) => {
|
||||
return http2.get('/api/v1/admin/server/host_service/point/host/quota_stats', { params })
|
||||
}
|
||||
|
||||
/** 获取宿主机 KSM 状态 */
|
||||
export const getHostKsmStatus = (params) => {
|
||||
return http2.get('/api/v1/admin/server/host_service/point/host/ksm/status', { params })
|
||||
}
|
||||
|
||||
/** 配置宿主机 KSM */
|
||||
export const configureHostKsm = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/host/ksm/configure', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* ================================
|
||||
* 主控服务接口 - 数据卷管理
|
||||
|
||||
@@ -88,6 +88,10 @@ export const deleteUserVmPostGroupRule = (params) => http2.delete(`${BASE}/post_
|
||||
// ========== 网络 ==========
|
||||
export const getUserVmNetworkList = (params) => http2.get(`${BASE}/network/list`, { params })
|
||||
export const getUserVmNetworkDetail = (params) => http2.get(`${BASE}/network/detail`, { params })
|
||||
export const setUserVmNetworkPrimary = (data) => http2.post(`${BASE}/network/set_primary`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
export const resetUserVmMac = (params) => http2.post(`${BASE}/reset_mac`, null, { params })
|
||||
export const disconnectUserVmNetwork = (data) => http2.post(`${BASE}/disconnect_network`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
export const connectUserVmNetwork = (data) => http2.post(`${BASE}/connect_network`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
|
||||
// ========== 组网 ==========
|
||||
export const getUserVmNetworkingList = (params) => http2.get(`${BASE}/networking/list`, { params })
|
||||
@@ -105,6 +109,7 @@ export const updateUserGoods = (data) => http2.post(`${GOODS_BASE}/update`, fd(d
|
||||
export const deleteUserGoods = (params) => http2.delete(`${GOODS_BASE}/delete`, { params })
|
||||
|
||||
export const getUserVmMetricsHistory = (params) => http2.get(`${BASE}/metrics_history`, { params })
|
||||
export const getUserVmTrafficHourly = (params) => http2.get(`${BASE}/traffic_hourly`, { params })
|
||||
|
||||
// ========== 流量策略 ==========
|
||||
// 测试未通过(接口新增,待联调)
|
||||
|
||||
@@ -124,13 +124,19 @@ const submitCreate = async () => {
|
||||
lock: createForm.lock,
|
||||
drop_all: createForm.drop_all
|
||||
})
|
||||
if (res?.data?.code === 200) {
|
||||
const code = res?.data?.code
|
||||
if (code === 200 || code === 201 || (code >= 200 && code < 300)) {
|
||||
ElMessage.success('创建成功')
|
||||
showCreate.value = false
|
||||
Object.assign(createForm, { name: '', direction: 'in', lock: false, drop_all: false })
|
||||
loadList()
|
||||
} else ElMessage.error(res?.data?.message || '创建失败')
|
||||
} catch { ElMessage.error('创建失败') } finally { createLoading.value = false }
|
||||
await loadList()
|
||||
} else {
|
||||
ElMessage.error(res?.data?.message || res?.data?.error || '创建失败')
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e?.response?.data?.message || e?.response?.data?.error || e?.message || '创建失败'
|
||||
ElMessage.error(msg)
|
||||
} finally { createLoading.value = false }
|
||||
}
|
||||
|
||||
const handleClose = () => { visible.value = false }
|
||||
|
||||
@@ -474,7 +474,7 @@ const orderRules = {
|
||||
{ type: 'number', message: '用户ID必须是数字', trigger: 'blur' }
|
||||
],
|
||||
coupon_id: [
|
||||
{ required: true, message: '请输入代金券ID', trigger: 'blur' },
|
||||
{ message: '请输入代金券ID', trigger: 'blur' },
|
||||
{ type: 'number', message: '代金券ID必须是数字', trigger: 'blur' }
|
||||
],
|
||||
pay_num: [
|
||||
@@ -756,15 +756,18 @@ const submitForm = () => {
|
||||
expireTimeSeconds = timestamp || 0
|
||||
}
|
||||
|
||||
// 准备提交的数据
|
||||
// 2026-05-12: /api/v1/admin/order/create 与 /update 入参 price / renew_price 单位由
|
||||
// "分"改为"元"(后端接口变更,用户确认两端都已生效);列表与表单内部仍按"分"持有,
|
||||
// 提交时统一除以 100 做"分→元"换算,避免再次入库时被当成元导致额外放大 100 倍。
|
||||
// 输入框旁的"分"单位文案暂不改动(用户明确仅要求 /100),UI 一致性问题待后续单独处理。
|
||||
const submitData = {
|
||||
name: orderForm.name,
|
||||
table: orderForm.table,
|
||||
user_id: Number(orderForm.user_id),
|
||||
commodity_id: Number(orderForm.commodity_id),
|
||||
pay_num: Number(orderForm.pay_num),
|
||||
price: Number(orderForm.price),
|
||||
renew_price: Number(orderForm.renew_price),
|
||||
price: Number(orderForm.price) / 100,
|
||||
renew_price: Number(orderForm.renew_price) / 100,
|
||||
expire_time: expireTimeSeconds,
|
||||
discount_code_id: Number(orderForm.discount_code_id),
|
||||
coupon_id: Number(orderForm.coupon_id),
|
||||
|
||||
@@ -106,6 +106,13 @@
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="售罄" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.soldOut ? 'danger' : 'success'" size="small">
|
||||
{{ row.soldOut ? '售罄' : '在售' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="280" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
|
||||
@@ -234,6 +241,10 @@
|
||||
<el-form-item label="库存数量" prop="inventory">
|
||||
<el-input-number v-model="productForm.inventory" :min="0" placeholder="请输入库存" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="售罄状态" prop="sold_out">
|
||||
<el-switch v-model="productForm.sold_out" active-text="已售罄" inactive-text="正常" active-color="#f56c6c" />
|
||||
<div class="form-tip">开启后用户端无法选购该商品,KVM 商品可由后台定时任务根据 IP 资源自动标记</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="商品价格" prop="price">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="productForm.price" :min="0" :precision="2" :step="0.01" placeholder="请输入价格(元)" style="flex:1" />
|
||||
@@ -957,7 +968,8 @@ const productForm = reactive({
|
||||
recommend: false,
|
||||
recommend_rebate: 0,
|
||||
arg_type: 'all', // 商品参数类型 all/plan/customize
|
||||
attribution_id: '' // 归属项ID
|
||||
attribution_id: '', // 归属项ID
|
||||
sold_out: false
|
||||
})
|
||||
|
||||
const productRules = {
|
||||
@@ -1261,7 +1273,8 @@ const handleEdit = (row) => {
|
||||
expire_time: row.expireTime,
|
||||
recommend: row.recommend,
|
||||
recommend_rebate: row.recommendRebate,
|
||||
arg_type: row.argType || 'all'
|
||||
arg_type: row.argType || 'all',
|
||||
sold_out: !!row.soldOut
|
||||
})
|
||||
coverPreviewUrl.value = row.cover || ''
|
||||
}
|
||||
|
||||
@@ -162,6 +162,17 @@
|
||||
<el-form-item label="说明" prop="note">
|
||||
<el-input v-model="planForm.note" type="textarea" :rows="2" placeholder="请输入套餐说明" />
|
||||
</el-form-item>
|
||||
<div v-if="missingMustSpecs.length > 0" class="must-params-alert">
|
||||
<div class="must-params-alert-header">
|
||||
<el-icon><WarningFilled /></el-icon>
|
||||
<span>以下必填参数尚未配置,请将其加入「参数配置」或「额外参数」中:</span>
|
||||
</div>
|
||||
<div class="must-params-alert-tags">
|
||||
<el-tag v-for="spec in missingMustSpecs" :key="spec.id" type="danger" size="small" effect="plain">
|
||||
<span class="must-star">*</span>{{ spec.name }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<el-form-item label="参数配置" prop="args">
|
||||
<div class="args-config-container">
|
||||
<div class="args-select-row">
|
||||
@@ -385,6 +396,10 @@ const selectedExtraArgIds = ref([])
|
||||
|
||||
const selectedArgSpecs = computed(() => planSpecList.value.filter(spec => selectedArgIds.value.includes(spec.id)))
|
||||
const extraSpecList = computed(() => planSpecList.value.filter(spec => !selectedArgIds.value.includes(spec.id)))
|
||||
const missingMustSpecs = computed(() => {
|
||||
const allSelectedIds = [...selectedArgIds.value, ...selectedExtraArgIds.value]
|
||||
return planSpecList.value.filter(s => s.must && !allSelectedIds.includes(s.id))
|
||||
})
|
||||
|
||||
const getSpecDisplayMin = (spec) => {
|
||||
if (!hasUnit(spec)) return spec.min ?? 0
|
||||
@@ -1184,6 +1199,26 @@ watch(() => props.visible, (val) => {
|
||||
.plan-card-actions .el-button + .el-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
.must-params-alert {
|
||||
margin: 0 0 16px;
|
||||
padding: 10px 14px;
|
||||
background: linear-gradient(135deg, #fff5f5 0%, #fff1f0 100%);
|
||||
border: 1px solid #fcdcdc;
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid #f56c6c;
|
||||
}
|
||||
.must-params-alert-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #f56c6c;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.must-params-alert-header .el-icon { font-size: 16px; flex-shrink: 0; }
|
||||
.must-params-alert-tags { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.must-params-alert-tags .must-star { color: #f56c6c; font-weight: 700; margin-right: 2px; }
|
||||
.plan-form-content { max-height: 60vh; overflow-y: auto; padding-right: 8px; margin-right: -8px; }
|
||||
.plan-form-content::-webkit-scrollbar { width: 6px; }
|
||||
.plan-form-content::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
+1501
-2528
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@
|
||||
<div class="name-row">
|
||||
<h2 class="vm-name">{{ vm?.name || userGoods.good?.name || `用户虚拟机 #${userGoodsId}` }}</h2>
|
||||
<el-tag v-if="vm?.status" :type="vmStatusType(vm.status)" size="small" style="margin-left:8px">{{ vmStatusLabel(vm.status) }}</el-tag>
|
||||
<el-tag v-if="vm?.network_disabled" size="small" type="danger" effect="dark" style="margin-left:4px">已断网</el-tag>
|
||||
<el-tag v-if="vm?.rescue" size="small" type="danger" effect="dark" style="margin-left:4px">救援模式</el-tag>
|
||||
<el-tag v-if="userGoods.tag" size="small" type="info" style="margin-left:4px">{{ userGoods.tag }}</el-tag>
|
||||
</div>
|
||||
@@ -46,6 +47,9 @@
|
||||
<el-dropdown-item command="resume">恢复</el-dropdown-item>
|
||||
<el-dropdown-item command="rescue" :disabled="!!vm?.rescue">救援模式</el-dropdown-item>
|
||||
<el-dropdown-item command="exitRescue" :disabled="!vm?.rescue">退出救援</el-dropdown-item>
|
||||
<el-dropdown-item command="resetMac">重置MAC地址</el-dropdown-item>
|
||||
<el-dropdown-item command="disconnectNetwork" :disabled="vm?.network_disabled">断网</el-dropdown-item>
|
||||
<el-dropdown-item command="connectNetwork" :disabled="!vm?.network_disabled">恢复网络</el-dropdown-item>
|
||||
<el-dropdown-item divided command="rebuild">重装系统</el-dropdown-item>
|
||||
<el-dropdown-item command="updateVm">编辑虚拟机</el-dropdown-item>
|
||||
<el-dropdown-item command="refactorVm">重构虚拟机</el-dropdown-item>
|
||||
@@ -63,14 +67,20 @@
|
||||
<!-- VM 配置信息 -->
|
||||
<div class="vm-config-grid" v-if="vm">
|
||||
<div class="config-row">
|
||||
<div class="config-cell"><span class="config-label">vCPU</span><span class="config-value">{{ vm.vcpu || '-' }} 核</span></div>
|
||||
<div class="config-cell"><span class="config-label">内存</span><span class="config-value">{{ formatMemory(vm.memory) }}</span></div>
|
||||
<div class="config-cell"><span class="config-label">下行带宽</span><span class="config-value">{{ vm.rx_bandwidth || 0 }} Mbps</span></div>
|
||||
<div class="config-cell"><span class="config-label">上行带宽</span><span class="config-value">{{ vm.tx_bandwidth || 0 }} Mbps</span></div>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<div class="config-cell"><span class="config-label">vCPU / 内存</span><span class="config-value">{{ vm.vcpu || '-' }} 核 / {{ formatMemory(vm.memory) }}</span></div>
|
||||
<div class="config-cell">
|
||||
<span class="config-label">带宽 ↓ / ↑</span>
|
||||
<span class="config-value">
|
||||
{{ vm.rx_bandwidth || 0 }} / {{ vm.tx_bandwidth || 0 }} Mbps
|
||||
<el-button link type="primary" size="small" class="cfg-edit-btn" @click="handleMoreCmd('updateTraffic')">
|
||||
<el-icon :size="14"><Edit /></el-icon>修改
|
||||
</el-button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="config-cell"><span class="config-label">用户名</span><span class="config-value" style="font-weight:500">{{ isWindows ? 'Administrator' : 'root' }}</span></div>
|
||||
<div class="config-cell"><span class="config-label">远程端口</span><span class="config-value">{{ isWindows ? (vm.ssh_port && vm.ssh_port !== 22 ? vm.ssh_port : 3389) : (vm.ssh_port || 22) }}</span></div>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<div class="config-cell">
|
||||
<span class="config-label">外网IP</span>
|
||||
<span class="config-value ip-value" v-if="vmPublicIpList.length">
|
||||
@@ -129,7 +139,25 @@
|
||||
</el-button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="config-cell"><span class="config-label">流量上限</span><span class="config-value">{{ formatTraffic(vm.traffic_max) }}</span></div>
|
||||
<div class="config-cell">
|
||||
<span class="config-label">流量</span>
|
||||
<span class="config-value traffic-cell">
|
||||
<span class="traffic-main">{{ formatTraffic(trafficUsedMb) }} / {{ formatTraffic(trafficTotalMb) }}</span>
|
||||
<span class="traffic-sub">
|
||||
基础 {{ formatTraffic(trafficBaseMb) }}
|
||||
<template v-if="trafficTempMb > 0">
|
||||
· 临时 {{ formatTraffic(trafficTempMb) }}
|
||||
<span v-if="trafficCycleStartText" class="traffic-cycle">(周期 {{ trafficCycleStartText }})</span>
|
||||
</template>
|
||||
</span>
|
||||
<span class="traffic-actions">
|
||||
<el-button link type="primary" size="small" class="cfg-edit-btn" @click="openTrafficPolicyDialog">
|
||||
<el-icon :size="14"><Edit /></el-icon>修改
|
||||
</el-button>
|
||||
<el-button link type="warning" size="small" class="cfg-edit-btn" @click="openAddTrafficDialog('temporary')">加临时</el-button>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="config-cell"><span class="config-label">续费价格</span><span class="config-value">¥{{ (userGoods.renewPrice / 100).toFixed(2) }}</span></div>
|
||||
<div class="config-cell"><span class="config-label">基础价格</span><span class="config-value">¥{{ (userGoods.basePrice / 100).toFixed(2) }}</span></div>
|
||||
</div>
|
||||
@@ -305,7 +333,20 @@
|
||||
<el-table-column prop="address" label="地址(CIDR)" min-width="150" />
|
||||
<el-table-column prop="gateway" label="网关" min-width="120" />
|
||||
<el-table-column prop="mac_address" label="MAC" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="主IP" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.is_primary" type="success" size="small" effect="dark">主</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="80"><template #default="{ row }"><el-tag :type="row.type === 'bridge' ? 'success' : 'warning'" size="small">{{ row.type === 'bridge' ? '网桥' : 'NAT' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="!row.is_primary" link type="warning" size="small" @click="handleSetVmNetworkPrimary(row)">设为主IP</el-button>
|
||||
<el-button link type="danger" size="small" :loading="deletingNetworkId === row.id" @click="handleDeleteVmNetwork(row)">
|
||||
<el-icon :size="14"><Delete /></el-icon>删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!vmNetworks.length" :image-size="60" description="暂无网络" />
|
||||
</el-tab-pane>
|
||||
@@ -398,6 +439,11 @@
|
||||
<div class="metric-summary-value">↓{{ formatNetLabel(latestMetrics.net_rx) }}</div>
|
||||
<div class="metric-summary-sub">↑{{ formatNetLabel(latestMetrics.net_tx) }}</div>
|
||||
</div>
|
||||
<div class="metric-summary-card">
|
||||
<div class="metric-summary-label">累计流量</div>
|
||||
<div class="metric-summary-value">{{ latestMetrics.traffic_used_mb != null ? latestMetrics.traffic_used_mb + ' MB' : '-' }}</div>
|
||||
<div class="metric-summary-sub"> </div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="metricsData">
|
||||
@@ -410,7 +456,7 @@
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card shadow="hover" class="metrics-card">
|
||||
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> 内存使用率</span></template>
|
||||
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> 内存使用</span></template>
|
||||
<div ref="memChartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
@@ -429,13 +475,27 @@
|
||||
</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><Monitor /></el-icon> 磁盘 IOPS</span></template>
|
||||
<div ref="diskIopsChartRef" 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><Monitor /></el-icon> 流量使用趋势</span></template>
|
||||
<div ref="trafficUsedChartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
<el-empty v-else-if="!metricsLoading" description="暂无监控数据" :image-size="80" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 流量策略 -->
|
||||
<el-tab-pane v-if="isVmGoods" label="流量策略" name="trafficPolicy">
|
||||
<!-- 流量管理(合并流量策略 + 流量统计) -->
|
||||
<el-tab-pane v-if="isVmGoods" label="流量管理" name="trafficManage">
|
||||
<div class="section-block">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">流量策略</h3>
|
||||
@@ -453,6 +513,30 @@
|
||||
</el-descriptions>
|
||||
<el-empty v-else-if="!trafficPolicyLoading" description="暂无流量策略数据" :image-size="60" />
|
||||
</div>
|
||||
<div class="section-block" style="margin-top:16px">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">每小时流量</h3>
|
||||
<div style="display: flex; align-items: center; gap: 8px">
|
||||
<el-date-picker
|
||||
v-model="trafficHourlyRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
size="small"
|
||||
style="width: 360px"
|
||||
:shortcuts="monitorShortcuts"
|
||||
@change="loadTrafficHourly"
|
||||
/>
|
||||
<el-button size="small" :icon="Refresh" @click="loadTrafficHourly" :loading="trafficHourlyLoading">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-card v-if="trafficHourlyData.length" shadow="hover" class="metrics-card" style="margin-top:12px">
|
||||
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 每小时流量(MB)</span></template>
|
||||
<div ref="trafficHourlyChartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
<el-empty v-else-if="!trafficHourlyLoading" description="暂无流量统计数据" :image-size="60" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
</el-tabs>
|
||||
@@ -1072,7 +1156,7 @@
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount, onActivated, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Refresh, ArrowDown, Monitor, WarningFilled, View, Hide, CopyDocument } from '@element-plus/icons-vue'
|
||||
import { ArrowLeft, Refresh, ArrowDown, Monitor, WarningFilled, View, Hide, CopyDocument, Edit, Delete } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getUserVmDetail, getUserVmVnc, getUserVmHostImages,
|
||||
startUserVm, stopUserVm, rebootUserVm, suspendUserVm, resumeUserVm, rescueUserVm, exitRescueUserVm, rebuildUserVm, deleteUserVm,
|
||||
@@ -1083,11 +1167,14 @@ import {
|
||||
getUserVmPostGroupList, createUserVmPostGroup, updateUserVmPostGroup, bindUserVmPostGroup, unbindUserVmPostGroup, applyUserVmPostGroup, deleteUserVmPostGroup, enableUserVmPostGroupWhitelist, disableUserVmPostGroupWhitelist,
|
||||
createUserVmPostGroupRule, updateUserVmPostGroupRule, deleteUserVmPostGroupRule,
|
||||
getUserVmPostGroupDetail,
|
||||
getUserVmNetworkList, getUserVmNetworkingList, createUserVmNetworking, assignUserVmNetworking, removeUserVmNetworkingNetwork, deleteUserVmNetworking,
|
||||
getUserVmNetworkList, getUserVmNetworkDetail, getUserVmNetworkingList, createUserVmNetworking, assignUserVmNetworking, removeUserVmNetworkingNetwork, deleteUserVmNetworking,
|
||||
getUserGoodsDetail,
|
||||
getUserVmMetricsHistory,
|
||||
getUserVmTrafficPolicy, updateUserVmTrafficPolicy, addUserVmFixedTraffic, addUserVmTemporaryTraffic
|
||||
getUserVmMetricsHistory, getUserVmTrafficHourly,
|
||||
getUserVmTrafficPolicy, updateUserVmTrafficPolicy, addUserVmFixedTraffic, addUserVmTemporaryTraffic,
|
||||
setUserVmNetworkPrimary, resetUserVmMac,
|
||||
disconnectUserVmNetwork, connectUserVmNetwork
|
||||
} from '@/api/admin/userVm'
|
||||
import { deleteNetwork as deletePointNetwork } from '@/api/admin/kvmService'
|
||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||
import { vmStatusLabel as vmStatusLabelUtil, vmStatusType as vmStatusTypeUtil, volumeStatusLabel, volumeStatusType } from '@/utils/tool'
|
||||
import UserSelector from '@/components/UserSelector/index.vue'
|
||||
@@ -1164,6 +1251,18 @@ const copyAllIps = async (ipList) => {
|
||||
|
||||
const isWindows = computed(() => vmImage.value?.os_type === 'windows')
|
||||
|
||||
// 流量字段优先使用 traffic_max_mb(add.json 新字段),fallback 旧字段 traffic_max
|
||||
// traffic_used 单位假设为 MB(与 traffic_max_mb 同维度),如后端实为字节需调整
|
||||
const trafficBaseMb = computed(() => Number(vm.value?.traffic_max_mb ?? vm.value?.traffic_max ?? 0) || 0)
|
||||
const trafficTempMb = computed(() => Number(vm.value?.temporary_traffic_mb || 0))
|
||||
const trafficTotalMb = computed(() => trafficBaseMb.value + trafficTempMb.value)
|
||||
const trafficUsedMb = computed(() => Number(vm.value?.traffic_used || 0))
|
||||
const trafficCycleStartText = computed(() => {
|
||||
const t = vm.value?.temporary_cycle_start
|
||||
const sec = typeof t === 'object' ? (t?.seconds ?? null) : t
|
||||
return sec ? dayjs(Number(sec) * 1000).format('YYYY-MM-DD') : ''
|
||||
})
|
||||
|
||||
const vmPublicIpList = computed(() => {
|
||||
return vmNetworks.value.filter(n => n.type === 'bridge').map(n => n.address ? n.address.split('/')[0] : n.name).filter(Boolean)
|
||||
})
|
||||
@@ -1262,7 +1361,7 @@ const handleTabChange = (tab) => {
|
||||
if (tab === 'security') loadSgLockInfo()
|
||||
if (tab === 'networking') loadNetworkings()
|
||||
if (tab === 'monitor' && !metricsData.value) loadMetricsHistory()
|
||||
if (tab === 'trafficPolicy') loadTrafficPolicy()
|
||||
if (tab === 'trafficManage') { loadTrafficPolicy(); loadTrafficHourly() }
|
||||
}
|
||||
|
||||
// 请求安全组详情补充 lock 字段(使用用户虚拟机安全组详情接口)
|
||||
@@ -1319,6 +1418,9 @@ const handleMoreCmd = (cmd) => {
|
||||
if (cmd === 'refactorVm') openRefactorVm()
|
||||
if (cmd === 'migrate') openMigrateVm()
|
||||
if (cmd === 'editGoods') openEditGoods()
|
||||
if (cmd === 'resetMac') handleResetVmMac()
|
||||
if (cmd === 'disconnectNetwork') handleDisconnectVmNetwork()
|
||||
if (cmd === 'connectNetwork') handleConnectVmNetwork()
|
||||
if (cmd === 'delete') {
|
||||
ElMessageBox.confirm('确定删除该用户虚拟机吗?', '删除确认', { type: 'error' }).then(async () => {
|
||||
try {
|
||||
@@ -1644,6 +1746,101 @@ const loadNetworks = async () => {
|
||||
} catch { /* */ } finally { networkLoading.value = false }
|
||||
}
|
||||
|
||||
// ---- 删除网络 ----
|
||||
// 走 host_service/point/network/delete:删除底层物理网络(破坏性,会影响其他绑定该网络的 VM)
|
||||
// row 字段可能不完整,service_id / host_id 通过 getUserVmNetworkDetail 兜底反查
|
||||
const deletingNetworkId = ref(0)
|
||||
const resolveNetworkServiceHost = async (row) => {
|
||||
let serviceId = row.service_id ?? row.host_service_id ?? row.kvm_service_id ?? 0
|
||||
let hostId = row.host_id ?? 0
|
||||
if (!serviceId || !hostId) {
|
||||
try {
|
||||
const res = await getUserVmNetworkDetail({ user_goods_id: userGoodsId.value, id: row.id })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const d = res.data.data
|
||||
const n = d.data || d.network || d
|
||||
if (!serviceId) serviceId = n?.service_id ?? n?.host_service_id ?? n?.kvm_service_id ?? 0
|
||||
if (!hostId) hostId = n?.host_id ?? 0
|
||||
}
|
||||
} catch { /* 兜底失败时返回原值,由调用方判断 */ }
|
||||
}
|
||||
return { serviceId, hostId }
|
||||
}
|
||||
const handleDeleteVmNetwork = (row) => {
|
||||
ElMessageBox.confirm(
|
||||
`将删除底层网络「${row.name}」(ID:${row.id}),该操作会影响所有绑定该网络的虚拟机,是否继续?`,
|
||||
'删除网络',
|
||||
{ confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning' }
|
||||
).then(async () => {
|
||||
deletingNetworkId.value = row.id
|
||||
try {
|
||||
const { serviceId, hostId } = await resolveNetworkServiceHost(row)
|
||||
if (!serviceId) { ElMessage.error('无法获取该网络所属服务ID,删除失败'); return }
|
||||
const params = { service_id: serviceId, network_id: row.id }
|
||||
if (hostId) params.host_id = hostId
|
||||
const res = await deletePointNetwork(params)
|
||||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadDetail() }
|
||||
else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||||
} catch (e) {
|
||||
ElMessage.error(extractApiError(e?.response?.data, '删除失败'))
|
||||
} finally {
|
||||
deletingNetworkId.value = 0
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleSetVmNetworkPrimary = (row) => {
|
||||
ElMessageBox.confirm(
|
||||
`将「${row.address || row.name}」设为主IP?设置后将重启虚拟机。`,
|
||||
'设置主IP', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
|
||||
).then(async () => {
|
||||
try {
|
||||
const res = await setUserVmNetworkPrimary({ user_goods_id: userGoodsId.value, network_id: row.id })
|
||||
if (res?.data?.code === 200) { ElMessage.success('设置主IP成功,虚拟机将重启'); loadDetail() }
|
||||
else ElMessage.error(extractApiError(res?.data, '设置主IP失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '设置主IP失败')) }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleResetVmMac = () => {
|
||||
ElMessageBox.confirm(
|
||||
'重置MAC地址将重建 CloudInit 并重启虚拟机,确定继续?',
|
||||
'重置MAC地址', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
|
||||
).then(async () => {
|
||||
try {
|
||||
const res = await resetUserVmMac({ user_goods_id: userGoodsId.value })
|
||||
if (res?.data?.code === 200) { ElMessage.success('MAC地址重置成功,虚拟机将重启'); loadDetail() }
|
||||
else ElMessage.error(extractApiError(res?.data, '重置MAC失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '重置MAC失败')) }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleDisconnectVmNetwork = () => {
|
||||
ElMessageBox.confirm(
|
||||
'断开虚拟机外部网络连接?断网后虚拟机将无法访问外部网络。',
|
||||
'断网', { confirmButtonText: '确定断网', cancelButtonText: '取消', type: 'warning' }
|
||||
).then(async () => {
|
||||
try {
|
||||
const res = await disconnectUserVmNetwork({ user_goods_id: userGoodsId.value })
|
||||
if (res?.data?.code === 200) { ElMessage.success('已断网'); loadDetail() }
|
||||
else ElMessage.error(extractApiError(res?.data, '断网失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '断网失败')) }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleConnectVmNetwork = () => {
|
||||
ElMessageBox.confirm(
|
||||
'恢复虚拟机外部网络连接?',
|
||||
'恢复网络', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'info' }
|
||||
).then(async () => {
|
||||
try {
|
||||
const res = await connectUserVmNetwork({ user_goods_id: userGoodsId.value })
|
||||
if (res?.data?.code === 200) { ElMessage.success('网络已恢复'); loadDetail() }
|
||||
else ElMessage.error(extractApiError(res?.data, '恢复网络失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '恢复网络失败')) }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// ---- 绑定网络 ----
|
||||
const showBindNetworkSelector = ref(false)
|
||||
|
||||
@@ -2019,12 +2216,15 @@ const loadTrafficPolicy = async () => {
|
||||
}
|
||||
|
||||
const openTrafficPolicyDialog = () => {
|
||||
// 从概览触发时 trafficPolicy 可能尚未加载(trafficPolicy tab 懒加载),
|
||||
// 用 vm.value 上 add.json 新字段作 fallback;同时异步加载策略以便后续 submit 时数据准确
|
||||
Object.assign(trafficPolicyForm, {
|
||||
traffic_max_mb: trafficPolicy.value?.traffic_max_mb || 0,
|
||||
exhausted_rx_mbps: trafficPolicy.value?.exhausted_rx_mbps || 0,
|
||||
exhausted_tx_mbps: trafficPolicy.value?.exhausted_tx_mbps || 0
|
||||
traffic_max_mb: trafficPolicy.value?.traffic_max_mb ?? vm.value?.traffic_max_mb ?? vm.value?.traffic_max ?? 0,
|
||||
exhausted_rx_mbps: trafficPolicy.value?.exhausted_rx_mbps ?? vm.value?.traffic_exhausted_rx_mbps ?? 0,
|
||||
exhausted_tx_mbps: trafficPolicy.value?.exhausted_tx_mbps ?? vm.value?.traffic_exhausted_tx_mbps ?? 0
|
||||
})
|
||||
trafficPolicyVisible.value = true
|
||||
if (!trafficPolicy.value) loadTrafficPolicy()
|
||||
}
|
||||
|
||||
const submitUpdateTrafficPolicy = async () => {
|
||||
@@ -2058,6 +2258,55 @@ const submitAddTraffic = async () => {
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { trafficPolicyLoading.value = false }
|
||||
}
|
||||
|
||||
// ---- 每小时流量统计 ----
|
||||
const trafficHourlyRange = ref(null)
|
||||
const trafficHourlyData = ref([])
|
||||
const trafficHourlyLoading = ref(false)
|
||||
const trafficHourlyChartRef = ref(null)
|
||||
let trafficHourlyChart = null
|
||||
|
||||
const loadTrafficHourly = async () => {
|
||||
if (!userGoodsId.value) return
|
||||
if (!trafficHourlyRange.value) {
|
||||
const now = new Date()
|
||||
trafficHourlyRange.value = [new Date(now.getTime() - 24 * 3600 * 1000), now]
|
||||
}
|
||||
trafficHourlyLoading.value = true
|
||||
try {
|
||||
const res = await getUserVmTrafficHourly({
|
||||
user_goods_id: userGoodsId.value,
|
||||
start: new Date(trafficHourlyRange.value[0]).toISOString(),
|
||||
end_time: new Date(trafficHourlyRange.value[1]).toISOString()
|
||||
})
|
||||
const raw = res?.data?.data?.data
|
||||
trafficHourlyData.value = typeof raw === 'string' ? JSON.parse(raw) : (Array.isArray(raw) ? raw : [])
|
||||
nextTick(renderTrafficHourlyChart)
|
||||
} catch { trafficHourlyData.value = [] } finally { trafficHourlyLoading.value = false }
|
||||
}
|
||||
|
||||
const renderTrafficHourlyChart = () => {
|
||||
if (!trafficHourlyChartRef.value || !trafficHourlyData.value.length) return
|
||||
if (!trafficHourlyChart) trafficHourlyChart = echarts.init(trafficHourlyChartRef.value)
|
||||
const data = trafficHourlyData.value
|
||||
const times = data.map(d => {
|
||||
const date = new Date(d.bucket)
|
||||
return date.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' })
|
||||
})
|
||||
const toMB = (b) => +(b / 1048576).toFixed(2)
|
||||
trafficHourlyChart.setOption({
|
||||
tooltip: { trigger: 'axis', formatter: (params) => params.map(p => `${p.marker}${p.seriesName}: ${p.value} MB`).join('<br/>') },
|
||||
legend: { data: ['下行', '上行', '合计'], bottom: 0 },
|
||||
grid: { top: 10, right: 16, bottom: 40, left: 50 },
|
||||
xAxis: { type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 10 } },
|
||||
yAxis: { type: 'value', name: 'MB', axisLabel: { fontSize: 10 } },
|
||||
series: [
|
||||
{ name: '下行', type: 'bar', stack: 'traffic', data: data.map(d => toMB(d.rx_bytes)), itemStyle: { color: '#409EFF' } },
|
||||
{ name: '上行', type: 'bar', stack: 'traffic', data: data.map(d => toMB(d.tx_bytes)), itemStyle: { color: '#67C23A' } },
|
||||
{ name: '合计', type: 'line', smooth: true, data: data.map(d => toMB(d.total_bytes)), itemStyle: { color: '#E6A23C' }, lineStyle: { width: 2 } }
|
||||
]
|
||||
}, true)
|
||||
}
|
||||
|
||||
// ---- 转移 ----
|
||||
const transferVisible = ref(false)
|
||||
const showTransferUserSelector = ref(false)
|
||||
@@ -2110,11 +2359,15 @@ const submitEditGoods = async () => {
|
||||
const cpuChartRef = ref(null)
|
||||
const memChartRef = ref(null)
|
||||
const diskChartRef = ref(null)
|
||||
const diskIopsChartRef = ref(null)
|
||||
const netChartRef = ref(null)
|
||||
const trafficUsedChartRef = ref(null)
|
||||
let cpuChart = null
|
||||
let memChart = null
|
||||
let diskChart = null
|
||||
let diskIopsChart = null
|
||||
let netChart = null
|
||||
let trafficUsedChart = null
|
||||
|
||||
const metricsData = ref(null)
|
||||
const metricsLoading = ref(false)
|
||||
@@ -2241,9 +2494,28 @@ const renderMetricsCharts = () => {
|
||||
})
|
||||
|
||||
const cpuData = metrics.map(m => m.cpu_usage ?? 0)
|
||||
const memData = metrics.map(m => m.mem_total ? ((m.mem_used / m.mem_total) * 100) : 0)
|
||||
const memData = metrics.map(m => m.mem_used ?? 0)
|
||||
const memTotal = Math.max(...metrics.map(m => m.mem_total ?? 0))
|
||||
const memAxisFmt = memTotal >= 1048576
|
||||
? (v) => (v / 1048576).toFixed(1) + ' GB'
|
||||
: memTotal >= 1024
|
||||
? (v) => (v / 1024).toFixed(0) + ' MB'
|
||||
: (v) => v + ' KB'
|
||||
|
||||
const diskReadData = metrics.map(m => m.disk_read ?? 0)
|
||||
const diskWriteData = metrics.map(m => m.disk_write ?? 0)
|
||||
|
||||
const diskReadRate = []
|
||||
const diskWriteRate = []
|
||||
for (let i = 0; i < metrics.length; i++) {
|
||||
if (i === 0) { diskReadRate.push(0); diskWriteRate.push(0); continue }
|
||||
const dt = (new Date(metrics[i].bucket) - new Date(metrics[i - 1].bucket)) / 1000
|
||||
if (dt > 0) {
|
||||
diskReadRate.push(+Math.max(0, ((metrics[i].disk_read ?? 0) - (metrics[i - 1].disk_read ?? 0)) / dt / 1024).toFixed(2))
|
||||
diskWriteRate.push(+Math.max(0, ((metrics[i].disk_write ?? 0) - (metrics[i - 1].disk_write ?? 0)) / dt / 1024).toFixed(2))
|
||||
} else { diskReadRate.push(0); diskWriteRate.push(0) }
|
||||
}
|
||||
|
||||
const netRxData = metrics.map(m => m.net_rx ?? 0)
|
||||
const netTxData = metrics.map(m => m.net_tx ?? 0)
|
||||
|
||||
@@ -2264,9 +2536,9 @@ const renderMetricsCharts = () => {
|
||||
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 + '%' } },
|
||||
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} 内存: ${formatMemKB(p[0].value)}` },
|
||||
grid: { ...baseGrid, left: 60 }, xAxis: makeXAxis(),
|
||||
yAxis: { type: 'value', min: 0, max: memTotal || undefined, axisLabel: { fontSize: 10, formatter: memAxisFmt } },
|
||||
series: [makeSeries('内存', memData, '#67c23a')]
|
||||
}, true)
|
||||
}
|
||||
@@ -2285,6 +2557,20 @@ const renderMetricsCharts = () => {
|
||||
}, true)
|
||||
}
|
||||
|
||||
if (diskIopsChartRef.value) {
|
||||
if (!diskIopsChart) diskIopsChart = echarts.init(diskIopsChartRef.value)
|
||||
diskIopsChart.setOption({
|
||||
tooltip: { trigger: 'axis', formatter: (params) => {
|
||||
let s = params[0].axisValue
|
||||
params.forEach(p => { s += `<br/>${p.marker} ${p.seriesName}: ${formatBytesRaw(p.value)}/s` })
|
||||
return s
|
||||
}},
|
||||
grid: baseGrid, xAxis: makeXAxis(),
|
||||
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: v => formatBytesRaw(v) + '/s' } },
|
||||
series: [makeSeries('读取', diskReadRate, '#409eff'), makeSeries('写入', diskWriteRate, '#e6a23c')]
|
||||
}, true)
|
||||
}
|
||||
|
||||
if (netChartRef.value) {
|
||||
if (!netChart) netChart = echarts.init(netChartRef.value)
|
||||
netChart.setOption({
|
||||
@@ -2298,13 +2584,32 @@ const renderMetricsCharts = () => {
|
||||
series: [makeSeries('接收', netRxData, '#409eff'), makeSeries('发送', netTxData, '#e6a23c')]
|
||||
}, true)
|
||||
}
|
||||
|
||||
if (trafficUsedChartRef.value) {
|
||||
if (!trafficUsedChart) trafficUsedChart = echarts.init(trafficUsedChartRef.value)
|
||||
const trafficDelta = []
|
||||
for (let i = 0; i < metrics.length; i++) {
|
||||
if (i === 0) { trafficDelta.push(0); continue }
|
||||
const delta = Math.max(0, (metrics[i].traffic_used_mb ?? 0) - (metrics[i - 1].traffic_used_mb ?? 0))
|
||||
trafficDelta.push(+delta.toFixed(2))
|
||||
}
|
||||
trafficUsedChart.setOption({
|
||||
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} 流量增量: ${p[0].value} MB` },
|
||||
grid: baseGrid, xAxis: makeXAxis(),
|
||||
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: v => v + ' MB' } },
|
||||
series: [makeSeries('流量增量', trafficDelta, '#722ed1')]
|
||||
}, true)
|
||||
}
|
||||
}
|
||||
|
||||
const disposeCharts = () => {
|
||||
cpuChart?.dispose(); cpuChart = null
|
||||
memChart?.dispose(); memChart = null
|
||||
diskChart?.dispose(); diskChart = null
|
||||
diskIopsChart?.dispose(); diskIopsChart = null
|
||||
netChart?.dispose(); netChart = null
|
||||
trafficUsedChart?.dispose(); trafficUsedChart = null
|
||||
trafficHourlyChart?.dispose(); trafficHourlyChart = null
|
||||
}
|
||||
|
||||
onMounted(() => { loadDetail() })
|
||||
@@ -2355,6 +2660,14 @@ onBeforeUnmount(() => { disposeCharts() })
|
||||
.config-cell:last-child { border-right: none; }
|
||||
.config-label { font-size: 12px; color: #86909c; line-height: 1; }
|
||||
.config-value { font-size: 13px; color: #1d2129; line-height: 1.4; word-break: break-all; }
|
||||
.cfg-edit-btn { margin-left: 8px; padding: 0 4px; height: 18px; vertical-align: middle; }
|
||||
.cfg-edit-btn .el-icon { margin-right: 2px; vertical-align: -2px; }
|
||||
.traffic-cell { display: flex; flex-direction: column; gap: 2px; }
|
||||
.traffic-cell .traffic-main { font-size: 13px; color: #1d2129; }
|
||||
.traffic-cell .traffic-sub { font-size: 12px; color: #86909c; line-height: 1.3; }
|
||||
.traffic-cell .traffic-cycle { color: #c0c4cc; margin-left: 2px; }
|
||||
.traffic-cell .traffic-actions { display: inline-flex; gap: 6px; margin-top: 2px; }
|
||||
.traffic-cell .traffic-actions .cfg-edit-btn { margin-left: 0; }
|
||||
.pwd-value { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.pwd-text { font-family: 'Consolas', 'Monaco', monospace; font-size: 13px; background: #f5f7fa; padding: 2px 8px; border-radius: 3px; letter-spacing: .5px; user-select: all; }
|
||||
.pwd-btn { padding: 0 !important; height: auto !important; min-height: auto !important; }
|
||||
|
||||
@@ -131,6 +131,127 @@
|
||||
</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>
|
||||
<div class="section-block">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">KSM 内存去重</h3>
|
||||
<div style="display:flex;gap:8px">
|
||||
<el-button size="small" :icon="Refresh" @click="loadKsmStatus" :loading="ksmLoading">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="ksmStats">
|
||||
<el-descriptions :column="3" border size="small" style="margin-top:12px">
|
||||
<el-descriptions-item label="内核支持">
|
||||
<el-tag :type="ksmStats.available ? 'success' : 'danger'" size="small">{{ ksmStats.available ? '支持' : '不支持' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="启用状态">
|
||||
<el-tag :type="ksmStats.enabled ? 'success' : 'info'" size="small">{{ ksmStats.enabled ? '已启用' : '未启用' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="跨 NUMA 合并">
|
||||
<el-tag :type="ksmStats.merge_across_nodes ? 'warning' : 'info'" size="small">{{ ksmStats.merge_across_nodes ? '是' : '否' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="扫描页面数">{{ ksmStats.pages_to_scan ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="扫描间隔">{{ ksmStats.sleep_millisecs != null ? ksmStats.sleep_millisecs + ' ms' : '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="完整扫描次数">{{ ksmStats.full_scans ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已合并唯一页面">{{ ksmStats.pages_shared ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="共享总页面">{{ ksmStats.pages_sharing ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="未合并页面">{{ ksmStats.pages_unshared ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="节省内存">
|
||||
<span style="font-weight:600;color:#67c23a">{{ formatMemKB(ksmStats.memory_saved_kb) }}</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div style="margin-top:12px;display:flex;gap:8px">
|
||||
<el-button v-if="!ksmStats.enabled" type="success" size="small" :loading="ksmActionLoading" @click="handleKsmAction('enable')">启用 KSM</el-button>
|
||||
<el-button v-if="ksmStats.enabled" type="danger" size="small" :loading="ksmActionLoading" @click="handleKsmAction('disable')">关闭 KSM</el-button>
|
||||
<el-button v-if="ksmStats.available" type="primary" size="small" @click="openKsmTuneDialog">调参</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty v-else-if="!ksmLoading" description="点击刷新加载 KSM 状态" :image-size="60" />
|
||||
</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">
|
||||
<div class="quota-grid">
|
||||
<div class="quota-card quota-card-vm">
|
||||
<div class="quota-card-icon"><el-icon :size="28" color="#409eff"><Monitor /></el-icon></div>
|
||||
<div class="quota-card-body">
|
||||
<div class="quota-card-label">虚拟机数量</div>
|
||||
<div class="quota-card-number">{{ quotaStats.vm_count ?? 0 }}<span class="quota-card-unit">台</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="quota-card" v-for="item in quotaAllocationItems" :key="item.label">
|
||||
<el-progress type="circle" :percentage="Math.min(100, item.ratio)" :width="80" :stroke-width="7" :color="quotaColor(item.ratio)">
|
||||
<span class="quota-ring-pct" :style="{ color: quotaColor(item.ratio) }">{{ item.ratio }}%</span>
|
||||
</el-progress>
|
||||
<div class="quota-card-body">
|
||||
<div class="quota-card-label">{{ item.label }}</div>
|
||||
<div class="quota-card-alloc">{{ item.allocatedText }} <span class="quota-card-sep">/</span> {{ item.plannedText }}</div>
|
||||
<div class="quota-card-sub">已分配 / 规划</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="quota-actual-section">
|
||||
<h4 class="quota-subtitle">实时使用</h4>
|
||||
<div class="quota-actual-grid">
|
||||
<div class="quota-actual-card">
|
||||
<div class="quota-actual-header">
|
||||
<span class="quota-actual-label">CPU 使用率</span>
|
||||
<span class="quota-actual-value" :style="{ color: quotaColor(quotaStats.actual_cpu_percent ?? 0) }">{{ quotaStats.actual_cpu_percent != null ? quotaStats.actual_cpu_percent.toFixed(1) + '%' : '-' }}</span>
|
||||
</div>
|
||||
<el-progress :percentage="Math.min(100, quotaStats.actual_cpu_percent ?? 0)" :show-text="false" :stroke-width="10" :color="quotaColor(quotaStats.actual_cpu_percent ?? 0)" />
|
||||
</div>
|
||||
<div class="quota-actual-card">
|
||||
<div class="quota-actual-header">
|
||||
<span class="quota-actual-label">内存使用</span>
|
||||
<span class="quota-actual-value" :style="{ color: quotaColor(actualMemPercent) }">{{ formatQuotaBytes(quotaStats.actual_memory_used) }} / {{ formatQuotaBytes(quotaStats.actual_memory_total) }}</span>
|
||||
</div>
|
||||
<el-progress :percentage="actualMemPercent" :show-text="false" :stroke-width="10" :color="quotaColor(actualMemPercent)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="quotaStatsDisk.length">
|
||||
<div class="quota-disk-section">
|
||||
<h4 class="quota-subtitle">磁盘使用详情</h4>
|
||||
<div class="quota-disk-list">
|
||||
<div class="quota-disk-item" v-for="disk in quotaStatsDisk" :key="disk.path">
|
||||
<div class="quota-disk-header">
|
||||
<code class="quota-disk-path">{{ disk.path }}</code>
|
||||
<span class="quota-disk-detail">{{ formatQuotaBytes(disk.used) }} / {{ formatQuotaBytes(disk.total) }}</span>
|
||||
<span class="quota-disk-pct" :style="{ color: quotaColor(disk.percent ?? 0) }">{{ (disk.percent ?? 0).toFixed(1) }}%</span>
|
||||
</div>
|
||||
<el-progress :percentage="Math.min(100, disk.percent ?? 0)" :show-text="false" :stroke-width="8" :color="quotaColor(disk.percent ?? 0)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<el-empty v-else-if="!quotaStatsLoading" description="暂无额度统计数据" :image-size="60" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="监控" name="monitor">
|
||||
@@ -408,6 +529,32 @@
|
||||
<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="宿主机组">
|
||||
@@ -475,6 +622,32 @@
|
||||
</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">
|
||||
@@ -529,6 +702,25 @@
|
||||
|
||||
<!-- 令牌用宿主机组选择器 -->
|
||||
<HostGroupSelectorPopup v-model="showTokenGroupSelector" :service-id="serviceId" :current-id="tokenForm.host_group_id" @confirm="handleTokenGroupSelected" />
|
||||
|
||||
<!-- KSM 调参弹窗 -->
|
||||
<el-dialog v-model="ksmTuneVisible" title="KSM 调参" width="480px" destroy-on-close>
|
||||
<el-form label-width="140px" style="padding: 8px 0">
|
||||
<el-form-item label="每次扫描页面数">
|
||||
<el-input-number v-model="ksmTuneForm.pages_to_scan" :min="0" :step="100" controls-position="right" style="width:200px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="扫描间隔 (ms)">
|
||||
<el-input-number v-model="ksmTuneForm.sleep_millisecs" :min="0" :step="10" controls-position="right" style="width:200px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="跨 NUMA 节点合并">
|
||||
<el-switch v-model="ksmTuneForm.merge_across_nodes" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="ksmTuneVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="ksmActionLoading" @click="submitKsmTune">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -536,12 +728,13 @@
|
||||
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, Refresh, Edit, Delete, Monitor, Coin, Connection, Search, Plus, Key, CopyDocument } from '@element-plus/icons-vue'
|
||||
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
|
||||
createHostToken, getMetricsHistory, getHostQuotaStats,
|
||||
getHostKsmStatus, configureHostKsm
|
||||
} from '@/api/admin/kvmService'
|
||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||
import { baseUrl } from '@/config/env'
|
||||
@@ -590,6 +783,7 @@ watch(activeTab, (tab) => {
|
||||
}
|
||||
}
|
||||
if (tab === 'networking') loadNetworkingList()
|
||||
if (tab === 'quotaStats' && !quotaStats.value) loadQuotaStats()
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -630,9 +824,56 @@ 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: ''
|
||||
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' }],
|
||||
@@ -703,6 +944,150 @@ const loadDetail = async () => {
|
||||
} 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 {
|
||||
const parsed = JSON.parse(quotaStats.value.actual_disk_json)
|
||||
if (Array.isArray(parsed)) return parsed
|
||||
return Object.entries(parsed).map(([path, info]) => ({ path, ...info }))
|
||||
} 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 formatQuotaKB = (kb) => {
|
||||
if (!kb && kb !== 0) return '-'
|
||||
const v = Number(kb)
|
||||
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 formatQuotaDisk = (gb) => {
|
||||
if (!gb && gb !== 0) return '-'
|
||||
const v = Number(gb)
|
||||
if (v >= 1024) return (v / 1024).toFixed(1) + ' TB'
|
||||
return v + ' GB'
|
||||
}
|
||||
|
||||
const formatBandwidth = (mbps) => {
|
||||
if (!mbps && mbps !== 0) return '-'
|
||||
const v = Number(mbps)
|
||||
if (v >= 1000) return (v / 1000).toFixed(1) + ' Gbps'
|
||||
return v + ' Mbps'
|
||||
}
|
||||
|
||||
const formatQuotaBytes = (bytes) => {
|
||||
if (!bytes && bytes !== 0) return '-'
|
||||
const n = Number(bytes)
|
||||
if (n >= 1099511627776) return (n / 1099511627776).toFixed(2) + ' TB'
|
||||
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 quotaColor = (ratio) => {
|
||||
if (ratio >= 90) return '#f56c6c'
|
||||
if (ratio >= 70) return '#e6a23c'
|
||||
return '#409eff'
|
||||
}
|
||||
|
||||
const quotaAllocationItems = computed(() => {
|
||||
const s = quotaStats.value
|
||||
if (!s) return []
|
||||
const ratio = (a, p) => p ? Math.round((a / p) * 100) : 0
|
||||
return [
|
||||
{ label: 'CPU', ratio: ratio(s.allocated_cpu, s.planned_cpu), allocatedText: (s.allocated_cpu ?? 0) + ' 核', plannedText: (s.planned_cpu ?? 0) + ' 核' },
|
||||
{ label: '内存', ratio: ratio(s.allocated_memory, s.planned_memory), allocatedText: formatQuotaKB(s.allocated_memory), plannedText: formatQuotaKB(s.planned_memory) },
|
||||
{ label: '磁盘', ratio: ratio(s.allocated_disk, s.planned_disk), allocatedText: formatQuotaDisk(s.allocated_disk), plannedText: formatQuotaDisk(s.planned_disk) },
|
||||
{ label: '下行带宽', ratio: ratio(s.allocated_rx_bandwidth, s.planned_rx_bandwidth), allocatedText: formatBandwidth(s.allocated_rx_bandwidth), plannedText: formatBandwidth(s.planned_rx_bandwidth) },
|
||||
{ label: '上行带宽', ratio: ratio(s.allocated_tx_bandwidth, s.planned_tx_bandwidth), allocatedText: formatBandwidth(s.allocated_tx_bandwidth), plannedText: formatBandwidth(s.planned_tx_bandwidth) }
|
||||
]
|
||||
})
|
||||
|
||||
const actualMemPercent = computed(() => {
|
||||
const s = quotaStats.value
|
||||
if (!s?.actual_memory_total) return 0
|
||||
return Math.round((s.actual_memory_used / s.actual_memory_total) * 100)
|
||||
})
|
||||
|
||||
// ---- KSM 内存去重 ----
|
||||
const ksmStats = ref(null)
|
||||
const ksmLoading = ref(false)
|
||||
const ksmActionLoading = ref(false)
|
||||
const ksmTuneVisible = ref(false)
|
||||
const ksmTuneForm = reactive({ pages_to_scan: 300, sleep_millisecs: 100, merge_across_nodes: false })
|
||||
|
||||
const loadKsmStatus = async () => {
|
||||
if (!serviceId.value || !hostId.value) return
|
||||
ksmLoading.value = true
|
||||
try {
|
||||
const res = await getHostKsmStatus({ service_id: serviceId.value, host_id: hostId.value })
|
||||
if (res?.data?.code === 200) ksmStats.value = res.data.data?.stats || res.data.data
|
||||
else ElMessage.error(extractApiError(res?.data, '获取 KSM 状态失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取 KSM 状态失败')) } finally { ksmLoading.value = false }
|
||||
}
|
||||
|
||||
const handleKsmAction = async (action) => {
|
||||
const labels = { enable: '启用', disable: '关闭' }
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要${labels[action]} KSM 吗?`, 'KSM 操作', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
|
||||
} catch { return }
|
||||
ksmActionLoading.value = true
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('service_id', serviceId.value)
|
||||
fd.append('host_id', hostId.value)
|
||||
fd.append('action', action)
|
||||
const res = await configureHostKsm(fd)
|
||||
if (res?.data?.code === 200) {
|
||||
ElMessage.success(`KSM 已${labels[action]}`)
|
||||
ksmStats.value = res.data.data?.stats || res.data.data
|
||||
} else ElMessage.error(extractApiError(res?.data, '操作失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { ksmActionLoading.value = false }
|
||||
}
|
||||
|
||||
const openKsmTuneDialog = () => {
|
||||
if (ksmStats.value) {
|
||||
ksmTuneForm.pages_to_scan = ksmStats.value.pages_to_scan ?? 300
|
||||
ksmTuneForm.sleep_millisecs = ksmStats.value.sleep_millisecs ?? 100
|
||||
ksmTuneForm.merge_across_nodes = !!ksmStats.value.merge_across_nodes
|
||||
}
|
||||
ksmTuneVisible.value = true
|
||||
}
|
||||
|
||||
const submitKsmTune = async () => {
|
||||
ksmActionLoading.value = true
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('service_id', serviceId.value)
|
||||
fd.append('host_id', hostId.value)
|
||||
fd.append('action', 'tune')
|
||||
fd.append('pages_to_scan', ksmTuneForm.pages_to_scan)
|
||||
fd.append('sleep_millisecs', ksmTuneForm.sleep_millisecs)
|
||||
fd.append('merge_across_nodes', ksmTuneForm.merge_across_nodes)
|
||||
const res = await configureHostKsm(fd)
|
||||
if (res?.data?.code === 200) {
|
||||
ElMessage.success('KSM 参数已更新')
|
||||
ksmStats.value = res.data.data?.stats || res.data.data
|
||||
ksmTuneVisible.value = false
|
||||
} else ElMessage.error(extractApiError(res?.data, '调参失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '调参失败')) } finally { ksmActionLoading.value = false }
|
||||
}
|
||||
|
||||
const cpuChartRef = ref(null)
|
||||
const memChartRef = ref(null)
|
||||
const netChartRef = ref(null)
|
||||
@@ -899,8 +1284,10 @@ const handleEdit = () => {
|
||||
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 || ''
|
||||
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
|
||||
}
|
||||
|
||||
@@ -947,7 +1334,8 @@ 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
|
||||
description: '', expire_hours: 24,
|
||||
...diskIoDefaults
|
||||
})
|
||||
const tokenResultInfo = reactive({ name: '', expire_hours: 24, token: '', service_id: 0 })
|
||||
const tokenRules = {
|
||||
@@ -979,10 +1367,13 @@ const openTokenDialog = () => {
|
||||
max_disk: d?.max_disk || 100,
|
||||
rx_bandwidth: d?.rx_bandwidth || 100,
|
||||
tx_bandwidth: d?.tx_bandwidth || 100,
|
||||
description: '', expire_hours: 24
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1004,6 +1395,7 @@ const handleTokenSubmit = () => {
|
||||
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)
|
||||
@@ -1294,4 +1686,44 @@ onBeforeUnmount(() => { isPageActive = false; disposeCharts() })
|
||||
.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; }
|
||||
|
||||
.quota-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 16px; margin-top: 12px; }
|
||||
.quota-card { background: #f7f8fa; border: 1px solid #e8e8e8; border-radius: 8px; padding: 20px 16px; display: flex; flex-direction: column; align-items: center; gap: 12px; transition: box-shadow 0.2s; }
|
||||
.quota-card:hover { box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06); }
|
||||
.quota-card-vm { flex-direction: row; justify-content: center; gap: 16px; }
|
||||
.quota-card-icon { display: flex; align-items: center; justify-content: center; width: 56px; height: 56px; background: #ecf5ff; border-radius: 12px; }
|
||||
.quota-card-body { display: flex; flex-direction: column; align-items: center; gap: 2px; min-width: 0; }
|
||||
.quota-card-vm .quota-card-body { align-items: flex-start; }
|
||||
.quota-card-label { font-size: 12px; color: #86909c; white-space: nowrap; }
|
||||
.quota-card-number { font-size: 32px; font-weight: 700; color: #1d2129; line-height: 1.1; }
|
||||
.quota-card-unit { font-size: 14px; font-weight: 400; color: #86909c; margin-left: 4px; }
|
||||
.quota-ring-pct { font-size: 16px; font-weight: 700; line-height: 1; }
|
||||
.quota-card-alloc { font-size: 13px; color: #1d2129; font-weight: 500; white-space: nowrap; text-align: center; }
|
||||
.quota-card-sep { color: #c0c4cc; margin: 0 2px; }
|
||||
.quota-card-sub { font-size: 11px; color: #a8abb2; }
|
||||
|
||||
.quota-actual-section { margin-top: 24px; }
|
||||
.quota-subtitle { font-size: 14px; font-weight: 600; color: #1d2129; margin: 0 0 14px; padding-left: 8px; border-left: 3px solid #409eff; }
|
||||
.quota-actual-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
|
||||
.quota-actual-card { background: #f7f8fa; border: 1px solid #e8e8e8; border-radius: 8px; padding: 16px 20px; }
|
||||
.quota-actual-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.quota-actual-label { font-size: 13px; color: #606266; font-weight: 500; }
|
||||
.quota-actual-value { font-size: 14px; font-weight: 600; }
|
||||
|
||||
.quota-disk-section { margin-top: 24px; }
|
||||
.quota-disk-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.quota-disk-item { background: #f7f8fa; border: 1px solid #e8e8e8; border-radius: 8px; padding: 12px 20px; }
|
||||
.quota-disk-header { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; flex-wrap: wrap; }
|
||||
.quota-disk-path { font-size: 13px; font-family: 'Consolas', 'Monaco', monospace; color: #1d2129; background: #e8eaed; padding: 2px 8px; border-radius: 4px; }
|
||||
.quota-disk-detail { font-size: 12px; color: #86909c; flex: 1; text-align: right; }
|
||||
.quota-disk-pct { font-size: 13px; font-weight: 600; min-width: 48px; text-align: right; }
|
||||
|
||||
</style>
|
||||
|
||||
@@ -8,30 +8,24 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-select v-model="selectedServiceId" placeholder="选择主控服务" filterable style="width: 240px" @change="handleServiceChange">
|
||||
<el-option v-for="s in serviceOptions" :key="s.id" :label="`${s.name} (ID: ${s.id})`" :value="s.id" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleSync" :loading="syncLoading" :disabled="!serviceId">
|
||||
<el-icon><RefreshRight /></el-icon>从远程同步
|
||||
</el-button>
|
||||
<el-button @click="handleRefresh">
|
||||
<el-button @click="loadAllServices" :loading="servicesLoading">
|
||||
<el-icon><Refresh /></el-icon>刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="embedded-toolbar" v-if="embedded">
|
||||
<el-button type="primary" @click="handleSync" :loading="syncLoading"><el-icon><RefreshRight /></el-icon>从远程同步</el-button>
|
||||
<el-button @click="loadHostGroups"><el-icon><Refresh /></el-icon>刷新</el-button>
|
||||
<el-button type="primary" @click="handleSync(embeddedServiceId)" :loading="syncLoadingMap[embeddedServiceId]"><el-icon><RefreshRight /></el-icon>从远程同步</el-button>
|
||||
<el-button @click="loadHostGroupsForService(embeddedServiceId)"><el-icon><Refresh /></el-icon>刷新</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 本地主机组列表(树形折叠) -->
|
||||
<div class="main-panel">
|
||||
<!-- embedded 模式:直接展示单个主控的主机组 -->
|
||||
<div v-if="embedded" class="main-panel">
|
||||
<div class="panel-header">
|
||||
<h4>本地主机组列表</h4>
|
||||
</div>
|
||||
<div class="panel-body" v-loading="loading">
|
||||
<el-table :data="treeGroupList" stripe style="width: 100%" row-key="_rowKey"
|
||||
:tree-props="{ children: '_children', hasChildren: '_hasChildren' }">
|
||||
<div class="panel-body" v-loading="hostGroupLoadingMap[embeddedServiceId]">
|
||||
<el-table :data="getTreeGroupList(embeddedServiceId)" stripe style="width: 100%" row-key="_rowKey"
|
||||
default-expand-all :tree-props="{ children: '_children', hasChildren: '_hasChildren' }">
|
||||
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column label="远程ID" width="80">
|
||||
@@ -60,13 +54,82 @@
|
||||
<el-button link type="primary" size="small" @click.stop="handleEditGroup(row)">编辑</el-button>
|
||||
<el-button link type="primary" size="small" @click.stop="handleBind(row)">绑定</el-button>
|
||||
<el-button link type="success" size="small" @click.stop="handleGenerateGoods(row)">生成商品</el-button>
|
||||
<el-button link type="danger" size="small" @click.stop="handleDeleteGroup(row)">删除</el-button>
|
||||
<el-button link type="danger" size="small" @click.stop="handleDeleteGroup(row, embeddedServiceId)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 非 embedded 模式:展示所有主控服务,每个可折叠展开 -->
|
||||
<div v-if="!embedded" v-loading="servicesLoading">
|
||||
<el-empty v-if="!servicesLoading && serviceList.length === 0" description="暂无主控服务" :image-size="100" />
|
||||
<div v-for="service in serviceList" :key="service.id" class="service-card">
|
||||
<div class="service-card-header" @click="toggleService(service)">
|
||||
<div class="service-card-title">
|
||||
<el-icon class="expand-icon" :class="{ 'is-expanded': expandedServiceIds.has(service.id) }"><ArrowRight /></el-icon>
|
||||
<span class="service-name">{{ service.name }}</span>
|
||||
<el-tag size="small" type="info">ID: {{ service.id }}</el-tag>
|
||||
<el-tag v-if="service.host" size="small">{{ service.host }}{{ service.port ? ':' + service.port : '' }}</el-tag>
|
||||
</div>
|
||||
<div class="service-card-actions" @click.stop>
|
||||
<el-button type="primary" size="small" @click="handleSync(service.id)" :loading="syncLoadingMap[service.id]">
|
||||
<el-icon><RefreshRight /></el-icon>同步
|
||||
</el-button>
|
||||
<el-button size="small" @click="loadHostGroupsForService(service.id)">
|
||||
<el-icon><Refresh /></el-icon>刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-collapse-transition>
|
||||
<div v-show="expandedServiceIds.has(service.id)" class="service-card-body">
|
||||
<div v-loading="hostGroupLoadingMap[service.id]" class="host-group-table-wrapper">
|
||||
<el-table
|
||||
v-if="hostGroupDataMap[service.id]?.length > 0"
|
||||
:data="getTreeGroupList(service.id)"
|
||||
stripe style="width: 100%" row-key="_rowKey"
|
||||
default-expand-all
|
||||
:tree-props="{ children: '_children', hasChildren: '_hasChildren' }"
|
||||
>
|
||||
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column label="远程ID" width="80">
|
||||
<template #default="{ row }">{{ row.remoteId || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="父级远程ID" width="100">
|
||||
<template #default="{ row }">{{ row.parentRemoteId || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="绑定商品组" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.goodGroupId" type="success" size="small">商品组#{{ row.goodGroupId }}</el-tag>
|
||||
<span v-else class="text-muted">未绑定</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="绑定商品" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.goodId" type="warning" size="small">商品#{{ row.goodId }}</el-tag>
|
||||
<span v-else class="text-muted">未绑定</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="note" label="备注" min-width="120" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.note || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="260" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click.stop="handleEditGroup(row)">编辑</el-button>
|
||||
<el-button link type="primary" size="small" @click.stop="handleBind(row)">绑定</el-button>
|
||||
<el-button link type="success" size="small" @click.stop="handleGenerateGoods(row)">生成商品</el-button>
|
||||
<el-button link type="danger" size="small" @click.stop="handleDeleteGroup(row, service.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-else-if="!hostGroupLoadingMap[service.id]" description="暂无主机组数据,请先从远程同步" :image-size="60" />
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑本地主机组弹窗 -->
|
||||
<el-dialog v-model="editDialogVisible" title="编辑本地主机组" width="480px" destroy-on-close>
|
||||
<el-form ref="editFormRef" :model="editForm" :rules="editFormRules" label-width="90px">
|
||||
@@ -215,7 +278,7 @@
|
||||
import { ref, reactive, computed, inject, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, RefreshRight, Search, ArrowLeft } from '@element-plus/icons-vue'
|
||||
import { Plus, Refresh, RefreshRight, Search, ArrowLeft, ArrowRight } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getHostGroupList,
|
||||
syncHostGroup,
|
||||
@@ -237,15 +300,12 @@ const embedded = inject('embedded', false)
|
||||
const injectedServiceId = inject('serviceId', null)
|
||||
const injectedServiceName = inject('serviceName', null)
|
||||
|
||||
const selectedServiceId = ref(parseInt(route.query.service_id) || null)
|
||||
const serviceOptions = ref([])
|
||||
const embeddedServiceId = computed(() => injectedServiceId?.value || 0)
|
||||
|
||||
const serviceId = computed(() => injectedServiceId?.value || selectedServiceId.value || 0)
|
||||
const serviceName = computed(() => {
|
||||
if (injectedServiceName?.value) return injectedServiceName.value
|
||||
const s = serviceOptions.value.find(x => x.id === selectedServiceId.value)
|
||||
return s?.name || route.query.service_name || ''
|
||||
})
|
||||
// ==================== 主控服务列表 ====================
|
||||
const serviceList = ref([])
|
||||
const servicesLoading = ref(false)
|
||||
const expandedServiceIds = reactive(new Set())
|
||||
|
||||
const normalizeService = (s) => ({
|
||||
id: s.Id ?? s.id,
|
||||
@@ -255,59 +315,29 @@ const normalizeService = (s) => ({
|
||||
note: s.Note ?? s.note
|
||||
})
|
||||
|
||||
const loadServiceOptions = async () => {
|
||||
const loadAllServices = async () => {
|
||||
servicesLoading.value = true
|
||||
try {
|
||||
const res = await getKvmServiceList({ page: 1, count: 10, key: '' })
|
||||
const res = await getKvmServiceList({ page: 1, count: 100, key: '' })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const inner = res.data.data
|
||||
const raw = inner.data || inner.list || (Array.isArray(inner) ? inner : [])
|
||||
serviceOptions.value = raw.map(normalizeService)
|
||||
serviceList.value = raw.map(normalizeService)
|
||||
serviceList.value.forEach(s => {
|
||||
expandedServiceIds.add(s.id)
|
||||
loadHostGroupsForService(s.id)
|
||||
})
|
||||
}
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
const handleServiceChange = () => {
|
||||
hostGroupList.value = []
|
||||
if (serviceId.value) {
|
||||
loadHostGroups()
|
||||
} catch { /* */ } finally {
|
||||
servicesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
if (!serviceId.value) {
|
||||
ElMessage.warning('请先选择主控服务')
|
||||
return
|
||||
}
|
||||
loadHostGroups()
|
||||
}
|
||||
// ==================== 每个主控下的主机组数据 ====================
|
||||
const hostGroupDataMap = reactive({})
|
||||
const hostGroupLoadingMap = reactive({})
|
||||
const syncLoadingMap = reactive({})
|
||||
|
||||
const loading = ref(false)
|
||||
const syncLoading = ref(false)
|
||||
const hostGroupList = ref([])
|
||||
const selectedGroup = ref(null)
|
||||
|
||||
const treeGroupList = computed(() => {
|
||||
const items = hostGroupList.value
|
||||
if (!items.length) return []
|
||||
const map = new Map()
|
||||
items.forEach(item => {
|
||||
map.set(item.remoteId, { ...item, _rowKey: `g-${item.id}`, _children: [], _hasChildren: false })
|
||||
})
|
||||
const roots = []
|
||||
map.forEach(item => {
|
||||
if (item.parentRemoteId && map.has(item.parentRemoteId)) {
|
||||
const parent = map.get(item.parentRemoteId)
|
||||
parent._children.push(item)
|
||||
parent._hasChildren = true
|
||||
} else {
|
||||
roots.push(item)
|
||||
}
|
||||
})
|
||||
return roots
|
||||
})
|
||||
|
||||
// 规范化后端 PascalCase 字段为前端 camelCase
|
||||
// 同时保留原始字段以便在需要时直接访问
|
||||
const normalizeHostGroup = (item) => {
|
||||
if (!item) return item
|
||||
return {
|
||||
@@ -325,36 +355,65 @@ const normalizeHostGroup = (item) => {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 本地主机组列表 ==========
|
||||
const loadHostGroups = async () => {
|
||||
if (!serviceId.value) return
|
||||
loading.value = true
|
||||
const loadHostGroupsForService = async (svcId) => {
|
||||
if (!svcId) return
|
||||
hostGroupLoadingMap[svcId] = true
|
||||
try {
|
||||
const res = await getHostGroupList({ service_id: serviceId.value })
|
||||
const res = await getHostGroupList({ service_id: svcId })
|
||||
const body = res?.data
|
||||
console.debug('[HostGroup] list response body:', JSON.stringify(body))
|
||||
if (body?.code === 200 && body?.data) {
|
||||
// data 可能是直接数组,或 { all_count, data: [...] } 格式
|
||||
const items = Array.isArray(body.data) ? body.data : (body.data.data || body.data.list || [])
|
||||
hostGroupList.value = items.map(normalizeHostGroup)
|
||||
console.debug('[HostGroup] normalized list:', hostGroupList.value)
|
||||
hostGroupDataMap[svcId] = items.map(normalizeHostGroup)
|
||||
} else {
|
||||
hostGroupList.value = []
|
||||
if (body?.message) {
|
||||
ElMessage.warning(body.message)
|
||||
}
|
||||
hostGroupDataMap[svcId] = []
|
||||
if (body?.message) ElMessage.warning(body.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取本地主机组列表失败:', error)
|
||||
ElMessage.error('获取本地主机组列表失败')
|
||||
hostGroupDataMap[svcId] = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
hostGroupLoadingMap[svcId] = false
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 同步 ==========
|
||||
const handleSync = async () => {
|
||||
if (!serviceId.value) {
|
||||
const buildTree = (items) => {
|
||||
if (!items || !items.length) return []
|
||||
const map = new Map()
|
||||
items.forEach(item => {
|
||||
map.set(item.remoteId, { ...item, _rowKey: `g-${item.id}`, _children: [], _hasChildren: false })
|
||||
})
|
||||
const roots = []
|
||||
map.forEach(item => {
|
||||
if (item.parentRemoteId && map.has(item.parentRemoteId)) {
|
||||
const parent = map.get(item.parentRemoteId)
|
||||
parent._children.push(item)
|
||||
parent._hasChildren = true
|
||||
} else {
|
||||
roots.push(item)
|
||||
}
|
||||
})
|
||||
return roots
|
||||
}
|
||||
|
||||
const getTreeGroupList = (svcId) => {
|
||||
return buildTree(hostGroupDataMap[svcId] || [])
|
||||
}
|
||||
|
||||
const toggleService = (service) => {
|
||||
if (expandedServiceIds.has(service.id)) {
|
||||
expandedServiceIds.delete(service.id)
|
||||
} else {
|
||||
expandedServiceIds.add(service.id)
|
||||
if (!hostGroupDataMap[service.id]) {
|
||||
loadHostGroupsForService(service.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 同步 ====================
|
||||
const handleSync = async (svcId) => {
|
||||
if (!svcId) {
|
||||
ElMessage.warning('缺少主控服务ID')
|
||||
return
|
||||
}
|
||||
@@ -363,9 +422,9 @@ const handleSync = async () => {
|
||||
cancelButtonText: '取消',
|
||||
type: 'info'
|
||||
}).then(async () => {
|
||||
syncLoading.value = true
|
||||
syncLoadingMap[svcId] = true
|
||||
try {
|
||||
const res = await syncHostGroup({ service_id: serviceId.value })
|
||||
const res = await syncHostGroup({ service_id: svcId })
|
||||
const body = res?.data
|
||||
if (body?.code === 200) {
|
||||
const synced = body.data
|
||||
@@ -374,19 +433,20 @@ const handleSync = async () => {
|
||||
} else {
|
||||
ElMessage.warning(body?.message || '同步返回异常')
|
||||
}
|
||||
loadHostGroups()
|
||||
loadHostGroupsForService(svcId)
|
||||
} catch (error) {
|
||||
ElMessage.error(extractApiError(error?.response?.data, '同步失败'))
|
||||
} finally {
|
||||
syncLoading.value = false
|
||||
syncLoadingMap[svcId] = false
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// ========== 编辑 ==========
|
||||
// ==================== 编辑 ====================
|
||||
const editDialogVisible = ref(false)
|
||||
const editSubmitLoading = ref(false)
|
||||
const editFormRef = ref(null)
|
||||
const editingServiceId = ref(null)
|
||||
|
||||
const editForm = reactive({
|
||||
id: undefined,
|
||||
@@ -399,6 +459,7 @@ const editFormRules = {
|
||||
}
|
||||
|
||||
const handleEditGroup = (row) => {
|
||||
editingServiceId.value = row.serviceId
|
||||
Object.assign(editForm, {
|
||||
id: Number(row.Id ?? row.id),
|
||||
name: row.Name ?? row.name,
|
||||
@@ -421,7 +482,7 @@ const submitEdit = () => {
|
||||
if (body?.code === 200) {
|
||||
ElMessage.success('修改成功')
|
||||
editDialogVisible.value = false
|
||||
loadHostGroups()
|
||||
if (editingServiceId.value) loadHostGroupsForService(editingServiceId.value)
|
||||
} else {
|
||||
ElMessage.error(extractApiError(body, '修改失败'))
|
||||
}
|
||||
@@ -433,10 +494,11 @@ const submitEdit = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 绑定 ==========
|
||||
// ==================== 绑定 ====================
|
||||
const bindDialogVisible = ref(false)
|
||||
const bindSubmitLoading = ref(false)
|
||||
const bindFormRef = ref(null)
|
||||
const bindingServiceId = ref(null)
|
||||
|
||||
const bindForm = reactive({
|
||||
id: undefined,
|
||||
@@ -447,23 +509,19 @@ const bindForm = reactive({
|
||||
good_id: 0
|
||||
})
|
||||
|
||||
// 选择器弹窗控制
|
||||
const showGroupSelector = ref(false)
|
||||
const showProductSelector = ref(false)
|
||||
|
||||
// 商品组选中回调
|
||||
const handleGroupSelected = (group) => {
|
||||
bindForm.good_group_id = group.id
|
||||
bindForm._groupName = group.name || ''
|
||||
}
|
||||
|
||||
// 商品选中回调
|
||||
const handleProductSelected = (product) => {
|
||||
bindForm.good_id = product.id
|
||||
bindForm._goodName = product.name || ''
|
||||
}
|
||||
|
||||
// 清除绑定
|
||||
const clearBindGroup = () => {
|
||||
bindForm.good_group_id = 0
|
||||
bindForm._groupName = ''
|
||||
@@ -475,6 +533,7 @@ const clearBindProduct = () => {
|
||||
}
|
||||
|
||||
const handleBind = (row) => {
|
||||
bindingServiceId.value = row.serviceId
|
||||
Object.assign(bindForm, {
|
||||
id: Number(row.Id ?? row.id),
|
||||
_name: row.Name ?? row.name,
|
||||
@@ -498,7 +557,7 @@ const submitBind = async () => {
|
||||
if (body?.code === 200) {
|
||||
ElMessage.success('绑定成功')
|
||||
bindDialogVisible.value = false
|
||||
loadHostGroups()
|
||||
if (bindingServiceId.value) loadHostGroupsForService(bindingServiceId.value)
|
||||
} else {
|
||||
ElMessage.error(extractApiError(body, '绑定失败'))
|
||||
}
|
||||
@@ -509,10 +568,11 @@ const submitBind = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 生成商品 ==========
|
||||
// ==================== 生成商品 ====================
|
||||
const generateDialogVisible = ref(false)
|
||||
const generateSubmitLoading = ref(false)
|
||||
const generateFormRef = ref(null)
|
||||
const generatingServiceId = ref(null)
|
||||
|
||||
const generateForm = reactive({
|
||||
id: undefined,
|
||||
@@ -527,10 +587,8 @@ const generateFormRules = {
|
||||
id: [{ required: true, message: '主机组ID不能为空', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
// 父级商品组选择器
|
||||
const showGenerateGroupSelector = ref(false)
|
||||
|
||||
// 标签选择器
|
||||
const showGenerateTagSelector = ref(false)
|
||||
const tagOptions = ref([])
|
||||
const tagLoading = ref(false)
|
||||
@@ -567,10 +625,10 @@ const fetchTagOptions = async () => {
|
||||
} catch { /* */ } finally { tagLoading.value = false }
|
||||
}
|
||||
|
||||
// 监听标签选择器打开时加载数据
|
||||
watch(showGenerateTagSelector, (val) => { if (val) loadTagOptions() })
|
||||
|
||||
const handleGenerateGoods = (row) => {
|
||||
generatingServiceId.value = row.serviceId
|
||||
Object.assign(generateForm, {
|
||||
id: Number(row.Id ?? row.id),
|
||||
parent_group_id: 0,
|
||||
@@ -603,7 +661,7 @@ const submitGenerate = () => {
|
||||
if (body?.code === 200) {
|
||||
ElMessage.success('商品生成成功')
|
||||
generateDialogVisible.value = false
|
||||
loadHostGroups()
|
||||
if (generatingServiceId.value) loadHostGroupsForService(generatingServiceId.value)
|
||||
} else {
|
||||
ElMessage.error(extractApiError(body, '商品生成失败'))
|
||||
}
|
||||
@@ -616,8 +674,8 @@ const submitGenerate = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 删除本地主机组 ==========
|
||||
const handleDeleteGroup = (row) => {
|
||||
// ==================== 删除本地主机组 ====================
|
||||
const handleDeleteGroup = (row, svcId) => {
|
||||
const rawId = Number(row.Id ?? row.id)
|
||||
if (!rawId) {
|
||||
ElMessage.error('无法获取主机组ID')
|
||||
@@ -633,7 +691,7 @@ const handleDeleteGroup = (row) => {
|
||||
const body = res?.data
|
||||
if (body?.code === 200) {
|
||||
ElMessage.success('删除成功')
|
||||
loadHostGroups()
|
||||
if (svcId) loadHostGroupsForService(svcId)
|
||||
} else {
|
||||
ElMessage.error(extractApiError(body, '删除失败'))
|
||||
}
|
||||
@@ -643,15 +701,16 @@ const handleDeleteGroup = (row) => {
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// ========== 返回 ==========
|
||||
// ==================== 返回 ====================
|
||||
const goBack = () => {
|
||||
router.push('/virtualization/kvm-service')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!embedded) loadServiceOptions()
|
||||
if (serviceId.value) {
|
||||
loadHostGroups()
|
||||
if (!embedded) {
|
||||
loadAllServices()
|
||||
} else if (embeddedServiceId.value) {
|
||||
loadHostGroupsForService(embeddedServiceId.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -723,8 +782,76 @@ onMounted(() => {
|
||||
|
||||
.panel-body {
|
||||
padding: 16px;
|
||||
min-height: 300px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* 主控服务卡片 */
|
||||
.service-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #ebeef5;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.service-card:hover {
|
||||
border-color: #c0c4cc;
|
||||
}
|
||||
|
||||
.service-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 20px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background: #fafbfc;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.service-card-header:hover {
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
.service-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
transition: transform 0.3s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.expand-icon.is-expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.service-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.service-card-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.service-card-body {
|
||||
border-top: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.host-group-table-wrapper {
|
||||
padding: 16px;
|
||||
min-height: 100px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -159,6 +159,32 @@
|
||||
</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="宿主机组">
|
||||
@@ -271,6 +297,32 @@
|
||||
</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">
|
||||
@@ -378,7 +430,7 @@
|
||||
import { ref, reactive, computed, inject, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Search, ArrowLeft, Monitor, Coin, Connection, Key, CopyDocument } from '@element-plus/icons-vue'
|
||||
import { Plus, Refresh, Search, ArrowLeft, ArrowRight, Monitor, Coin, Connection, Key, CopyDocument } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getRemoteHostList, getRemoteHostDetail,
|
||||
addRemoteHost, updateRemoteHost, deleteRemoteHost,
|
||||
@@ -418,12 +470,49 @@ const currentDetail = ref(null)
|
||||
const metricsVisible = ref(false)
|
||||
const metricsData = 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 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 formData = reactive({
|
||||
id: undefined, 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: '',
|
||||
_groupName: ''
|
||||
_groupName: '',
|
||||
...diskIoDefaults
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
@@ -522,8 +611,10 @@ const resetForm = () => {
|
||||
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: '',
|
||||
_groupName: ''
|
||||
_groupName: '', ...diskIoDefaults
|
||||
})
|
||||
showDiskIoSection.value = false
|
||||
ioBwUnit.value = 'MB/s'
|
||||
}
|
||||
|
||||
const handleHostGroupSelected = (group) => {
|
||||
@@ -585,9 +676,10 @@ const handleEdit = (row) => {
|
||||
max_cpu: row.max_cpu || 0, max_memory: row.max_memory || 0, max_disk: row.max_disk || 0,
|
||||
rx_bandwidth: row.rx_bandwidth || 0, tx_bandwidth: row.tx_bandwidth || 0,
|
||||
host_group_id: row.host_group_id || 0, description: row.description || '',
|
||||
_groupName: getGroupName(row.host_group_id)
|
||||
_groupName: getGroupName(row.host_group_id),
|
||||
...Object.fromEntries(diskIoFields.map(f => [f.key, row[f.key] ?? diskIoDefaults[f.key]]))
|
||||
})
|
||||
// 异步获取详情以补全password等字段
|
||||
showDiskIoSection.value = diskIoFields.some(f => row[f.key] && row[f.key] !== diskIoDefaults[f.key])
|
||||
getRemoteHostDetail({ service_id: serviceId.value, id: row.id }).then(res => {
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
@@ -595,6 +687,7 @@ const handleEdit = (row) => {
|
||||
if (detail.password) formData.password = detail.password
|
||||
if (detail.token) formData.token = detail.token
|
||||
if (detail.private_key) formData.private_key = detail.private_key
|
||||
diskIoFields.forEach(f => { if (detail[f.key] !== undefined) formData[f.key] = detail[f.key] })
|
||||
}
|
||||
}).catch(() => {})
|
||||
dialogVisible.value = true
|
||||
@@ -719,7 +812,8 @@ const tokenForm = reactive({
|
||||
max_memory: 4194304, max_disk: 100,
|
||||
rx_bandwidth: 100, tx_bandwidth: 100,
|
||||
description: '', expire_hours: 24,
|
||||
_groupName: ''
|
||||
_groupName: '',
|
||||
...diskIoDefaults
|
||||
})
|
||||
|
||||
const tokenResultInfo = reactive({ name: '', expire_hours: 24, token: '', service_id: 0 })
|
||||
@@ -750,10 +844,13 @@ const openTokenDialog = () => {
|
||||
name: '', host_group_id: 0, max_cpu: 4,
|
||||
max_memory: 4194304, max_disk: 100,
|
||||
rx_bandwidth: 100, tx_bandwidth: 100,
|
||||
description: '', expire_hours: 24, _groupName: ''
|
||||
description: '', expire_hours: 24, _groupName: '',
|
||||
...diskIoDefaults
|
||||
})
|
||||
tokenMemUnit.value = 'GB'
|
||||
tokenDiskUnit.value = 'GB'
|
||||
showTokenDiskIo.value = false
|
||||
tokenIoBwUnit.value = 'MB/s'
|
||||
tokenDialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -776,6 +873,7 @@ const handleTokenSubmit = () => {
|
||||
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)
|
||||
|
||||
@@ -832,4 +930,10 @@ onMounted(() => {
|
||||
.metrics-card { margin-bottom: 12px; }
|
||||
.metrics-title { font-weight: 600; font-size: 14px; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.metrics-title .el-icon { font-size: 16px; color: #409eff; }
|
||||
.tk-section-title.clickable { cursor: pointer; user-select: none; display: flex; align-items: center; gap: 6px; }
|
||||
.tk-section-title.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; }
|
||||
.io-sub-title { font-size: 13px; font-weight: 500; color: #606266; margin: 12px 0 8px; display: flex; align-items: center; }
|
||||
</style>
|
||||
|
||||
@@ -131,6 +131,32 @@
|
||||
</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">
|
||||
@@ -326,6 +352,32 @@
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title clickable" @click="showHostDiskIo = !showHostDiskIo">
|
||||
硬盘 IO 限制
|
||||
<el-icon class="section-arrow" :class="{ expanded: showHostDiskIo }"><ArrowRight /></el-icon>
|
||||
<span class="section-hint">可选,不展开则使用默认值</span>
|
||||
</div>
|
||||
<div v-show="showHostDiskIo">
|
||||
<div class="io-sub-title">
|
||||
带宽限制
|
||||
<el-select v-model="hostIoBwUnit" 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="+(hostForm[f.key] / getHostIoBwFactor()).toFixed(2)" @update:model-value="v => hostForm[f.key] = Math.round((v || 0) * getHostIoBwFactor())" :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="hostForm[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="宿主机组">
|
||||
@@ -625,6 +677,42 @@ const handleOptimalHost = async (row) => {
|
||||
}
|
||||
|
||||
// ---- 宿主机 CRUD ----
|
||||
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 ioBwUnitOptions = [
|
||||
{ label: 'B/s', factor: 1 },
|
||||
{ label: 'KB/s', factor: 1024 },
|
||||
{ label: 'MB/s', factor: 1048576 },
|
||||
{ label: 'GB/s', factor: 1073741824 }
|
||||
]
|
||||
const hostIoBwUnit = ref('MB/s')
|
||||
const tokenIoBwUnit = ref('MB/s')
|
||||
const getHostIoBwFactor = () => ioBwUnitOptions.find(u => u.label === hostIoBwUnit.value)?.factor || 1048576
|
||||
const getTokenIoBwFactor = () => ioBwUnitOptions.find(u => u.label === tokenIoBwUnit.value)?.factor || 1048576
|
||||
|
||||
const showHostDiskIo = ref(false)
|
||||
const showTokenDiskIo = ref(false)
|
||||
|
||||
const hostDialogVisible = ref(false)
|
||||
const hostDialogType = ref('add')
|
||||
const hostFormRef = ref(null)
|
||||
@@ -632,7 +720,8 @@ const hostForm = reactive({
|
||||
id: undefined, 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: ''
|
||||
rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '',
|
||||
...diskIoDefaults
|
||||
})
|
||||
const hostFormRules = {
|
||||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||
@@ -642,13 +731,17 @@ const hostFormRules = {
|
||||
|
||||
const handleAddHost = () => {
|
||||
hostDialogType.value = 'add'
|
||||
Object.assign(hostForm, { id: undefined, 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: '' })
|
||||
Object.assign(hostForm, { id: undefined, 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 })
|
||||
showHostDiskIo.value = false
|
||||
hostIoBwUnit.value = 'MB/s'
|
||||
hostDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleAddHostToGroup = (group) => {
|
||||
hostDialogType.value = 'add'
|
||||
Object.assign(hostForm, { id: undefined, 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: group.id, description: '' })
|
||||
Object.assign(hostForm, { id: undefined, 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: group.id, description: '', ...diskIoDefaults })
|
||||
showHostDiskIo.value = false
|
||||
hostIoBwUnit.value = 'MB/s'
|
||||
hostDialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -659,14 +752,17 @@ const handleEditHost = (row) => {
|
||||
port: row.port || 22, user: row.user || '', password: row.password || '', private_key: row.private_key || '',
|
||||
max_cpu: row.max_cpu || 0, max_memory: row.max_memory || 0, max_disk: row.max_disk || 0,
|
||||
rx_bandwidth: row.rx_bandwidth || 0, tx_bandwidth: row.tx_bandwidth || 0,
|
||||
host_group_id: row.host_group_id || 0, description: row.description || ''
|
||||
host_group_id: row.host_group_id || 0, description: row.description || '',
|
||||
...Object.fromEntries(diskIoFields.map(f => [f.key, row[f.key] ?? diskIoDefaults[f.key]]))
|
||||
})
|
||||
showHostDiskIo.value = diskIoFields.some(f => row[f.key] && row[f.key] !== diskIoDefaults[f.key])
|
||||
getRemoteHostDetail({ service_id: serviceId.value, id: row.id }).then(res => {
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const d = res.data.data.host ?? res.data.data.data ?? res.data.data
|
||||
if (d.password) hostForm.password = d.password
|
||||
if (d.token) hostForm.token = d.token
|
||||
if (d.private_key) hostForm.private_key = d.private_key
|
||||
diskIoFields.forEach(f => { if (d[f.key] !== undefined) hostForm[f.key] = d[f.key] })
|
||||
}
|
||||
}).catch(() => {})
|
||||
hostDialogVisible.value = true
|
||||
@@ -723,7 +819,8 @@ 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
|
||||
description: '', expire_hours: 24,
|
||||
...diskIoDefaults
|
||||
})
|
||||
const tokenResultInfo = reactive({ name: '', expire_hours: 24, token: '', service_id: 0 })
|
||||
const tokenRules = {
|
||||
@@ -751,10 +848,13 @@ const openTokenDialog = () => {
|
||||
name: '', host_group_id: 0, max_cpu: 4,
|
||||
max_memory: 4194304, max_disk: 100,
|
||||
rx_bandwidth: 100, tx_bandwidth: 100,
|
||||
description: '', expire_hours: 24
|
||||
description: '', expire_hours: 24,
|
||||
...diskIoDefaults
|
||||
})
|
||||
tokenMemUnit.value = 'GB'
|
||||
tokenDiskUnit.value = 'GB'
|
||||
showTokenDiskIo.value = false
|
||||
tokenIoBwUnit.value = 'MB/s'
|
||||
tokenDialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -772,6 +872,7 @@ const handleTokenSubmit = () => {
|
||||
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)
|
||||
@@ -839,4 +940,10 @@ onMounted(() => { if (serviceId.value) loadTreeData() })
|
||||
|
||||
.host-addr { color: #409eff; font-size: 13px; }
|
||||
.host-url { color: #909399; font-size: 12px; }
|
||||
.tk-section-title.clickable { cursor: pointer; user-select: none; display: flex; align-items: center; gap: 6px; }
|
||||
.tk-section-title.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; }
|
||||
.io-sub-title { font-size: 13px; font-weight: 500; color: #606266; margin: 12px 0 8px; display: flex; align-items: center; }
|
||||
</style>
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
<el-dropdown-item divided command="editVm" :disabled="isMigrating">修改虚拟机</el-dropdown-item>
|
||||
<el-dropdown-item command="refactorVm" :disabled="isMigrating">重构虚拟机</el-dropdown-item>
|
||||
<el-dropdown-item command="updateTraffic" :disabled="isMigrating">修改带宽</el-dropdown-item>
|
||||
<el-dropdown-item command="resetMac" :disabled="isMigrating">重置MAC地址</el-dropdown-item>
|
||||
<el-dropdown-item command="disconnectNetwork" :disabled="isMigrating || detail.network_disabled">断网</el-dropdown-item>
|
||||
<el-dropdown-item command="connectNetwork" :disabled="isMigrating || !detail.network_disabled">恢复网络</el-dropdown-item>
|
||||
<el-dropdown-item divided command="rebuild" :disabled="isMigrating">重装虚拟机</el-dropdown-item>
|
||||
<el-dropdown-item command="rescue">救援模式</el-dropdown-item>
|
||||
<el-dropdown-item command="exitRescue">退出救援</el-dropdown-item>
|
||||
@@ -50,6 +53,7 @@
|
||||
<span class="status-value">
|
||||
<span class="status-dot" :class="detail.status === 'running' ? 'dot-running' : 'dot-other'"></span>
|
||||
{{ vmStatusLabel(detail.status) }}
|
||||
<el-tag v-if="detail.network_disabled" type="danger" size="small" effect="dark" style="margin-left:8px">已断网</el-tag>
|
||||
<el-tag v-if="isMigrating" type="warning" size="small" effect="dark" style="margin-left:8px">迁移中</el-tag>
|
||||
</span>
|
||||
</div>
|
||||
@@ -221,8 +225,23 @@
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<div class="config-cell">
|
||||
<span class="config-label">流量上限</span>
|
||||
<span class="config-value">{{ formatTrafficMax(detail.traffic_max) }}</span>
|
||||
<span class="config-label">流量</span>
|
||||
<span class="config-value traffic-cell">
|
||||
<span class="traffic-main">{{ formatTrafficMb(detailTrafficUsedMb) }} / {{ formatTrafficMb(detailTrafficTotalMb) }}</span>
|
||||
<span class="traffic-sub">
|
||||
基础 {{ formatTrafficMb(detailTrafficBaseMb) }}
|
||||
<template v-if="detailTrafficTempMb > 0">
|
||||
· 临时 {{ formatTrafficMb(detailTrafficTempMb) }}
|
||||
<span v-if="detailTrafficCycleStartText" class="traffic-cycle">(周期 {{ detailTrafficCycleStartText }})</span>
|
||||
</template>
|
||||
</span>
|
||||
<span class="traffic-actions">
|
||||
<el-button link type="primary" size="small" class="cfg-edit-btn" @click="openVmTrafficPolicyDialog" :disabled="isMigrating">
|
||||
<el-icon :size="14"><Edit /></el-icon>修改
|
||||
</el-button>
|
||||
<el-button link type="warning" size="small" class="cfg-edit-btn" @click="openVmAddTrafficDialog('temporary')" :disabled="isMigrating">加临时</el-button>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="config-cell">
|
||||
<span class="config-label">快照配额</span>
|
||||
@@ -265,6 +284,11 @@
|
||||
<el-table-column prop="gateway" label="网关" min-width="120" />
|
||||
<el-table-column prop="nameservers" label="DNS" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="mac_address" label="MAC地址" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="主IP" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.is_primary" type="success" size="small" effect="dark">主</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.type === 'bridge' ? 'success' : 'warning'" size="small">{{ row.type === 'bridge' ? '网桥' : 'NAT' }}</el-tag>
|
||||
@@ -272,10 +296,11 @@
|
||||
</el-table-column>
|
||||
<el-table-column prop="bridge_name" label="网桥" width="100" />
|
||||
<el-table-column prop="target_device" label="目标设备" width="100" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<el-table-column label="操作" width="240" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="handleNetDetail(row)">详情</el-button>
|
||||
<el-button link type="primary" size="small" @click="handleNetEdit(row)">编辑</el-button>
|
||||
<el-button v-if="!row.is_primary" link type="warning" size="small" @click="handleSetPrimary(row)">设为主IP</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleNetDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -606,6 +631,11 @@
|
||||
<div class="metric-summary-value">↓{{ formatNetLabel(latestMetrics.net_rx) }}</div>
|
||||
<div class="metric-summary-sub">↑{{ formatNetLabel(latestMetrics.net_tx) }}</div>
|
||||
</div>
|
||||
<div class="metric-summary-card">
|
||||
<div class="metric-summary-label">累计流量</div>
|
||||
<div class="metric-summary-value">{{ latestMetrics.traffic_used_mb != null ? latestMetrics.traffic_used_mb + ' MB' : '-' }}</div>
|
||||
<div class="metric-summary-sub"> </div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="historicalMetricsData">
|
||||
@@ -618,7 +648,7 @@
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card shadow="hover" class="metrics-card">
|
||||
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 内存使用率</span></template>
|
||||
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 内存使用</span></template>
|
||||
<div ref="memChartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
@@ -637,13 +667,27 @@
|
||||
</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><Refresh /></el-icon> 磁盘 IOPS</span></template>
|
||||
<div ref="diskIopsChartRef" 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><Refresh /></el-icon> 流量使用趋势</span></template>
|
||||
<div ref="trafficUsedChartRef" 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="vmTrafficPolicy">
|
||||
<!-- 流量管理(合并流量策略 + 流量统计) -->
|
||||
<el-tab-pane label="流量管理" name="trafficManage">
|
||||
<div class="section-block">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">流量策略</h3>
|
||||
@@ -661,6 +705,30 @@
|
||||
</el-descriptions>
|
||||
<el-empty v-else-if="!vmTrafficPolicyLoading" description="暂无流量策略数据" :image-size="60" />
|
||||
</div>
|
||||
<div class="section-block">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">每小时流量</h3>
|
||||
<div style="display: flex; align-items: center; gap: 8px">
|
||||
<el-date-picker
|
||||
v-model="trafficHourlyRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
size="small"
|
||||
style="width: 360px"
|
||||
:shortcuts="monitorShortcuts"
|
||||
@change="loadTrafficHourly"
|
||||
/>
|
||||
<el-button size="small" :icon="Refresh" @click="loadTrafficHourly" :loading="trafficHourlyLoading">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-card v-if="trafficHourlyData.length" shadow="hover" class="metrics-card" style="margin-top:12px">
|
||||
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 每小时流量(MB)</span></template>
|
||||
<div ref="trafficHourlyChartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
<el-empty v-else-if="!trafficHourlyLoading" description="暂无流量统计数据" :image-size="60" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
</el-tabs>
|
||||
@@ -1564,7 +1632,7 @@
|
||||
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, onBeforeUnmount, nextTick, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Refresh, ArrowDown, Plus, Search, WarningFilled, Loading } from '@element-plus/icons-vue'
|
||||
import { ArrowLeft, Refresh, ArrowDown, Plus, Search, WarningFilled, Loading, Edit } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getVmDetail, getVmStatus,
|
||||
startVm, stopVm, rebootVm, suspendVm, resumeVm,
|
||||
@@ -1583,7 +1651,9 @@ import {
|
||||
migrateVm, getRemoteHostGroupList, getRemoteHostDetail,
|
||||
dataMigrateVm, getDataMigrateProgress, abortDataMigrate,
|
||||
getKvmServiceList, getMetricsHistory, getNetworkList,
|
||||
getVmTrafficPolicy, updateVmTrafficPolicy, addVmFixedTraffic, addVmTemporaryTraffic
|
||||
getVmTrafficPolicy, updateVmTrafficPolicy, addVmFixedTraffic, addVmTemporaryTraffic,
|
||||
setNetworkPrimary, resetVmMac,
|
||||
disconnectVmNetwork, connectVmNetwork, getVmTrafficHourly
|
||||
} from '@/api/admin/kvmService'
|
||||
import { getUserInfo } from '@/api/admin/user'
|
||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||
@@ -1644,6 +1714,33 @@ const formatTrafficMax = (val) => {
|
||||
return `${gb.toFixed(2)} GB`
|
||||
}
|
||||
|
||||
// 流量分段展示:以 MB 输入,自适应输出单位;与 formatTrafficMax 区别是允许 0 / 小数值精简显示
|
||||
const formatTrafficMb = (mb) => {
|
||||
const n = Number(mb)
|
||||
if (!Number.isFinite(n) || n <= 0) return '0 MB'
|
||||
if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(2)} TB`
|
||||
if (n >= 1024) return `${(n / 1024).toFixed(2)} GB`
|
||||
return `${n} MB`
|
||||
}
|
||||
|
||||
// 概览流量段:优先使用 add.json 新字段 traffic_max_mb / temporary_traffic_mb,fallback 旧字段 traffic_max
|
||||
// traffic_used 单位假设为 MB(与 traffic_max_mb 同维度),如后端实为字节需调整
|
||||
const detailTrafficBaseMb = computed(() => Number(detail.value?.traffic_max_mb ?? detail.value?.traffic_max ?? 0) || 0)
|
||||
const detailTrafficTempMb = computed(() => Number(detail.value?.temporary_traffic_mb || 0))
|
||||
const detailTrafficTotalMb = computed(() => detailTrafficBaseMb.value + detailTrafficTempMb.value)
|
||||
const detailTrafficUsedMb = computed(() => Number(detail.value?.traffic_used || 0))
|
||||
const detailTrafficCycleStartText = computed(() => {
|
||||
const t = detail.value?.temporary_cycle_start
|
||||
const sec = typeof t === 'object' ? (t?.seconds ?? null) : t
|
||||
if (!sec) return ''
|
||||
const d = new Date(Number(sec) * 1000)
|
||||
if (isNaN(d.getTime())) return ''
|
||||
const yyyy = d.getFullYear()
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
})
|
||||
|
||||
const copyAllIps = (ipList) => {
|
||||
if (!ipList?.length) return
|
||||
const text = ipList.join('\n')
|
||||
@@ -1694,7 +1791,8 @@ const handleMoreCommand = (cmd) => {
|
||||
const actionMap = {
|
||||
editVm: handleEditVm, refactorVm: handleRefactorVm, updateTraffic: handleUpdateTraffic,
|
||||
rebuild: handleRebuild, rescue: handleRescue, exitRescue: handleExitRescue,
|
||||
migrateVm: handleMigrateVm
|
||||
migrateVm: handleMigrateVm, resetMac: handleResetMac,
|
||||
disconnectNetwork: handleDisconnectNetwork, connectNetwork: handleConnectNetwork
|
||||
}
|
||||
if (actionMap[cmd]) actionMap[cmd]()
|
||||
if (cmd === 'dataMigrateVm') handleDataMigrateVm()
|
||||
@@ -1800,10 +1898,14 @@ const cpuChartRef = ref(null)
|
||||
const memChartRef = ref(null)
|
||||
const netChartRef = ref(null)
|
||||
const diskChartRef = ref(null)
|
||||
const diskIopsChartRef = ref(null)
|
||||
const trafficUsedChartRef = ref(null)
|
||||
let cpuChart = null
|
||||
let memChart = null
|
||||
let netChart = null
|
||||
let diskChart = null
|
||||
let diskIopsChart = null
|
||||
let trafficUsedChart = null
|
||||
let isPageActive = false
|
||||
|
||||
const historicalMetricsData = ref(null)
|
||||
@@ -1931,9 +2033,28 @@ const renderHistoricalCharts = () => {
|
||||
})
|
||||
|
||||
const cpuData = metrics.map(m => m.cpu_usage ?? 0)
|
||||
const memData = metrics.map(m => m.mem_total ? ((m.mem_used / m.mem_total) * 100) : 0)
|
||||
const memData = metrics.map(m => m.mem_used ?? 0)
|
||||
const memTotal = Math.max(...metrics.map(m => m.mem_total ?? 0))
|
||||
const memAxisFmt = memTotal >= 1048576
|
||||
? (v) => (v / 1048576).toFixed(1) + ' GB'
|
||||
: memTotal >= 1024
|
||||
? (v) => (v / 1024).toFixed(0) + ' MB'
|
||||
: (v) => v + ' KB'
|
||||
|
||||
const diskReadData = metrics.map(m => m.disk_read ?? 0)
|
||||
const diskWriteData = metrics.map(m => m.disk_write ?? 0)
|
||||
|
||||
const diskReadRate = []
|
||||
const diskWriteRate = []
|
||||
for (let i = 0; i < metrics.length; i++) {
|
||||
if (i === 0) { diskReadRate.push(0); diskWriteRate.push(0); continue }
|
||||
const dt = (new Date(metrics[i].bucket) - new Date(metrics[i - 1].bucket)) / 1000
|
||||
if (dt > 0) {
|
||||
diskReadRate.push(+Math.max(0, ((metrics[i].disk_read ?? 0) - (metrics[i - 1].disk_read ?? 0)) / dt / 1024).toFixed(2))
|
||||
diskWriteRate.push(+Math.max(0, ((metrics[i].disk_write ?? 0) - (metrics[i - 1].disk_write ?? 0)) / dt / 1024).toFixed(2))
|
||||
} else { diskReadRate.push(0); diskWriteRate.push(0) }
|
||||
}
|
||||
|
||||
const netRxData = metrics.map(m => m.net_rx ?? 0)
|
||||
const netTxData = metrics.map(m => m.net_tx ?? 0)
|
||||
|
||||
@@ -1954,9 +2075,9 @@ const renderHistoricalCharts = () => {
|
||||
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 + '%' } },
|
||||
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} 内存: ${formatMemKB(p[0].value)}` },
|
||||
grid: { ...baseGrid, left: 60 }, xAxis: makeXAxis(),
|
||||
yAxis: { type: 'value', min: 0, max: memTotal || undefined, axisLabel: { fontSize: 10, formatter: memAxisFmt } },
|
||||
series: [makeSeries('内存', memData, '#67c23a')]
|
||||
}, true)
|
||||
}
|
||||
@@ -1975,6 +2096,20 @@ const renderHistoricalCharts = () => {
|
||||
}, true)
|
||||
}
|
||||
|
||||
if (diskIopsChartRef.value) {
|
||||
if (!diskIopsChart) diskIopsChart = echarts.init(diskIopsChartRef.value)
|
||||
diskIopsChart.setOption({
|
||||
tooltip: { trigger: 'axis', formatter: (params) => {
|
||||
let s = params[0].axisValue
|
||||
params.forEach(p => { s += `<br/>${p.marker} ${p.seriesName}: ${formatBytesRaw(p.value)}/s` })
|
||||
return s
|
||||
}},
|
||||
grid: baseGrid, xAxis: makeXAxis(),
|
||||
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: v => formatBytesRaw(v) + '/s' } },
|
||||
series: [makeSeries('读取', diskReadRate, '#409eff'), makeSeries('写入', diskWriteRate, '#e6a23c')]
|
||||
}, true)
|
||||
}
|
||||
|
||||
if (netChartRef.value) {
|
||||
if (!netChart) netChart = echarts.init(netChartRef.value)
|
||||
netChart.setOption({
|
||||
@@ -1988,6 +2123,22 @@ const renderHistoricalCharts = () => {
|
||||
series: [makeSeries('接收', netRxData, '#409eff'), makeSeries('发送', netTxData, '#e6a23c')]
|
||||
}, true)
|
||||
}
|
||||
|
||||
if (trafficUsedChartRef.value) {
|
||||
if (!trafficUsedChart) trafficUsedChart = echarts.init(trafficUsedChartRef.value)
|
||||
const trafficDelta = []
|
||||
for (let i = 0; i < metrics.length; i++) {
|
||||
if (i === 0) { trafficDelta.push(0); continue }
|
||||
const delta = Math.max(0, (metrics[i].traffic_used_mb ?? 0) - (metrics[i - 1].traffic_used_mb ?? 0))
|
||||
trafficDelta.push(+delta.toFixed(2))
|
||||
}
|
||||
trafficUsedChart.setOption({
|
||||
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} 流量增量: ${p[0].value} MB` },
|
||||
grid: baseGrid, xAxis: makeXAxis(),
|
||||
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: v => v + ' MB' } },
|
||||
series: [makeSeries('流量增量', trafficDelta, '#722ed1')]
|
||||
}, true)
|
||||
}
|
||||
}
|
||||
|
||||
const disposeCharts = () => {
|
||||
@@ -1995,6 +2146,9 @@ const disposeCharts = () => {
|
||||
memChart?.dispose(); memChart = null
|
||||
netChart?.dispose(); netChart = null
|
||||
diskChart?.dispose(); diskChart = null
|
||||
diskIopsChart?.dispose(); diskIopsChart = null
|
||||
trafficUsedChart?.dispose(); trafficUsedChart = null
|
||||
trafficHourlyChart?.dispose(); trafficHourlyChart = null
|
||||
}
|
||||
|
||||
const powerDialogVisible = ref(false)
|
||||
@@ -2287,12 +2441,15 @@ const loadVmTrafficPolicy = async () => {
|
||||
}
|
||||
|
||||
const openVmTrafficPolicyDialog = () => {
|
||||
// 从概览触发时 vmTrafficPolicy 可能尚未加载(流量策略 tab 懒加载),
|
||||
// 用 detail.value 上 add.json 新字段作 fallback;同时异步加载策略
|
||||
Object.assign(vmTrafficPolicyForm, {
|
||||
traffic_max_mb: vmTrafficPolicy.value?.traffic_max_mb || 0,
|
||||
exhausted_rx_mbps: vmTrafficPolicy.value?.exhausted_rx_mbps || 0,
|
||||
exhausted_tx_mbps: vmTrafficPolicy.value?.exhausted_tx_mbps || 0
|
||||
traffic_max_mb: vmTrafficPolicy.value?.traffic_max_mb ?? detail.value?.traffic_max_mb ?? detail.value?.traffic_max ?? 0,
|
||||
exhausted_rx_mbps: vmTrafficPolicy.value?.exhausted_rx_mbps ?? detail.value?.traffic_exhausted_rx_mbps ?? 0,
|
||||
exhausted_tx_mbps: vmTrafficPolicy.value?.exhausted_tx_mbps ?? detail.value?.traffic_exhausted_tx_mbps ?? 0
|
||||
})
|
||||
vmTrafficPolicyVisible.value = true
|
||||
if (!vmTrafficPolicy.value) loadVmTrafficPolicy()
|
||||
}
|
||||
|
||||
const submitUpdateVmTrafficPolicy = async () => {
|
||||
@@ -2780,6 +2937,122 @@ const handleNetDelete = (row) => {
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleSetPrimary = (row) => {
|
||||
ElMessageBox.confirm(
|
||||
`将「${row.address || row.name}」设为主IP?设置后将重启虚拟机。`,
|
||||
'设置主IP', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
|
||||
).then(async () => {
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('service_id', serviceId.value)
|
||||
fd.append('network_id', row.id)
|
||||
fd.append('host_id', row.host_id || detail.value?.host_id)
|
||||
const res = await setNetworkPrimary(fd)
|
||||
if (res?.data?.code === 200) { ElMessage.success('设置主IP成功,虚拟机将重启'); loadDetail() }
|
||||
else ElMessage.error(extractApiError(res?.data, '设置主IP失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '设置主IP失败')) }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleResetMac = () => {
|
||||
ElMessageBox.confirm(
|
||||
'重置MAC地址将重建 CloudInit 并重启虚拟机,确定继续?',
|
||||
'重置MAC地址', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
|
||||
).then(async () => {
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('service_id', serviceId.value)
|
||||
fd.append('vm_id', detail.value?.id)
|
||||
const res = await resetVmMac(fd)
|
||||
if (res?.data?.code === 200) { ElMessage.success('MAC地址重置成功,虚拟机将重启'); loadDetail() }
|
||||
else ElMessage.error(extractApiError(res?.data, '重置MAC失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '重置MAC失败')) }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleDisconnectNetwork = () => {
|
||||
ElMessageBox.confirm(
|
||||
'断开虚拟机外部网络连接?断网后虚拟机将无法访问外部网络。',
|
||||
'断网', { confirmButtonText: '确定断网', cancelButtonText: '取消', type: 'warning' }
|
||||
).then(async () => {
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('service_id', serviceId.value)
|
||||
fd.append('vm_id', detail.value?.id)
|
||||
const res = await disconnectVmNetwork(fd)
|
||||
if (res?.data?.code === 200) { ElMessage.success('已断网'); loadDetail() }
|
||||
else ElMessage.error(extractApiError(res?.data, '断网失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '断网失败')) }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleConnectNetwork = () => {
|
||||
ElMessageBox.confirm(
|
||||
'恢复虚拟机外部网络连接?',
|
||||
'恢复网络', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'info' }
|
||||
).then(async () => {
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('service_id', serviceId.value)
|
||||
fd.append('vm_id', detail.value?.id)
|
||||
const res = await connectVmNetwork(fd)
|
||||
if (res?.data?.code === 200) { ElMessage.success('网络已恢复'); loadDetail() }
|
||||
else ElMessage.error(extractApiError(res?.data, '恢复网络失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '恢复网络失败')) }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// ---- 每小时流量统计 ----
|
||||
const trafficHourlyRange = ref(null)
|
||||
const trafficHourlyData = ref([])
|
||||
const trafficHourlyLoading = ref(false)
|
||||
const trafficHourlyChartRef = ref(null)
|
||||
let trafficHourlyChart = null
|
||||
|
||||
const loadTrafficHourly = async () => {
|
||||
if (!serviceId.value || !detail.value?.host_id || !detail.value?.name) return
|
||||
if (!trafficHourlyRange.value) {
|
||||
const now = new Date()
|
||||
trafficHourlyRange.value = [new Date(now.getTime() - 24 * 3600 * 1000), now]
|
||||
}
|
||||
trafficHourlyLoading.value = true
|
||||
try {
|
||||
const res = await getVmTrafficHourly({
|
||||
service_id: serviceId.value,
|
||||
host_id: detail.value.host_id,
|
||||
vm_name: detail.value.name,
|
||||
start: new Date(trafficHourlyRange.value[0]).toISOString(),
|
||||
end_time: new Date(trafficHourlyRange.value[1]).toISOString()
|
||||
})
|
||||
const raw = res?.data?.data?.data
|
||||
trafficHourlyData.value = typeof raw === 'string' ? JSON.parse(raw) : (Array.isArray(raw) ? raw : [])
|
||||
nextTick(renderTrafficHourlyChart)
|
||||
} catch { trafficHourlyData.value = [] } finally { trafficHourlyLoading.value = false }
|
||||
}
|
||||
|
||||
const renderTrafficHourlyChart = () => {
|
||||
if (!trafficHourlyChartRef.value || !trafficHourlyData.value.length) return
|
||||
if (!trafficHourlyChart) trafficHourlyChart = echarts.init(trafficHourlyChartRef.value)
|
||||
const data = trafficHourlyData.value
|
||||
const times = data.map(d => {
|
||||
const date = new Date(d.bucket)
|
||||
return date.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' })
|
||||
})
|
||||
const toMB = (b) => +(b / 1048576).toFixed(2)
|
||||
trafficHourlyChart.setOption({
|
||||
tooltip: { trigger: 'axis', formatter: (params) => params.map(p => `${p.marker}${p.seriesName}: ${p.value} MB`).join('<br/>') },
|
||||
legend: { data: ['下行', '上行', '合计'], bottom: 0 },
|
||||
grid: { top: 10, right: 16, bottom: 40, left: 50 },
|
||||
xAxis: { type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 10 } },
|
||||
yAxis: { type: 'value', name: 'MB', axisLabel: { fontSize: 10 } },
|
||||
series: [
|
||||
{ name: '下行', type: 'bar', stack: 'traffic', data: data.map(d => toMB(d.rx_bytes)), itemStyle: { color: '#409EFF' } },
|
||||
{ name: '上行', type: 'bar', stack: 'traffic', data: data.map(d => toMB(d.tx_bytes)), itemStyle: { color: '#67C23A' } },
|
||||
{ name: '合计', type: 'line', smooth: true, data: data.map(d => toMB(d.total_bytes)), itemStyle: { color: '#E6A23C' }, lineStyle: { width: 2 } }
|
||||
]
|
||||
}, true)
|
||||
}
|
||||
|
||||
// ---- 数据卷操作(绑定/创建/调整/挂载/卸载/迁移/删除/详情) ----
|
||||
const showVolSelector = ref(false)
|
||||
|
||||
@@ -3619,7 +3892,7 @@ const triggerTabLoad = (tab) => {
|
||||
if (tab === 'backup') { loadBackups(); loadBackupQuota() }
|
||||
if (tab === 'userNetworking') loadVmNetworkingList()
|
||||
if (tab === 'security') loadSgLockInfo()
|
||||
if (tab === 'vmTrafficPolicy') loadVmTrafficPolicy()
|
||||
if (tab === 'trafficManage') { loadVmTrafficPolicy(); loadTrafficHourly() }
|
||||
}
|
||||
|
||||
// 请求安全组详情补充 lock 字段
|
||||
@@ -3692,6 +3965,14 @@ onMounted(() => { isPageActive = true; initPage() })
|
||||
.config-cell:last-child { border-right: none; }
|
||||
.config-label { font-size: 12px; color: #86909c; line-height: 1; }
|
||||
.config-value { font-size: 14px; color: #1d2129; line-height: 1.4; word-break: break-all; }
|
||||
.cfg-edit-btn { margin-left: 8px; padding: 0 4px; height: 18px; vertical-align: middle; }
|
||||
.cfg-edit-btn .el-icon { margin-right: 2px; vertical-align: -2px; }
|
||||
.traffic-cell { display: flex; flex-direction: column; gap: 2px; }
|
||||
.traffic-cell .traffic-main { font-size: 14px; color: #1d2129; }
|
||||
.traffic-cell .traffic-sub { font-size: 12px; color: #86909c; line-height: 1.3; }
|
||||
.traffic-cell .traffic-cycle { color: #c0c4cc; margin-left: 2px; }
|
||||
.traffic-cell .traffic-actions { display: inline-flex; gap: 6px; margin-top: 2px; }
|
||||
.traffic-cell .traffic-actions .cfg-edit-btn { margin-left: 0; }
|
||||
.spec-value { font-size: 13px; color: #4e5969; }
|
||||
.ip-value { color: #165dff; font-weight: 500; }
|
||||
.password-cell { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
@@ -391,6 +391,7 @@
|
||||
<el-descriptions-item label="磁盘写入">{{ formatBytesRaw(vmMetricsData.disk_write) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="网络接收">{{ formatNetSpeed(vmMetricsData.net_rx) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="网络发送">{{ formatNetSpeed(vmMetricsData.net_tx) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="累计流量">{{ vmMetricsData.traffic_used_mb != null ? vmMetricsData.traffic_used_mb + ' MB' : '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user