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: |
|
run: |
|
||||||
pnpm build
|
pnpm build
|
||||||
|
|
||||||
|
- name: Compress artifacts
|
||||||
|
run: |
|
||||||
|
tar -czf dist.tar.gz -C ./dist .
|
||||||
|
|
||||||
- name: Save artifact
|
- name: Save artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: vue3-build
|
name: vue3-build
|
||||||
path: |
|
path: dist.tar.gz
|
||||||
./dist
|
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
needs: build
|
needs: build
|
||||||
@@ -49,11 +52,11 @@ jobs:
|
|||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
echo "${{ secrets.PUBLICT_PRIVATE_KEY }}" > ~/.ssh/id_rsa
|
echo "${{ secrets.PUBLICT_PRIVATE_KEY }}" > ~/.ssh/id_rsa
|
||||||
chmod 600 ~/.ssh/id_rsa
|
chmod 600 ~/.ssh/id_rsa
|
||||||
|
ssh-keyscan -H ${{ vars.WEB_SERVICE_SERVER_IP }} >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
- name: Deploy to server
|
- name: Deploy to server
|
||||||
run: |
|
run: |
|
||||||
ssh-keyscan -H ${{ vars.WEB_SERVICE_SERVER_IP_1 }} >> ~/.ssh/known_hosts
|
DEPLOY_DIR="/home/www/web-online/admin.007yjs.com/"
|
||||||
scp -o StrictHostKeyChecking=no -r ./* ${{ vars.ROOT_USER_NAME }}@${{ vars.WEB_SERVICE_SERVER_IP_1 }}:/home/www/admin.007yjs.com/
|
ssh ${{ vars.ROOT_USER_NAME }}@${{ vars.WEB_SERVICE_SERVER_IP }} "mkdir -p $DEPLOY_DIR"
|
||||||
ssh-keyscan -H ${{ vars.WEB_SERVICE_SERVER_IP_2 }} >> ~/.ssh/known_hosts
|
scp -o StrictHostKeyChecking=no dist.tar.gz ${{ vars.ROOT_USER_NAME }}@${{ vars.WEB_SERVICE_SERVER_IP }}:$DEPLOY_DIR
|
||||||
scp -o StrictHostKeyChecking=no -r ./* ${{ vars.ROOT_USER_NAME }}@${{ vars.WEB_SERVICE_SERVER_IP_2 }}:/home/www/admin.007yjs.com/
|
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: |
|
run: |
|
||||||
pnpm build
|
pnpm build
|
||||||
|
|
||||||
|
- name: Compress artifacts
|
||||||
|
run: |
|
||||||
|
tar -czf dist.tar.gz -C ./dist .
|
||||||
|
|
||||||
- name: Save artifact
|
- name: Save artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: vue3-build
|
name: vue3-build
|
||||||
path: |
|
path: dist.tar.gz
|
||||||
./dist
|
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
needs: build
|
needs: build
|
||||||
@@ -49,5 +52,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Deploy to server
|
- name: Deploy to server
|
||||||
run: |
|
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 })
|
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 getUserVmNetworkList = (params) => http2.get(`${BASE}/network/list`, { params })
|
||||||
export const getUserVmNetworkDetail = (params) => http2.get(`${BASE}/network/detail`, { 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 })
|
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 deleteUserGoods = (params) => http2.delete(`${GOODS_BASE}/delete`, { params })
|
||||||
|
|
||||||
export const getUserVmMetricsHistory = (params) => http2.get(`${BASE}/metrics_history`, { 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,
|
lock: createForm.lock,
|
||||||
drop_all: createForm.drop_all
|
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('创建成功')
|
ElMessage.success('创建成功')
|
||||||
showCreate.value = false
|
showCreate.value = false
|
||||||
Object.assign(createForm, { name: '', direction: 'in', lock: false, drop_all: false })
|
Object.assign(createForm, { name: '', direction: 'in', lock: false, drop_all: false })
|
||||||
loadList()
|
await loadList()
|
||||||
} else ElMessage.error(res?.data?.message || '创建失败')
|
} else {
|
||||||
} catch { ElMessage.error('创建失败') } finally { createLoading.value = false }
|
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 }
|
const handleClose = () => { visible.value = false }
|
||||||
|
|||||||
@@ -474,7 +474,7 @@ const orderRules = {
|
|||||||
{ type: 'number', message: '用户ID必须是数字', trigger: 'blur' }
|
{ type: 'number', message: '用户ID必须是数字', trigger: 'blur' }
|
||||||
],
|
],
|
||||||
coupon_id: [
|
coupon_id: [
|
||||||
{ required: true, message: '请输入代金券ID', trigger: 'blur' },
|
{ message: '请输入代金券ID', trigger: 'blur' },
|
||||||
{ type: 'number', message: '代金券ID必须是数字', trigger: 'blur' }
|
{ type: 'number', message: '代金券ID必须是数字', trigger: 'blur' }
|
||||||
],
|
],
|
||||||
pay_num: [
|
pay_num: [
|
||||||
@@ -756,15 +756,18 @@ const submitForm = () => {
|
|||||||
expireTimeSeconds = timestamp || 0
|
expireTimeSeconds = timestamp || 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// 准备提交的数据
|
// 2026-05-12: /api/v1/admin/order/create 与 /update 入参 price / renew_price 单位由
|
||||||
|
// "分"改为"元"(后端接口变更,用户确认两端都已生效);列表与表单内部仍按"分"持有,
|
||||||
|
// 提交时统一除以 100 做"分→元"换算,避免再次入库时被当成元导致额外放大 100 倍。
|
||||||
|
// 输入框旁的"分"单位文案暂不改动(用户明确仅要求 /100),UI 一致性问题待后续单独处理。
|
||||||
const submitData = {
|
const submitData = {
|
||||||
name: orderForm.name,
|
name: orderForm.name,
|
||||||
table: orderForm.table,
|
table: orderForm.table,
|
||||||
user_id: Number(orderForm.user_id),
|
user_id: Number(orderForm.user_id),
|
||||||
commodity_id: Number(orderForm.commodity_id),
|
commodity_id: Number(orderForm.commodity_id),
|
||||||
pay_num: Number(orderForm.pay_num),
|
pay_num: Number(orderForm.pay_num),
|
||||||
price: Number(orderForm.price),
|
price: Number(orderForm.price) / 100,
|
||||||
renew_price: Number(orderForm.renew_price),
|
renew_price: Number(orderForm.renew_price) / 100,
|
||||||
expire_time: expireTimeSeconds,
|
expire_time: expireTimeSeconds,
|
||||||
discount_code_id: Number(orderForm.discount_code_id),
|
discount_code_id: Number(orderForm.discount_code_id),
|
||||||
coupon_id: Number(orderForm.coupon_id),
|
coupon_id: Number(orderForm.coupon_id),
|
||||||
|
|||||||
@@ -106,6 +106,13 @@
|
|||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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">
|
<el-table-column label="操作" width="280" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
|
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
|
||||||
@@ -234,6 +241,10 @@
|
|||||||
<el-form-item label="库存数量" prop="inventory">
|
<el-form-item label="库存数量" prop="inventory">
|
||||||
<el-input-number v-model="productForm.inventory" :min="0" placeholder="请输入库存" style="width: 100%" />
|
<el-input-number v-model="productForm.inventory" :min="0" placeholder="请输入库存" style="width: 100%" />
|
||||||
</el-form-item>
|
</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">
|
<el-form-item label="商品价格" prop="price">
|
||||||
<div class="unit-input-row">
|
<div class="unit-input-row">
|
||||||
<el-input-number v-model="productForm.price" :min="0" :precision="2" :step="0.01" placeholder="请输入价格(元)" style="flex:1" />
|
<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: false,
|
||||||
recommend_rebate: 0,
|
recommend_rebate: 0,
|
||||||
arg_type: 'all', // 商品参数类型 all/plan/customize
|
arg_type: 'all', // 商品参数类型 all/plan/customize
|
||||||
attribution_id: '' // 归属项ID
|
attribution_id: '', // 归属项ID
|
||||||
|
sold_out: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const productRules = {
|
const productRules = {
|
||||||
@@ -1261,7 +1273,8 @@ const handleEdit = (row) => {
|
|||||||
expire_time: row.expireTime,
|
expire_time: row.expireTime,
|
||||||
recommend: row.recommend,
|
recommend: row.recommend,
|
||||||
recommend_rebate: row.recommendRebate,
|
recommend_rebate: row.recommendRebate,
|
||||||
arg_type: row.argType || 'all'
|
arg_type: row.argType || 'all',
|
||||||
|
sold_out: !!row.soldOut
|
||||||
})
|
})
|
||||||
coverPreviewUrl.value = row.cover || ''
|
coverPreviewUrl.value = row.cover || ''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,6 +162,17 @@
|
|||||||
<el-form-item label="说明" prop="note">
|
<el-form-item label="说明" prop="note">
|
||||||
<el-input v-model="planForm.note" type="textarea" :rows="2" placeholder="请输入套餐说明" />
|
<el-input v-model="planForm.note" type="textarea" :rows="2" placeholder="请输入套餐说明" />
|
||||||
</el-form-item>
|
</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">
|
<el-form-item label="参数配置" prop="args">
|
||||||
<div class="args-config-container">
|
<div class="args-config-container">
|
||||||
<div class="args-select-row">
|
<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 selectedArgSpecs = computed(() => planSpecList.value.filter(spec => selectedArgIds.value.includes(spec.id)))
|
||||||
const extraSpecList = 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) => {
|
const getSpecDisplayMin = (spec) => {
|
||||||
if (!hasUnit(spec)) return spec.min ?? 0
|
if (!hasUnit(spec)) return spec.min ?? 0
|
||||||
@@ -1184,6 +1199,26 @@ watch(() => props.visible, (val) => {
|
|||||||
.plan-card-actions .el-button + .el-button {
|
.plan-card-actions .el-button + .el-button {
|
||||||
margin-left: 0;
|
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 { max-height: 60vh; overflow-y: auto; padding-right: 8px; margin-right: -8px; }
|
||||||
.plan-form-content::-webkit-scrollbar { width: 6px; }
|
.plan-form-content::-webkit-scrollbar { width: 6px; }
|
||||||
.plan-form-content::-webkit-scrollbar-track { background: transparent; }
|
.plan-form-content::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
|||||||
+1505
-2532
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@
|
|||||||
<div class="name-row">
|
<div class="name-row">
|
||||||
<h2 class="vm-name">{{ vm?.name || userGoods.good?.name || `用户虚拟机 #${userGoodsId}` }}</h2>
|
<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?.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="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>
|
<el-tag v-if="userGoods.tag" size="small" type="info" style="margin-left:4px">{{ userGoods.tag }}</el-tag>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,6 +47,9 @@
|
|||||||
<el-dropdown-item command="resume">恢复</el-dropdown-item>
|
<el-dropdown-item command="resume">恢复</el-dropdown-item>
|
||||||
<el-dropdown-item command="rescue" :disabled="!!vm?.rescue">救援模式</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="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 divided command="rebuild">重装系统</el-dropdown-item>
|
||||||
<el-dropdown-item command="updateVm">编辑虚拟机</el-dropdown-item>
|
<el-dropdown-item command="updateVm">编辑虚拟机</el-dropdown-item>
|
||||||
<el-dropdown-item command="refactorVm">重构虚拟机</el-dropdown-item>
|
<el-dropdown-item command="refactorVm">重构虚拟机</el-dropdown-item>
|
||||||
@@ -63,14 +67,20 @@
|
|||||||
<!-- VM 配置信息 -->
|
<!-- VM 配置信息 -->
|
||||||
<div class="vm-config-grid" v-if="vm">
|
<div class="vm-config-grid" v-if="vm">
|
||||||
<div class="config-row">
|
<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">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">{{ formatMemory(vm.memory) }}</span></div>
|
<div class="config-cell">
|
||||||
<div class="config-cell"><span class="config-label">下行带宽</span><span class="config-value">{{ vm.rx_bandwidth || 0 }} Mbps</span></div>
|
<span class="config-label">带宽 ↓ / ↑</span>
|
||||||
<div class="config-cell"><span class="config-label">上行带宽</span><span class="config-value">{{ vm.tx_bandwidth || 0 }} Mbps</span></div>
|
<span class="config-value">
|
||||||
</div>
|
{{ vm.rx_bandwidth || 0 }} / {{ vm.tx_bandwidth || 0 }} Mbps
|
||||||
<div class="config-row">
|
<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" 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 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">
|
<div class="config-cell">
|
||||||
<span class="config-label">外网IP</span>
|
<span class="config-label">外网IP</span>
|
||||||
<span class="config-value ip-value" v-if="vmPublicIpList.length">
|
<span class="config-value ip-value" v-if="vmPublicIpList.length">
|
||||||
@@ -129,7 +139,25 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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.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 class="config-cell"><span class="config-label">基础价格</span><span class="config-value">¥{{ (userGoods.basePrice / 100).toFixed(2) }}</span></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -305,7 +333,20 @@
|
|||||||
<el-table-column prop="address" label="地址(CIDR)" min-width="150" />
|
<el-table-column prop="address" label="地址(CIDR)" min-width="150" />
|
||||||
<el-table-column prop="gateway" label="网关" min-width="120" />
|
<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 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="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-table>
|
||||||
<el-empty v-if="!vmNetworks.length" :image-size="60" description="暂无网络" />
|
<el-empty v-if="!vmNetworks.length" :image-size="60" description="暂无网络" />
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
@@ -398,6 +439,11 @@
|
|||||||
<div class="metric-summary-value">↓{{ formatNetLabel(latestMetrics.net_rx) }}</div>
|
<div class="metric-summary-value">↓{{ formatNetLabel(latestMetrics.net_rx) }}</div>
|
||||||
<div class="metric-summary-sub">↑{{ formatNetLabel(latestMetrics.net_tx) }}</div>
|
<div class="metric-summary-sub">↑{{ formatNetLabel(latestMetrics.net_tx) }}</div>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="metricsData">
|
<template v-if="metricsData">
|
||||||
@@ -410,7 +456,7 @@
|
|||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-card shadow="hover" class="metrics-card">
|
<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>
|
<div ref="memChartRef" class="chart-container"></div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
@@ -429,13 +475,27 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</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>
|
</template>
|
||||||
<el-empty v-else-if="!metricsLoading" description="暂无监控数据" :image-size="80" />
|
<el-empty v-else-if="!metricsLoading" description="暂无监控数据" :image-size="80" />
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</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-block">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h3 class="section-title">流量策略</h3>
|
<h3 class="section-title">流量策略</h3>
|
||||||
@@ -453,6 +513,30 @@
|
|||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
<el-empty v-else-if="!trafficPolicyLoading" description="暂无流量策略数据" :image-size="60" />
|
<el-empty v-else-if="!trafficPolicyLoading" description="暂无流量策略数据" :image-size="60" />
|
||||||
</div>
|
</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-tab-pane>
|
||||||
|
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
@@ -1072,7 +1156,7 @@
|
|||||||
import { ref, reactive, computed, onMounted, onBeforeUnmount, onActivated, nextTick } from 'vue'
|
import { ref, reactive, computed, onMounted, onBeforeUnmount, onActivated, nextTick } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
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 {
|
import {
|
||||||
getUserVmDetail, getUserVmVnc, getUserVmHostImages,
|
getUserVmDetail, getUserVmVnc, getUserVmHostImages,
|
||||||
startUserVm, stopUserVm, rebootUserVm, suspendUserVm, resumeUserVm, rescueUserVm, exitRescueUserVm, rebuildUserVm, deleteUserVm,
|
startUserVm, stopUserVm, rebootUserVm, suspendUserVm, resumeUserVm, rescueUserVm, exitRescueUserVm, rebuildUserVm, deleteUserVm,
|
||||||
@@ -1083,11 +1167,14 @@ import {
|
|||||||
getUserVmPostGroupList, createUserVmPostGroup, updateUserVmPostGroup, bindUserVmPostGroup, unbindUserVmPostGroup, applyUserVmPostGroup, deleteUserVmPostGroup, enableUserVmPostGroupWhitelist, disableUserVmPostGroupWhitelist,
|
getUserVmPostGroupList, createUserVmPostGroup, updateUserVmPostGroup, bindUserVmPostGroup, unbindUserVmPostGroup, applyUserVmPostGroup, deleteUserVmPostGroup, enableUserVmPostGroupWhitelist, disableUserVmPostGroupWhitelist,
|
||||||
createUserVmPostGroupRule, updateUserVmPostGroupRule, deleteUserVmPostGroupRule,
|
createUserVmPostGroupRule, updateUserVmPostGroupRule, deleteUserVmPostGroupRule,
|
||||||
getUserVmPostGroupDetail,
|
getUserVmPostGroupDetail,
|
||||||
getUserVmNetworkList, getUserVmNetworkingList, createUserVmNetworking, assignUserVmNetworking, removeUserVmNetworkingNetwork, deleteUserVmNetworking,
|
getUserVmNetworkList, getUserVmNetworkDetail, getUserVmNetworkingList, createUserVmNetworking, assignUserVmNetworking, removeUserVmNetworkingNetwork, deleteUserVmNetworking,
|
||||||
getUserGoodsDetail,
|
getUserGoodsDetail,
|
||||||
getUserVmMetricsHistory,
|
getUserVmMetricsHistory, getUserVmTrafficHourly,
|
||||||
getUserVmTrafficPolicy, updateUserVmTrafficPolicy, addUserVmFixedTraffic, addUserVmTemporaryTraffic
|
getUserVmTrafficPolicy, updateUserVmTrafficPolicy, addUserVmFixedTraffic, addUserVmTemporaryTraffic,
|
||||||
|
setUserVmNetworkPrimary, resetUserVmMac,
|
||||||
|
disconnectUserVmNetwork, connectUserVmNetwork
|
||||||
} from '@/api/admin/userVm'
|
} from '@/api/admin/userVm'
|
||||||
|
import { deleteNetwork as deletePointNetwork } from '@/api/admin/kvmService'
|
||||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||||
import { vmStatusLabel as vmStatusLabelUtil, vmStatusType as vmStatusTypeUtil, volumeStatusLabel, volumeStatusType } from '@/utils/tool'
|
import { vmStatusLabel as vmStatusLabelUtil, vmStatusType as vmStatusTypeUtil, volumeStatusLabel, volumeStatusType } from '@/utils/tool'
|
||||||
import UserSelector from '@/components/UserSelector/index.vue'
|
import UserSelector from '@/components/UserSelector/index.vue'
|
||||||
@@ -1164,6 +1251,18 @@ const copyAllIps = async (ipList) => {
|
|||||||
|
|
||||||
const isWindows = computed(() => vmImage.value?.os_type === 'windows')
|
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(() => {
|
const vmPublicIpList = computed(() => {
|
||||||
return vmNetworks.value.filter(n => n.type === 'bridge').map(n => n.address ? n.address.split('/')[0] : n.name).filter(Boolean)
|
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 === 'security') loadSgLockInfo()
|
||||||
if (tab === 'networking') loadNetworkings()
|
if (tab === 'networking') loadNetworkings()
|
||||||
if (tab === 'monitor' && !metricsData.value) loadMetricsHistory()
|
if (tab === 'monitor' && !metricsData.value) loadMetricsHistory()
|
||||||
if (tab === 'trafficPolicy') loadTrafficPolicy()
|
if (tab === 'trafficManage') { loadTrafficPolicy(); loadTrafficHourly() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 请求安全组详情补充 lock 字段(使用用户虚拟机安全组详情接口)
|
// 请求安全组详情补充 lock 字段(使用用户虚拟机安全组详情接口)
|
||||||
@@ -1319,6 +1418,9 @@ const handleMoreCmd = (cmd) => {
|
|||||||
if (cmd === 'refactorVm') openRefactorVm()
|
if (cmd === 'refactorVm') openRefactorVm()
|
||||||
if (cmd === 'migrate') openMigrateVm()
|
if (cmd === 'migrate') openMigrateVm()
|
||||||
if (cmd === 'editGoods') openEditGoods()
|
if (cmd === 'editGoods') openEditGoods()
|
||||||
|
if (cmd === 'resetMac') handleResetVmMac()
|
||||||
|
if (cmd === 'disconnectNetwork') handleDisconnectVmNetwork()
|
||||||
|
if (cmd === 'connectNetwork') handleConnectVmNetwork()
|
||||||
if (cmd === 'delete') {
|
if (cmd === 'delete') {
|
||||||
ElMessageBox.confirm('确定删除该用户虚拟机吗?', '删除确认', { type: 'error' }).then(async () => {
|
ElMessageBox.confirm('确定删除该用户虚拟机吗?', '删除确认', { type: 'error' }).then(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -1644,6 +1746,101 @@ const loadNetworks = async () => {
|
|||||||
} catch { /* */ } finally { networkLoading.value = false }
|
} 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)
|
const showBindNetworkSelector = ref(false)
|
||||||
|
|
||||||
@@ -2019,12 +2216,15 @@ const loadTrafficPolicy = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openTrafficPolicyDialog = () => {
|
const openTrafficPolicyDialog = () => {
|
||||||
|
// 从概览触发时 trafficPolicy 可能尚未加载(trafficPolicy tab 懒加载),
|
||||||
|
// 用 vm.value 上 add.json 新字段作 fallback;同时异步加载策略以便后续 submit 时数据准确
|
||||||
Object.assign(trafficPolicyForm, {
|
Object.assign(trafficPolicyForm, {
|
||||||
traffic_max_mb: trafficPolicy.value?.traffic_max_mb || 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 || 0,
|
exhausted_rx_mbps: trafficPolicy.value?.exhausted_rx_mbps ?? vm.value?.traffic_exhausted_rx_mbps ?? 0,
|
||||||
exhausted_tx_mbps: trafficPolicy.value?.exhausted_tx_mbps || 0
|
exhausted_tx_mbps: trafficPolicy.value?.exhausted_tx_mbps ?? vm.value?.traffic_exhausted_tx_mbps ?? 0
|
||||||
})
|
})
|
||||||
trafficPolicyVisible.value = true
|
trafficPolicyVisible.value = true
|
||||||
|
if (!trafficPolicy.value) loadTrafficPolicy()
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitUpdateTrafficPolicy = async () => {
|
const submitUpdateTrafficPolicy = async () => {
|
||||||
@@ -2058,6 +2258,55 @@ const submitAddTraffic = async () => {
|
|||||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { trafficPolicyLoading.value = false }
|
} 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 transferVisible = ref(false)
|
||||||
const showTransferUserSelector = ref(false)
|
const showTransferUserSelector = ref(false)
|
||||||
@@ -2110,11 +2359,15 @@ const submitEditGoods = async () => {
|
|||||||
const cpuChartRef = ref(null)
|
const cpuChartRef = ref(null)
|
||||||
const memChartRef = ref(null)
|
const memChartRef = ref(null)
|
||||||
const diskChartRef = ref(null)
|
const diskChartRef = ref(null)
|
||||||
|
const diskIopsChartRef = ref(null)
|
||||||
const netChartRef = ref(null)
|
const netChartRef = ref(null)
|
||||||
|
const trafficUsedChartRef = ref(null)
|
||||||
let cpuChart = null
|
let cpuChart = null
|
||||||
let memChart = null
|
let memChart = null
|
||||||
let diskChart = null
|
let diskChart = null
|
||||||
|
let diskIopsChart = null
|
||||||
let netChart = null
|
let netChart = null
|
||||||
|
let trafficUsedChart = null
|
||||||
|
|
||||||
const metricsData = ref(null)
|
const metricsData = ref(null)
|
||||||
const metricsLoading = ref(false)
|
const metricsLoading = ref(false)
|
||||||
@@ -2241,9 +2494,28 @@ const renderMetricsCharts = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const cpuData = metrics.map(m => m.cpu_usage ?? 0)
|
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 diskReadData = metrics.map(m => m.disk_read ?? 0)
|
||||||
const diskWriteData = metrics.map(m => m.disk_write ?? 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 netRxData = metrics.map(m => m.net_rx ?? 0)
|
||||||
const netTxData = metrics.map(m => m.net_tx ?? 0)
|
const netTxData = metrics.map(m => m.net_tx ?? 0)
|
||||||
|
|
||||||
@@ -2264,9 +2536,9 @@ const renderMetricsCharts = () => {
|
|||||||
if (memChartRef.value) {
|
if (memChartRef.value) {
|
||||||
if (!memChart) memChart = echarts.init(memChartRef.value)
|
if (!memChart) memChart = echarts.init(memChartRef.value)
|
||||||
memChart.setOption({
|
memChart.setOption({
|
||||||
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} 内存: ${p[0].value.toFixed(1)}%` },
|
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} 内存: ${formatMemKB(p[0].value)}` },
|
||||||
grid: baseGrid, xAxis: makeXAxis(),
|
grid: { ...baseGrid, left: 60 }, xAxis: makeXAxis(),
|
||||||
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { fontSize: 10, formatter: v => v + '%' } },
|
yAxis: { type: 'value', min: 0, max: memTotal || undefined, axisLabel: { fontSize: 10, formatter: memAxisFmt } },
|
||||||
series: [makeSeries('内存', memData, '#67c23a')]
|
series: [makeSeries('内存', memData, '#67c23a')]
|
||||||
}, true)
|
}, true)
|
||||||
}
|
}
|
||||||
@@ -2285,6 +2557,20 @@ const renderMetricsCharts = () => {
|
|||||||
}, true)
|
}, 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 (netChartRef.value) {
|
||||||
if (!netChart) netChart = echarts.init(netChartRef.value)
|
if (!netChart) netChart = echarts.init(netChartRef.value)
|
||||||
netChart.setOption({
|
netChart.setOption({
|
||||||
@@ -2298,13 +2584,32 @@ const renderMetricsCharts = () => {
|
|||||||
series: [makeSeries('接收', netRxData, '#409eff'), makeSeries('发送', netTxData, '#e6a23c')]
|
series: [makeSeries('接收', netRxData, '#409eff'), makeSeries('发送', netTxData, '#e6a23c')]
|
||||||
}, true)
|
}, 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 = () => {
|
const disposeCharts = () => {
|
||||||
cpuChart?.dispose(); cpuChart = null
|
cpuChart?.dispose(); cpuChart = null
|
||||||
memChart?.dispose(); memChart = null
|
memChart?.dispose(); memChart = null
|
||||||
diskChart?.dispose(); diskChart = null
|
diskChart?.dispose(); diskChart = null
|
||||||
|
diskIopsChart?.dispose(); diskIopsChart = null
|
||||||
netChart?.dispose(); netChart = null
|
netChart?.dispose(); netChart = null
|
||||||
|
trafficUsedChart?.dispose(); trafficUsedChart = null
|
||||||
|
trafficHourlyChart?.dispose(); trafficHourlyChart = null
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => { loadDetail() })
|
onMounted(() => { loadDetail() })
|
||||||
@@ -2355,6 +2660,14 @@ onBeforeUnmount(() => { disposeCharts() })
|
|||||||
.config-cell:last-child { border-right: none; }
|
.config-cell:last-child { border-right: none; }
|
||||||
.config-label { font-size: 12px; color: #86909c; line-height: 1; }
|
.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; }
|
.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-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-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; }
|
.pwd-btn { padding: 0 !important; height: auto !important; min-height: auto !important; }
|
||||||
|
|||||||
@@ -131,6 +131,127 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="section-block">
|
||||||
|
<h3 class="section-title clickable" @click="showDetailDiskIo = !showDetailDiskIo">
|
||||||
|
硬盘 IO 限制
|
||||||
|
<el-icon class="section-arrow" :class="{ expanded: showDetailDiskIo }"><ArrowRight /></el-icon>
|
||||||
|
</h3>
|
||||||
|
<div v-show="showDetailDiskIo" class="config-grid">
|
||||||
|
<div class="config-row">
|
||||||
|
<div class="config-cell" v-for="f in diskIoBwFields" :key="f.key">
|
||||||
|
<span class="config-label">{{ f.label }}带宽</span>
|
||||||
|
<span class="config-value mono-text">{{ formatDiskIoVal(detail[f.key], true) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="config-row">
|
||||||
|
<div class="config-cell" v-for="f in diskIoIopsFields" :key="f.key">
|
||||||
|
<span class="config-label">{{ f.label }} IOPS</span>
|
||||||
|
<span class="config-value mono-text">{{ formatDiskIoVal(detail[f.key], false) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
|
||||||
<el-tab-pane label="监控" name="monitor">
|
<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>
|
<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>
|
</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">
|
||||||
<div class="tk-section-title">其他配置</div>
|
<div class="tk-section-title">其他配置</div>
|
||||||
<el-form-item label="宿主机组">
|
<el-form-item label="宿主机组">
|
||||||
@@ -475,6 +622,32 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
||||||
<div class="tk-section-title">令牌有效期</div>
|
<div class="tk-section-title">令牌有效期</div>
|
||||||
<el-form-item label="有效期" prop="expire_hours">
|
<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" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -536,12 +728,13 @@
|
|||||||
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, onBeforeUnmount, watch, nextTick, provide } from 'vue'
|
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, onBeforeUnmount, watch, nextTick, provide } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
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 {
|
import {
|
||||||
getRemoteHostDetail, updateRemoteHost, deleteRemoteHost,
|
getRemoteHostDetail, updateRemoteHost, deleteRemoteHost,
|
||||||
getUserNetworkingList, getUserNetworkingDetail, createUserNetworking, deleteUserNetworking,
|
getUserNetworkingList, getUserNetworkingDetail, createUserNetworking, deleteUserNetworking,
|
||||||
assignUserNetworking, removeUserNetworkingNetwork,
|
assignUserNetworking, removeUserNetworkingNetwork,
|
||||||
createHostToken, getMetricsHistory
|
createHostToken, getMetricsHistory, getHostQuotaStats,
|
||||||
|
getHostKsmStatus, configureHostKsm
|
||||||
} from '@/api/admin/kvmService'
|
} from '@/api/admin/kvmService'
|
||||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||||
import { baseUrl } from '@/config/env'
|
import { baseUrl } from '@/config/env'
|
||||||
@@ -590,6 +783,7 @@ watch(activeTab, (tab) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (tab === 'networking') loadNetworkingList()
|
if (tab === 'networking') loadNetworkingList()
|
||||||
|
if (tab === 'quotaStats' && !quotaStats.value) loadQuotaStats()
|
||||||
})
|
})
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -630,9 +824,56 @@ const editDialogVisible = ref(false)
|
|||||||
const showGroupSelector = ref(false)
|
const showGroupSelector = ref(false)
|
||||||
const formRef = ref(null)
|
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({
|
const formData = reactive({
|
||||||
name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', private_key: '',
|
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 = {
|
const formRules = {
|
||||||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
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 }
|
} 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 cpuChartRef = ref(null)
|
||||||
const memChartRef = ref(null)
|
const memChartRef = ref(null)
|
||||||
const netChartRef = 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 || '',
|
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,
|
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,
|
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
|
editDialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -947,7 +1334,8 @@ const tokenForm = reactive({
|
|||||||
name: '', host_group_id: 0, max_cpu: 4,
|
name: '', host_group_id: 0, max_cpu: 4,
|
||||||
max_memory: 4194304, max_disk: 100,
|
max_memory: 4194304, max_disk: 100,
|
||||||
rx_bandwidth: 100, tx_bandwidth: 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 tokenResultInfo = reactive({ name: '', expire_hours: 24, token: '', service_id: 0 })
|
||||||
const tokenRules = {
|
const tokenRules = {
|
||||||
@@ -979,10 +1367,13 @@ const openTokenDialog = () => {
|
|||||||
max_disk: d?.max_disk || 100,
|
max_disk: d?.max_disk || 100,
|
||||||
rx_bandwidth: d?.rx_bandwidth || 100,
|
rx_bandwidth: d?.rx_bandwidth || 100,
|
||||||
tx_bandwidth: d?.tx_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'
|
tokenMemUnit.value = 'GB'
|
||||||
tokenDiskUnit.value = 'GB'
|
tokenDiskUnit.value = 'GB'
|
||||||
|
showTokenDiskIo.value = false
|
||||||
|
tokenIoBwUnit.value = 'MB/s'
|
||||||
tokenDialogVisible.value = true
|
tokenDialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1004,6 +1395,7 @@ const handleTokenSubmit = () => {
|
|||||||
fd.append('max_disk', tokenForm.max_disk)
|
fd.append('max_disk', tokenForm.max_disk)
|
||||||
fd.append('rx_bandwidth', tokenForm.rx_bandwidth)
|
fd.append('rx_bandwidth', tokenForm.rx_bandwidth)
|
||||||
fd.append('tx_bandwidth', tokenForm.tx_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('description', tokenForm.description || '')
|
||||||
fd.append('expire_hours', tokenForm.expire_hours)
|
fd.append('expire_hours', tokenForm.expire_hours)
|
||||||
const res = await createHostToken(fd)
|
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-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; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -8,30 +8,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<el-select v-model="selectedServiceId" placeholder="选择主控服务" filterable style="width: 240px" @change="handleServiceChange">
|
<el-button @click="loadAllServices" :loading="servicesLoading">
|
||||||
<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-icon><Refresh /></el-icon>刷新
|
<el-icon><Refresh /></el-icon>刷新
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="embedded-toolbar" v-if="embedded">
|
<div class="embedded-toolbar" v-if="embedded">
|
||||||
<el-button type="primary" @click="handleSync" :loading="syncLoading"><el-icon><RefreshRight /></el-icon>从远程同步</el-button>
|
<el-button type="primary" @click="handleSync(embeddedServiceId)" :loading="syncLoadingMap[embeddedServiceId]"><el-icon><RefreshRight /></el-icon>从远程同步</el-button>
|
||||||
<el-button @click="loadHostGroups"><el-icon><Refresh /></el-icon>刷新</el-button>
|
<el-button @click="loadHostGroupsForService(embeddedServiceId)"><el-icon><Refresh /></el-icon>刷新</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 本地主机组列表(树形折叠) -->
|
<!-- embedded 模式:直接展示单个主控的主机组 -->
|
||||||
<div class="main-panel">
|
<div v-if="embedded" class="main-panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h4>本地主机组列表</h4>
|
<h4>本地主机组列表</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body" v-loading="loading">
|
<div class="panel-body" v-loading="hostGroupLoadingMap[embeddedServiceId]">
|
||||||
<el-table :data="treeGroupList" stripe style="width: 100%" row-key="_rowKey"
|
<el-table :data="getTreeGroupList(embeddedServiceId)" stripe style="width: 100%" row-key="_rowKey"
|
||||||
:tree-props="{ children: '_children', hasChildren: '_hasChildren' }">
|
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="name" label="名称" min-width="140" show-overflow-tooltip />
|
||||||
<el-table-column prop="id" label="ID" width="70" />
|
<el-table-column prop="id" label="ID" width="70" />
|
||||||
<el-table-column label="远程ID" width="80">
|
<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="handleEditGroup(row)">编辑</el-button>
|
||||||
<el-button link type="primary" size="small" @click.stop="handleBind(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="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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
</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-dialog v-model="editDialogVisible" title="编辑本地主机组" width="480px" destroy-on-close>
|
||||||
<el-form ref="editFormRef" :model="editForm" :rules="editFormRules" label-width="90px">
|
<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 { ref, reactive, computed, inject, onMounted, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
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 {
|
import {
|
||||||
getHostGroupList,
|
getHostGroupList,
|
||||||
syncHostGroup,
|
syncHostGroup,
|
||||||
@@ -237,15 +300,12 @@ const embedded = inject('embedded', false)
|
|||||||
const injectedServiceId = inject('serviceId', null)
|
const injectedServiceId = inject('serviceId', null)
|
||||||
const injectedServiceName = inject('serviceName', null)
|
const injectedServiceName = inject('serviceName', null)
|
||||||
|
|
||||||
const selectedServiceId = ref(parseInt(route.query.service_id) || null)
|
const embeddedServiceId = computed(() => injectedServiceId?.value || 0)
|
||||||
const serviceOptions = ref([])
|
|
||||||
|
|
||||||
const serviceId = computed(() => injectedServiceId?.value || selectedServiceId.value || 0)
|
// ==================== 主控服务列表 ====================
|
||||||
const serviceName = computed(() => {
|
const serviceList = ref([])
|
||||||
if (injectedServiceName?.value) return injectedServiceName.value
|
const servicesLoading = ref(false)
|
||||||
const s = serviceOptions.value.find(x => x.id === selectedServiceId.value)
|
const expandedServiceIds = reactive(new Set())
|
||||||
return s?.name || route.query.service_name || ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const normalizeService = (s) => ({
|
const normalizeService = (s) => ({
|
||||||
id: s.Id ?? s.id,
|
id: s.Id ?? s.id,
|
||||||
@@ -255,59 +315,29 @@ const normalizeService = (s) => ({
|
|||||||
note: s.Note ?? s.note
|
note: s.Note ?? s.note
|
||||||
})
|
})
|
||||||
|
|
||||||
const loadServiceOptions = async () => {
|
const loadAllServices = async () => {
|
||||||
|
servicesLoading.value = true
|
||||||
try {
|
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) {
|
if (res?.data?.code === 200 && res?.data?.data) {
|
||||||
const inner = res.data.data
|
const inner = res.data.data
|
||||||
const raw = inner.data || inner.list || (Array.isArray(inner) ? inner : [])
|
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 { /* */ }
|
} catch { /* */ } finally {
|
||||||
}
|
servicesLoading.value = false
|
||||||
|
|
||||||
const handleServiceChange = () => {
|
|
||||||
hostGroupList.value = []
|
|
||||||
if (serviceId.value) {
|
|
||||||
loadHostGroups()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRefresh = () => {
|
// ==================== 每个主控下的主机组数据 ====================
|
||||||
if (!serviceId.value) {
|
const hostGroupDataMap = reactive({})
|
||||||
ElMessage.warning('请先选择主控服务')
|
const hostGroupLoadingMap = reactive({})
|
||||||
return
|
const syncLoadingMap = reactive({})
|
||||||
}
|
|
||||||
loadHostGroups()
|
|
||||||
}
|
|
||||||
|
|
||||||
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) => {
|
const normalizeHostGroup = (item) => {
|
||||||
if (!item) return item
|
if (!item) return item
|
||||||
return {
|
return {
|
||||||
@@ -325,36 +355,65 @@ const normalizeHostGroup = (item) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 本地主机组列表 ==========
|
const loadHostGroupsForService = async (svcId) => {
|
||||||
const loadHostGroups = async () => {
|
if (!svcId) return
|
||||||
if (!serviceId.value) return
|
hostGroupLoadingMap[svcId] = true
|
||||||
loading.value = true
|
|
||||||
try {
|
try {
|
||||||
const res = await getHostGroupList({ service_id: serviceId.value })
|
const res = await getHostGroupList({ service_id: svcId })
|
||||||
const body = res?.data
|
const body = res?.data
|
||||||
console.debug('[HostGroup] list response body:', JSON.stringify(body))
|
|
||||||
if (body?.code === 200 && body?.data) {
|
if (body?.code === 200 && body?.data) {
|
||||||
// data 可能是直接数组,或 { all_count, data: [...] } 格式
|
|
||||||
const items = Array.isArray(body.data) ? body.data : (body.data.data || body.data.list || [])
|
const items = Array.isArray(body.data) ? body.data : (body.data.data || body.data.list || [])
|
||||||
hostGroupList.value = items.map(normalizeHostGroup)
|
hostGroupDataMap[svcId] = items.map(normalizeHostGroup)
|
||||||
console.debug('[HostGroup] normalized list:', hostGroupList.value)
|
|
||||||
} else {
|
} else {
|
||||||
hostGroupList.value = []
|
hostGroupDataMap[svcId] = []
|
||||||
if (body?.message) {
|
if (body?.message) ElMessage.warning(body.message)
|
||||||
ElMessage.warning(body.message)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取本地主机组列表失败:', error)
|
console.error('获取本地主机组列表失败:', error)
|
||||||
ElMessage.error('获取本地主机组列表失败')
|
ElMessage.error('获取本地主机组列表失败')
|
||||||
|
hostGroupDataMap[svcId] = []
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
hostGroupLoadingMap[svcId] = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 同步 ==========
|
const buildTree = (items) => {
|
||||||
const handleSync = async () => {
|
if (!items || !items.length) return []
|
||||||
if (!serviceId.value) {
|
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')
|
ElMessage.warning('缺少主控服务ID')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -363,9 +422,9 @@ const handleSync = async () => {
|
|||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
type: 'info'
|
type: 'info'
|
||||||
}).then(async () => {
|
}).then(async () => {
|
||||||
syncLoading.value = true
|
syncLoadingMap[svcId] = true
|
||||||
try {
|
try {
|
||||||
const res = await syncHostGroup({ service_id: serviceId.value })
|
const res = await syncHostGroup({ service_id: svcId })
|
||||||
const body = res?.data
|
const body = res?.data
|
||||||
if (body?.code === 200) {
|
if (body?.code === 200) {
|
||||||
const synced = body.data
|
const synced = body.data
|
||||||
@@ -374,19 +433,20 @@ const handleSync = async () => {
|
|||||||
} else {
|
} else {
|
||||||
ElMessage.warning(body?.message || '同步返回异常')
|
ElMessage.warning(body?.message || '同步返回异常')
|
||||||
}
|
}
|
||||||
loadHostGroups()
|
loadHostGroupsForService(svcId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(extractApiError(error?.response?.data, '同步失败'))
|
ElMessage.error(extractApiError(error?.response?.data, '同步失败'))
|
||||||
} finally {
|
} finally {
|
||||||
syncLoading.value = false
|
syncLoadingMap[svcId] = false
|
||||||
}
|
}
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 编辑 ==========
|
// ==================== 编辑 ====================
|
||||||
const editDialogVisible = ref(false)
|
const editDialogVisible = ref(false)
|
||||||
const editSubmitLoading = ref(false)
|
const editSubmitLoading = ref(false)
|
||||||
const editFormRef = ref(null)
|
const editFormRef = ref(null)
|
||||||
|
const editingServiceId = ref(null)
|
||||||
|
|
||||||
const editForm = reactive({
|
const editForm = reactive({
|
||||||
id: undefined,
|
id: undefined,
|
||||||
@@ -399,6 +459,7 @@ const editFormRules = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleEditGroup = (row) => {
|
const handleEditGroup = (row) => {
|
||||||
|
editingServiceId.value = row.serviceId
|
||||||
Object.assign(editForm, {
|
Object.assign(editForm, {
|
||||||
id: Number(row.Id ?? row.id),
|
id: Number(row.Id ?? row.id),
|
||||||
name: row.Name ?? row.name,
|
name: row.Name ?? row.name,
|
||||||
@@ -421,7 +482,7 @@ const submitEdit = () => {
|
|||||||
if (body?.code === 200) {
|
if (body?.code === 200) {
|
||||||
ElMessage.success('修改成功')
|
ElMessage.success('修改成功')
|
||||||
editDialogVisible.value = false
|
editDialogVisible.value = false
|
||||||
loadHostGroups()
|
if (editingServiceId.value) loadHostGroupsForService(editingServiceId.value)
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(extractApiError(body, '修改失败'))
|
ElMessage.error(extractApiError(body, '修改失败'))
|
||||||
}
|
}
|
||||||
@@ -433,10 +494,11 @@ const submitEdit = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 绑定 ==========
|
// ==================== 绑定 ====================
|
||||||
const bindDialogVisible = ref(false)
|
const bindDialogVisible = ref(false)
|
||||||
const bindSubmitLoading = ref(false)
|
const bindSubmitLoading = ref(false)
|
||||||
const bindFormRef = ref(null)
|
const bindFormRef = ref(null)
|
||||||
|
const bindingServiceId = ref(null)
|
||||||
|
|
||||||
const bindForm = reactive({
|
const bindForm = reactive({
|
||||||
id: undefined,
|
id: undefined,
|
||||||
@@ -447,23 +509,19 @@ const bindForm = reactive({
|
|||||||
good_id: 0
|
good_id: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
// 选择器弹窗控制
|
|
||||||
const showGroupSelector = ref(false)
|
const showGroupSelector = ref(false)
|
||||||
const showProductSelector = ref(false)
|
const showProductSelector = ref(false)
|
||||||
|
|
||||||
// 商品组选中回调
|
|
||||||
const handleGroupSelected = (group) => {
|
const handleGroupSelected = (group) => {
|
||||||
bindForm.good_group_id = group.id
|
bindForm.good_group_id = group.id
|
||||||
bindForm._groupName = group.name || ''
|
bindForm._groupName = group.name || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// 商品选中回调
|
|
||||||
const handleProductSelected = (product) => {
|
const handleProductSelected = (product) => {
|
||||||
bindForm.good_id = product.id
|
bindForm.good_id = product.id
|
||||||
bindForm._goodName = product.name || ''
|
bindForm._goodName = product.name || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清除绑定
|
|
||||||
const clearBindGroup = () => {
|
const clearBindGroup = () => {
|
||||||
bindForm.good_group_id = 0
|
bindForm.good_group_id = 0
|
||||||
bindForm._groupName = ''
|
bindForm._groupName = ''
|
||||||
@@ -475,6 +533,7 @@ const clearBindProduct = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleBind = (row) => {
|
const handleBind = (row) => {
|
||||||
|
bindingServiceId.value = row.serviceId
|
||||||
Object.assign(bindForm, {
|
Object.assign(bindForm, {
|
||||||
id: Number(row.Id ?? row.id),
|
id: Number(row.Id ?? row.id),
|
||||||
_name: row.Name ?? row.name,
|
_name: row.Name ?? row.name,
|
||||||
@@ -498,7 +557,7 @@ const submitBind = async () => {
|
|||||||
if (body?.code === 200) {
|
if (body?.code === 200) {
|
||||||
ElMessage.success('绑定成功')
|
ElMessage.success('绑定成功')
|
||||||
bindDialogVisible.value = false
|
bindDialogVisible.value = false
|
||||||
loadHostGroups()
|
if (bindingServiceId.value) loadHostGroupsForService(bindingServiceId.value)
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(extractApiError(body, '绑定失败'))
|
ElMessage.error(extractApiError(body, '绑定失败'))
|
||||||
}
|
}
|
||||||
@@ -509,10 +568,11 @@ const submitBind = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 生成商品 ==========
|
// ==================== 生成商品 ====================
|
||||||
const generateDialogVisible = ref(false)
|
const generateDialogVisible = ref(false)
|
||||||
const generateSubmitLoading = ref(false)
|
const generateSubmitLoading = ref(false)
|
||||||
const generateFormRef = ref(null)
|
const generateFormRef = ref(null)
|
||||||
|
const generatingServiceId = ref(null)
|
||||||
|
|
||||||
const generateForm = reactive({
|
const generateForm = reactive({
|
||||||
id: undefined,
|
id: undefined,
|
||||||
@@ -527,10 +587,8 @@ const generateFormRules = {
|
|||||||
id: [{ required: true, message: '主机组ID不能为空', trigger: 'blur' }]
|
id: [{ required: true, message: '主机组ID不能为空', trigger: 'blur' }]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 父级商品组选择器
|
|
||||||
const showGenerateGroupSelector = ref(false)
|
const showGenerateGroupSelector = ref(false)
|
||||||
|
|
||||||
// 标签选择器
|
|
||||||
const showGenerateTagSelector = ref(false)
|
const showGenerateTagSelector = ref(false)
|
||||||
const tagOptions = ref([])
|
const tagOptions = ref([])
|
||||||
const tagLoading = ref(false)
|
const tagLoading = ref(false)
|
||||||
@@ -567,10 +625,10 @@ const fetchTagOptions = async () => {
|
|||||||
} catch { /* */ } finally { tagLoading.value = false }
|
} catch { /* */ } finally { tagLoading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听标签选择器打开时加载数据
|
|
||||||
watch(showGenerateTagSelector, (val) => { if (val) loadTagOptions() })
|
watch(showGenerateTagSelector, (val) => { if (val) loadTagOptions() })
|
||||||
|
|
||||||
const handleGenerateGoods = (row) => {
|
const handleGenerateGoods = (row) => {
|
||||||
|
generatingServiceId.value = row.serviceId
|
||||||
Object.assign(generateForm, {
|
Object.assign(generateForm, {
|
||||||
id: Number(row.Id ?? row.id),
|
id: Number(row.Id ?? row.id),
|
||||||
parent_group_id: 0,
|
parent_group_id: 0,
|
||||||
@@ -603,7 +661,7 @@ const submitGenerate = () => {
|
|||||||
if (body?.code === 200) {
|
if (body?.code === 200) {
|
||||||
ElMessage.success('商品生成成功')
|
ElMessage.success('商品生成成功')
|
||||||
generateDialogVisible.value = false
|
generateDialogVisible.value = false
|
||||||
loadHostGroups()
|
if (generatingServiceId.value) loadHostGroupsForService(generatingServiceId.value)
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(extractApiError(body, '商品生成失败'))
|
ElMessage.error(extractApiError(body, '商品生成失败'))
|
||||||
}
|
}
|
||||||
@@ -616,8 +674,8 @@ const submitGenerate = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 删除本地主机组 ==========
|
// ==================== 删除本地主机组 ====================
|
||||||
const handleDeleteGroup = (row) => {
|
const handleDeleteGroup = (row, svcId) => {
|
||||||
const rawId = Number(row.Id ?? row.id)
|
const rawId = Number(row.Id ?? row.id)
|
||||||
if (!rawId) {
|
if (!rawId) {
|
||||||
ElMessage.error('无法获取主机组ID')
|
ElMessage.error('无法获取主机组ID')
|
||||||
@@ -633,7 +691,7 @@ const handleDeleteGroup = (row) => {
|
|||||||
const body = res?.data
|
const body = res?.data
|
||||||
if (body?.code === 200) {
|
if (body?.code === 200) {
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success('删除成功')
|
||||||
loadHostGroups()
|
if (svcId) loadHostGroupsForService(svcId)
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(extractApiError(body, '删除失败'))
|
ElMessage.error(extractApiError(body, '删除失败'))
|
||||||
}
|
}
|
||||||
@@ -643,15 +701,16 @@ const handleDeleteGroup = (row) => {
|
|||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 返回 ==========
|
// ==================== 返回 ====================
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
router.push('/virtualization/kvm-service')
|
router.push('/virtualization/kvm-service')
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!embedded) loadServiceOptions()
|
if (!embedded) {
|
||||||
if (serviceId.value) {
|
loadAllServices()
|
||||||
loadHostGroups()
|
} else if (embeddedServiceId.value) {
|
||||||
|
loadHostGroupsForService(embeddedServiceId.value)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -723,8 +782,76 @@ onMounted(() => {
|
|||||||
|
|
||||||
.panel-body {
|
.panel-body {
|
||||||
padding: 16px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -159,6 +159,32 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
||||||
<div class="tk-section-title">其他配置</div>
|
<div class="tk-section-title">其他配置</div>
|
||||||
<el-form-item label="宿主机组">
|
<el-form-item label="宿主机组">
|
||||||
@@ -271,6 +297,32 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
||||||
<div class="tk-section-title">令牌有效期</div>
|
<div class="tk-section-title">令牌有效期</div>
|
||||||
<el-form-item label="有效期" prop="expire_hours">
|
<el-form-item label="有效期" prop="expire_hours">
|
||||||
@@ -378,7 +430,7 @@
|
|||||||
import { ref, reactive, computed, inject, onMounted } from 'vue'
|
import { ref, reactive, computed, inject, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
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 {
|
import {
|
||||||
getRemoteHostList, getRemoteHostDetail,
|
getRemoteHostList, getRemoteHostDetail,
|
||||||
addRemoteHost, updateRemoteHost, deleteRemoteHost,
|
addRemoteHost, updateRemoteHost, deleteRemoteHost,
|
||||||
@@ -418,12 +470,49 @@ const currentDetail = ref(null)
|
|||||||
const metricsVisible = ref(false)
|
const metricsVisible = ref(false)
|
||||||
const metricsData = ref(null)
|
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({
|
const formData = reactive({
|
||||||
id: undefined, name: '', base_url: '', ip: '', token: '',
|
id: undefined, name: '', base_url: '', ip: '', token: '',
|
||||||
port: 22, user: '', password: '', private_key: '',
|
port: 22, user: '', password: '', private_key: '',
|
||||||
max_cpu: 0, max_memory: 0, max_disk: 0,
|
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: '',
|
||||||
_groupName: ''
|
_groupName: '',
|
||||||
|
...diskIoDefaults
|
||||||
})
|
})
|
||||||
|
|
||||||
const formRules = {
|
const formRules = {
|
||||||
@@ -522,8 +611,10 @@ const resetForm = () => {
|
|||||||
port: 22, user: '', password: '', private_key: '',
|
port: 22, user: '', password: '', private_key: '',
|
||||||
max_cpu: 0, max_memory: 0, max_disk: 0,
|
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: '',
|
||||||
_groupName: ''
|
_groupName: '', ...diskIoDefaults
|
||||||
})
|
})
|
||||||
|
showDiskIoSection.value = false
|
||||||
|
ioBwUnit.value = 'MB/s'
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleHostGroupSelected = (group) => {
|
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,
|
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,
|
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 || '',
|
||||||
_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 => {
|
getRemoteHostDetail({ service_id: serviceId.value, id: row.id }).then(res => {
|
||||||
const body = res?.data
|
const body = res?.data
|
||||||
if (body?.code === 200 && body?.data) {
|
if (body?.code === 200 && body?.data) {
|
||||||
@@ -595,6 +687,7 @@ const handleEdit = (row) => {
|
|||||||
if (detail.password) formData.password = detail.password
|
if (detail.password) formData.password = detail.password
|
||||||
if (detail.token) formData.token = detail.token
|
if (detail.token) formData.token = detail.token
|
||||||
if (detail.private_key) formData.private_key = detail.private_key
|
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(() => {})
|
}).catch(() => {})
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
@@ -719,7 +812,8 @@ const tokenForm = reactive({
|
|||||||
max_memory: 4194304, max_disk: 100,
|
max_memory: 4194304, max_disk: 100,
|
||||||
rx_bandwidth: 100, tx_bandwidth: 100,
|
rx_bandwidth: 100, tx_bandwidth: 100,
|
||||||
description: '', expire_hours: 24,
|
description: '', expire_hours: 24,
|
||||||
_groupName: ''
|
_groupName: '',
|
||||||
|
...diskIoDefaults
|
||||||
})
|
})
|
||||||
|
|
||||||
const tokenResultInfo = reactive({ name: '', expire_hours: 24, token: '', service_id: 0 })
|
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,
|
name: '', host_group_id: 0, max_cpu: 4,
|
||||||
max_memory: 4194304, max_disk: 100,
|
max_memory: 4194304, max_disk: 100,
|
||||||
rx_bandwidth: 100, tx_bandwidth: 100,
|
rx_bandwidth: 100, tx_bandwidth: 100,
|
||||||
description: '', expire_hours: 24, _groupName: ''
|
description: '', expire_hours: 24, _groupName: '',
|
||||||
|
...diskIoDefaults
|
||||||
})
|
})
|
||||||
tokenMemUnit.value = 'GB'
|
tokenMemUnit.value = 'GB'
|
||||||
tokenDiskUnit.value = 'GB'
|
tokenDiskUnit.value = 'GB'
|
||||||
|
showTokenDiskIo.value = false
|
||||||
|
tokenIoBwUnit.value = 'MB/s'
|
||||||
tokenDialogVisible.value = true
|
tokenDialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -776,6 +873,7 @@ const handleTokenSubmit = () => {
|
|||||||
fd.append('max_disk', tokenForm.max_disk)
|
fd.append('max_disk', tokenForm.max_disk)
|
||||||
fd.append('rx_bandwidth', tokenForm.rx_bandwidth)
|
fd.append('rx_bandwidth', tokenForm.rx_bandwidth)
|
||||||
fd.append('tx_bandwidth', tokenForm.tx_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('description', tokenForm.description || '')
|
||||||
fd.append('expire_hours', tokenForm.expire_hours)
|
fd.append('expire_hours', tokenForm.expire_hours)
|
||||||
|
|
||||||
@@ -832,4 +930,10 @@ onMounted(() => {
|
|||||||
.metrics-card { margin-bottom: 12px; }
|
.metrics-card { margin-bottom: 12px; }
|
||||||
.metrics-title { font-weight: 600; font-size: 14px; display: inline-flex; align-items: center; gap: 6px; }
|
.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; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -131,6 +131,32 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
||||||
<div class="tk-section-title">令牌有效期</div>
|
<div class="tk-section-title">令牌有效期</div>
|
||||||
<el-form-item label="有效期" prop="expire_hours">
|
<el-form-item label="有效期" prop="expire_hours">
|
||||||
@@ -326,6 +352,32 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
||||||
<div class="tk-section-title">其他配置</div>
|
<div class="tk-section-title">其他配置</div>
|
||||||
<el-form-item label="宿主机组">
|
<el-form-item label="宿主机组">
|
||||||
@@ -625,6 +677,42 @@ const handleOptimalHost = async (row) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- 宿主机 CRUD ----
|
// ---- 宿主机 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 hostDialogVisible = ref(false)
|
||||||
const hostDialogType = ref('add')
|
const hostDialogType = ref('add')
|
||||||
const hostFormRef = ref(null)
|
const hostFormRef = ref(null)
|
||||||
@@ -632,7 +720,8 @@ const hostForm = reactive({
|
|||||||
id: undefined, name: '', base_url: '', ip: '', token: '',
|
id: undefined, name: '', base_url: '', ip: '', token: '',
|
||||||
port: 22, user: '', password: '', private_key: '',
|
port: 22, user: '', password: '', private_key: '',
|
||||||
max_cpu: 0, max_memory: 0, max_disk: 0,
|
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 = {
|
const hostFormRules = {
|
||||||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||||
@@ -642,13 +731,17 @@ const hostFormRules = {
|
|||||||
|
|
||||||
const handleAddHost = () => {
|
const handleAddHost = () => {
|
||||||
hostDialogType.value = 'add'
|
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
|
hostDialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddHostToGroup = (group) => {
|
const handleAddHostToGroup = (group) => {
|
||||||
hostDialogType.value = 'add'
|
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
|
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 || '',
|
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,
|
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,
|
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 => {
|
getRemoteHostDetail({ service_id: serviceId.value, id: row.id }).then(res => {
|
||||||
if (res?.data?.code === 200 && res?.data?.data) {
|
if (res?.data?.code === 200 && res?.data?.data) {
|
||||||
const d = res.data.data.host ?? res.data.data.data ?? 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.password) hostForm.password = d.password
|
||||||
if (d.token) hostForm.token = d.token
|
if (d.token) hostForm.token = d.token
|
||||||
if (d.private_key) hostForm.private_key = d.private_key
|
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(() => {})
|
}).catch(() => {})
|
||||||
hostDialogVisible.value = true
|
hostDialogVisible.value = true
|
||||||
@@ -723,7 +819,8 @@ const tokenForm = reactive({
|
|||||||
name: '', host_group_id: 0, max_cpu: 4,
|
name: '', host_group_id: 0, max_cpu: 4,
|
||||||
max_memory: 4194304, max_disk: 100,
|
max_memory: 4194304, max_disk: 100,
|
||||||
rx_bandwidth: 100, tx_bandwidth: 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 tokenResultInfo = reactive({ name: '', expire_hours: 24, token: '', service_id: 0 })
|
||||||
const tokenRules = {
|
const tokenRules = {
|
||||||
@@ -751,10 +848,13 @@ const openTokenDialog = () => {
|
|||||||
name: '', host_group_id: 0, max_cpu: 4,
|
name: '', host_group_id: 0, max_cpu: 4,
|
||||||
max_memory: 4194304, max_disk: 100,
|
max_memory: 4194304, max_disk: 100,
|
||||||
rx_bandwidth: 100, tx_bandwidth: 100,
|
rx_bandwidth: 100, tx_bandwidth: 100,
|
||||||
description: '', expire_hours: 24
|
description: '', expire_hours: 24,
|
||||||
|
...diskIoDefaults
|
||||||
})
|
})
|
||||||
tokenMemUnit.value = 'GB'
|
tokenMemUnit.value = 'GB'
|
||||||
tokenDiskUnit.value = 'GB'
|
tokenDiskUnit.value = 'GB'
|
||||||
|
showTokenDiskIo.value = false
|
||||||
|
tokenIoBwUnit.value = 'MB/s'
|
||||||
tokenDialogVisible.value = true
|
tokenDialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -772,6 +872,7 @@ const handleTokenSubmit = () => {
|
|||||||
fd.append('max_disk', tokenForm.max_disk)
|
fd.append('max_disk', tokenForm.max_disk)
|
||||||
fd.append('rx_bandwidth', tokenForm.rx_bandwidth)
|
fd.append('rx_bandwidth', tokenForm.rx_bandwidth)
|
||||||
fd.append('tx_bandwidth', tokenForm.tx_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('description', tokenForm.description || '')
|
||||||
fd.append('expire_hours', tokenForm.expire_hours)
|
fd.append('expire_hours', tokenForm.expire_hours)
|
||||||
const res = await createHostToken(fd)
|
const res = await createHostToken(fd)
|
||||||
@@ -839,4 +940,10 @@ onMounted(() => { if (serviceId.value) loadTreeData() })
|
|||||||
|
|
||||||
.host-addr { color: #409eff; font-size: 13px; }
|
.host-addr { color: #409eff; font-size: 13px; }
|
||||||
.host-url { color: #909399; font-size: 12px; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -30,6 +30,9 @@
|
|||||||
<el-dropdown-item divided command="editVm" :disabled="isMigrating">修改虚拟机</el-dropdown-item>
|
<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="refactorVm" :disabled="isMigrating">重构虚拟机</el-dropdown-item>
|
||||||
<el-dropdown-item command="updateTraffic" :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 divided command="rebuild" :disabled="isMigrating">重装虚拟机</el-dropdown-item>
|
||||||
<el-dropdown-item command="rescue">救援模式</el-dropdown-item>
|
<el-dropdown-item command="rescue">救援模式</el-dropdown-item>
|
||||||
<el-dropdown-item command="exitRescue">退出救援</el-dropdown-item>
|
<el-dropdown-item command="exitRescue">退出救援</el-dropdown-item>
|
||||||
@@ -50,6 +53,7 @@
|
|||||||
<span class="status-value">
|
<span class="status-value">
|
||||||
<span class="status-dot" :class="detail.status === 'running' ? 'dot-running' : 'dot-other'"></span>
|
<span class="status-dot" :class="detail.status === 'running' ? 'dot-running' : 'dot-other'"></span>
|
||||||
{{ vmStatusLabel(detail.status) }}
|
{{ 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>
|
<el-tag v-if="isMigrating" type="warning" size="small" effect="dark" style="margin-left:8px">迁移中</el-tag>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -221,8 +225,23 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="config-row">
|
<div class="config-row">
|
||||||
<div class="config-cell">
|
<div class="config-cell">
|
||||||
<span class="config-label">流量上限</span>
|
<span class="config-label">流量</span>
|
||||||
<span class="config-value">{{ formatTrafficMax(detail.traffic_max) }}</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>
|
||||||
<div class="config-cell">
|
<div class="config-cell">
|
||||||
<span class="config-label">快照配额</span>
|
<span class="config-label">快照配额</span>
|
||||||
@@ -265,6 +284,11 @@
|
|||||||
<el-table-column prop="gateway" label="网关" min-width="120" />
|
<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="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 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">
|
<el-table-column label="类型" width="80">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="row.type === 'bridge' ? 'success' : 'warning'" size="small">{{ row.type === 'bridge' ? '网桥' : 'NAT' }}</el-tag>
|
<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>
|
||||||
<el-table-column prop="bridge_name" label="网桥" width="100" />
|
<el-table-column prop="bridge_name" label="网桥" width="100" />
|
||||||
<el-table-column prop="target_device" label="目标设备" width="100" show-overflow-tooltip />
|
<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 }">
|
<template #default="{ row }">
|
||||||
<el-button link type="primary" size="small" @click="handleNetDetail(row)">详情</el-button>
|
<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 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>
|
<el-button link type="danger" size="small" @click="handleNetDelete(row)">删除</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -606,6 +631,11 @@
|
|||||||
<div class="metric-summary-value">↓{{ formatNetLabel(latestMetrics.net_rx) }}</div>
|
<div class="metric-summary-value">↓{{ formatNetLabel(latestMetrics.net_rx) }}</div>
|
||||||
<div class="metric-summary-sub">↑{{ formatNetLabel(latestMetrics.net_tx) }}</div>
|
<div class="metric-summary-sub">↑{{ formatNetLabel(latestMetrics.net_tx) }}</div>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="historicalMetricsData">
|
<template v-if="historicalMetricsData">
|
||||||
@@ -618,7 +648,7 @@
|
|||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-card shadow="hover" class="metrics-card">
|
<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>
|
<div ref="memChartRef" class="chart-container"></div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
@@ -637,13 +667,27 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</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>
|
</template>
|
||||||
<el-empty v-else-if="!historicalMetricsLoading" description="加载监控数据中..." :image-size="80" />
|
<el-empty v-else-if="!historicalMetricsLoading" description="加载监控数据中..." :image-size="80" />
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<!-- 流量策略 -->
|
<!-- 流量管理(合并流量策略 + 流量统计) -->
|
||||||
<el-tab-pane label="流量策略" name="vmTrafficPolicy">
|
<el-tab-pane label="流量管理" name="trafficManage">
|
||||||
<div class="section-block">
|
<div class="section-block">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h3 class="section-title">流量策略</h3>
|
<h3 class="section-title">流量策略</h3>
|
||||||
@@ -661,6 +705,30 @@
|
|||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
<el-empty v-else-if="!vmTrafficPolicyLoading" description="暂无流量策略数据" :image-size="60" />
|
<el-empty v-else-if="!vmTrafficPolicyLoading" description="暂无流量策略数据" :image-size="60" />
|
||||||
</div>
|
</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-tab-pane>
|
||||||
|
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
@@ -1564,7 +1632,7 @@
|
|||||||
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, onBeforeUnmount, nextTick, watch } from 'vue'
|
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, onBeforeUnmount, nextTick, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
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 {
|
import {
|
||||||
getVmDetail, getVmStatus,
|
getVmDetail, getVmStatus,
|
||||||
startVm, stopVm, rebootVm, suspendVm, resumeVm,
|
startVm, stopVm, rebootVm, suspendVm, resumeVm,
|
||||||
@@ -1583,7 +1651,9 @@ import {
|
|||||||
migrateVm, getRemoteHostGroupList, getRemoteHostDetail,
|
migrateVm, getRemoteHostGroupList, getRemoteHostDetail,
|
||||||
dataMigrateVm, getDataMigrateProgress, abortDataMigrate,
|
dataMigrateVm, getDataMigrateProgress, abortDataMigrate,
|
||||||
getKvmServiceList, getMetricsHistory, getNetworkList,
|
getKvmServiceList, getMetricsHistory, getNetworkList,
|
||||||
getVmTrafficPolicy, updateVmTrafficPolicy, addVmFixedTraffic, addVmTemporaryTraffic
|
getVmTrafficPolicy, updateVmTrafficPolicy, addVmFixedTraffic, addVmTemporaryTraffic,
|
||||||
|
setNetworkPrimary, resetVmMac,
|
||||||
|
disconnectVmNetwork, connectVmNetwork, getVmTrafficHourly
|
||||||
} from '@/api/admin/kvmService'
|
} from '@/api/admin/kvmService'
|
||||||
import { getUserInfo } from '@/api/admin/user'
|
import { getUserInfo } from '@/api/admin/user'
|
||||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||||
@@ -1644,6 +1714,33 @@ const formatTrafficMax = (val) => {
|
|||||||
return `${gb.toFixed(2)} GB`
|
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) => {
|
const copyAllIps = (ipList) => {
|
||||||
if (!ipList?.length) return
|
if (!ipList?.length) return
|
||||||
const text = ipList.join('\n')
|
const text = ipList.join('\n')
|
||||||
@@ -1694,7 +1791,8 @@ const handleMoreCommand = (cmd) => {
|
|||||||
const actionMap = {
|
const actionMap = {
|
||||||
editVm: handleEditVm, refactorVm: handleRefactorVm, updateTraffic: handleUpdateTraffic,
|
editVm: handleEditVm, refactorVm: handleRefactorVm, updateTraffic: handleUpdateTraffic,
|
||||||
rebuild: handleRebuild, rescue: handleRescue, exitRescue: handleExitRescue,
|
rebuild: handleRebuild, rescue: handleRescue, exitRescue: handleExitRescue,
|
||||||
migrateVm: handleMigrateVm
|
migrateVm: handleMigrateVm, resetMac: handleResetMac,
|
||||||
|
disconnectNetwork: handleDisconnectNetwork, connectNetwork: handleConnectNetwork
|
||||||
}
|
}
|
||||||
if (actionMap[cmd]) actionMap[cmd]()
|
if (actionMap[cmd]) actionMap[cmd]()
|
||||||
if (cmd === 'dataMigrateVm') handleDataMigrateVm()
|
if (cmd === 'dataMigrateVm') handleDataMigrateVm()
|
||||||
@@ -1800,10 +1898,14 @@ const cpuChartRef = ref(null)
|
|||||||
const memChartRef = ref(null)
|
const memChartRef = ref(null)
|
||||||
const netChartRef = ref(null)
|
const netChartRef = ref(null)
|
||||||
const diskChartRef = ref(null)
|
const diskChartRef = ref(null)
|
||||||
|
const diskIopsChartRef = ref(null)
|
||||||
|
const trafficUsedChartRef = ref(null)
|
||||||
let cpuChart = null
|
let cpuChart = null
|
||||||
let memChart = null
|
let memChart = null
|
||||||
let netChart = null
|
let netChart = null
|
||||||
let diskChart = null
|
let diskChart = null
|
||||||
|
let diskIopsChart = null
|
||||||
|
let trafficUsedChart = null
|
||||||
let isPageActive = false
|
let isPageActive = false
|
||||||
|
|
||||||
const historicalMetricsData = ref(null)
|
const historicalMetricsData = ref(null)
|
||||||
@@ -1931,9 +2033,28 @@ const renderHistoricalCharts = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const cpuData = metrics.map(m => m.cpu_usage ?? 0)
|
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 diskReadData = metrics.map(m => m.disk_read ?? 0)
|
||||||
const diskWriteData = metrics.map(m => m.disk_write ?? 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 netRxData = metrics.map(m => m.net_rx ?? 0)
|
||||||
const netTxData = metrics.map(m => m.net_tx ?? 0)
|
const netTxData = metrics.map(m => m.net_tx ?? 0)
|
||||||
|
|
||||||
@@ -1954,9 +2075,9 @@ const renderHistoricalCharts = () => {
|
|||||||
if (memChartRef.value) {
|
if (memChartRef.value) {
|
||||||
if (!memChart) memChart = echarts.init(memChartRef.value)
|
if (!memChart) memChart = echarts.init(memChartRef.value)
|
||||||
memChart.setOption({
|
memChart.setOption({
|
||||||
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} 内存: ${p[0].value.toFixed(1)}%` },
|
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} 内存: ${formatMemKB(p[0].value)}` },
|
||||||
grid: baseGrid, xAxis: makeXAxis(),
|
grid: { ...baseGrid, left: 60 }, xAxis: makeXAxis(),
|
||||||
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { fontSize: 10, formatter: v => v + '%' } },
|
yAxis: { type: 'value', min: 0, max: memTotal || undefined, axisLabel: { fontSize: 10, formatter: memAxisFmt } },
|
||||||
series: [makeSeries('内存', memData, '#67c23a')]
|
series: [makeSeries('内存', memData, '#67c23a')]
|
||||||
}, true)
|
}, true)
|
||||||
}
|
}
|
||||||
@@ -1975,6 +2096,20 @@ const renderHistoricalCharts = () => {
|
|||||||
}, true)
|
}, 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 (netChartRef.value) {
|
||||||
if (!netChart) netChart = echarts.init(netChartRef.value)
|
if (!netChart) netChart = echarts.init(netChartRef.value)
|
||||||
netChart.setOption({
|
netChart.setOption({
|
||||||
@@ -1988,6 +2123,22 @@ const renderHistoricalCharts = () => {
|
|||||||
series: [makeSeries('接收', netRxData, '#409eff'), makeSeries('发送', netTxData, '#e6a23c')]
|
series: [makeSeries('接收', netRxData, '#409eff'), makeSeries('发送', netTxData, '#e6a23c')]
|
||||||
}, true)
|
}, 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 = () => {
|
const disposeCharts = () => {
|
||||||
@@ -1995,6 +2146,9 @@ const disposeCharts = () => {
|
|||||||
memChart?.dispose(); memChart = null
|
memChart?.dispose(); memChart = null
|
||||||
netChart?.dispose(); netChart = null
|
netChart?.dispose(); netChart = null
|
||||||
diskChart?.dispose(); diskChart = null
|
diskChart?.dispose(); diskChart = null
|
||||||
|
diskIopsChart?.dispose(); diskIopsChart = null
|
||||||
|
trafficUsedChart?.dispose(); trafficUsedChart = null
|
||||||
|
trafficHourlyChart?.dispose(); trafficHourlyChart = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const powerDialogVisible = ref(false)
|
const powerDialogVisible = ref(false)
|
||||||
@@ -2287,12 +2441,15 @@ const loadVmTrafficPolicy = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openVmTrafficPolicyDialog = () => {
|
const openVmTrafficPolicyDialog = () => {
|
||||||
|
// 从概览触发时 vmTrafficPolicy 可能尚未加载(流量策略 tab 懒加载),
|
||||||
|
// 用 detail.value 上 add.json 新字段作 fallback;同时异步加载策略
|
||||||
Object.assign(vmTrafficPolicyForm, {
|
Object.assign(vmTrafficPolicyForm, {
|
||||||
traffic_max_mb: vmTrafficPolicy.value?.traffic_max_mb || 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 || 0,
|
exhausted_rx_mbps: vmTrafficPolicy.value?.exhausted_rx_mbps ?? detail.value?.traffic_exhausted_rx_mbps ?? 0,
|
||||||
exhausted_tx_mbps: vmTrafficPolicy.value?.exhausted_tx_mbps || 0
|
exhausted_tx_mbps: vmTrafficPolicy.value?.exhausted_tx_mbps ?? detail.value?.traffic_exhausted_tx_mbps ?? 0
|
||||||
})
|
})
|
||||||
vmTrafficPolicyVisible.value = true
|
vmTrafficPolicyVisible.value = true
|
||||||
|
if (!vmTrafficPolicy.value) loadVmTrafficPolicy()
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitUpdateVmTrafficPolicy = async () => {
|
const submitUpdateVmTrafficPolicy = async () => {
|
||||||
@@ -2780,6 +2937,122 @@ const handleNetDelete = (row) => {
|
|||||||
}).catch(() => {})
|
}).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)
|
const showVolSelector = ref(false)
|
||||||
|
|
||||||
@@ -3619,7 +3892,7 @@ const triggerTabLoad = (tab) => {
|
|||||||
if (tab === 'backup') { loadBackups(); loadBackupQuota() }
|
if (tab === 'backup') { loadBackups(); loadBackupQuota() }
|
||||||
if (tab === 'userNetworking') loadVmNetworkingList()
|
if (tab === 'userNetworking') loadVmNetworkingList()
|
||||||
if (tab === 'security') loadSgLockInfo()
|
if (tab === 'security') loadSgLockInfo()
|
||||||
if (tab === 'vmTrafficPolicy') loadVmTrafficPolicy()
|
if (tab === 'trafficManage') { loadVmTrafficPolicy(); loadTrafficHourly() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 请求安全组详情补充 lock 字段
|
// 请求安全组详情补充 lock 字段
|
||||||
@@ -3692,6 +3965,14 @@ onMounted(() => { isPageActive = true; initPage() })
|
|||||||
.config-cell:last-child { border-right: none; }
|
.config-cell:last-child { border-right: none; }
|
||||||
.config-label { font-size: 12px; color: #86909c; line-height: 1; }
|
.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; }
|
.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; }
|
.spec-value { font-size: 13px; color: #4e5969; }
|
||||||
.ip-value { color: #165dff; font-weight: 500; }
|
.ip-value { color: #165dff; font-weight: 500; }
|
||||||
.password-cell { display: flex; align-items: center; gap: 8px; }
|
.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="磁盘写入">{{ 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_rx) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="网络发送">{{ formatNetSpeed(vmMetricsData.net_tx) }}</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>
|
</el-descriptions>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user