331 lines
16 KiB
Vue
331 lines
16 KiB
Vue
<template>
|
|
<div class="sg-detail-page">
|
|
<div class="page-header">
|
|
<div class="header-left">
|
|
<el-button @click="goBack" link class="back-btn"><el-icon><ArrowLeft /></el-icon> 返回安全组列表</el-button>
|
|
<el-divider direction="vertical" />
|
|
<span class="page-title">安全组详情</span>
|
|
</div>
|
|
<div class="header-right">
|
|
<el-button plain :icon="Refresh" @click="loadDetail" :loading="loading">刷新</el-button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="main-content" v-loading="loading">
|
|
<!-- 基本信息 -->
|
|
<el-card shadow="never" class="info-card" v-if="detail">
|
|
<template #header><span class="card-title">基本信息</span></template>
|
|
<el-descriptions :column="2" border>
|
|
<el-descriptions-item label="ID">{{ detail.id }}</el-descriptions-item>
|
|
<el-descriptions-item label="名称">{{ detail.name }}</el-descriptions-item>
|
|
<el-descriptions-item label="锁定">
|
|
<el-tag :type="detail.lock ? 'danger' : 'success'" size="small">{{ detail.lock ? '是' : '否' }}</el-tag>
|
|
</el-descriptions-item>
|
|
<el-descriptions-item label="白名单模式">
|
|
<el-tag :type="detail.drop_all ? 'warning' : 'info'" size="small">{{ detail.drop_all ? '开启' : '关闭' }}</el-tag>
|
|
</el-descriptions-item>
|
|
<el-descriptions-item label="宿主机">{{ getHostLabel(detail.host_id) }}</el-descriptions-item>
|
|
<el-descriptions-item label="介绍">{{ detail.direction || '-' }}</el-descriptions-item>
|
|
<el-descriptions-item label="创建时间">{{ formatTimestamp(detail.created_at) }}</el-descriptions-item>
|
|
<el-descriptions-item label="更新时间">{{ formatTimestamp(detail.updated_at) }}</el-descriptions-item>
|
|
</el-descriptions>
|
|
</el-card>
|
|
|
|
<!-- 操作按钮 -->
|
|
<el-card shadow="never" class="info-card" v-if="detail">
|
|
<template #header><span class="card-title">操作</span></template>
|
|
<div class="action-buttons">
|
|
<el-button type="primary" @click="handleSync">同步到宿主机</el-button>
|
|
<el-button type="success" @click="handleBind">绑定VM</el-button>
|
|
<el-button type="warning" @click="handleUnbind">解绑VM</el-button>
|
|
<el-button :type="detail.drop_all ? 'info' : 'warning'" @click="handleToggleWhitelist">
|
|
{{ detail.drop_all ? '关闭白名单' : '开启白名单' }}
|
|
</el-button>
|
|
<el-button type="primary" @click="handleApply">应用规则</el-button>
|
|
<el-button type="danger" @click="handleDelete">删除安全组</el-button>
|
|
</div>
|
|
</el-card>
|
|
|
|
<!-- 规则管理 -->
|
|
<el-card shadow="never" class="info-card">
|
|
<template #header>
|
|
<div class="card-header-row">
|
|
<span class="card-title">安全组规则</span>
|
|
<el-button type="primary" size="small" @click="handleAddRule">新增规则</el-button>
|
|
</div>
|
|
</template>
|
|
<el-table :data="detail?.rules || []" stripe size="small" style="width: 100%">
|
|
<el-table-column prop="id" label="ID" width="60" />
|
|
<el-table-column prop="protocol" label="协议" width="80">
|
|
<template #default="{ row }"><el-tag size="small">{{ (row.protocol || '-').toUpperCase() }}</el-tag></template>
|
|
</el-table-column>
|
|
<el-table-column prop="action" label="动作" width="80">
|
|
<template #default="{ row }">
|
|
<el-tag :type="row.action === 'allow' ? 'success' : 'danger'" size="small">{{ row.action === 'allow' ? '允许' : '拒绝' }}</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="port_range" label="端口范围" min-width="120" />
|
|
<el-table-column prop="ip_range" label="IP 范围" min-width="140" />
|
|
<el-table-column prop="priority" label="优先级" width="80" />
|
|
<el-table-column label="操作" width="130">
|
|
<template #default="{ row }">
|
|
<el-button link type="primary" size="small" @click="handleEditRule(row)">编辑</el-button>
|
|
<el-button link type="danger" size="small" @click="handleDeleteRule(row)">删除</el-button>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
<el-empty v-if="!detail?.rules?.length && !loading" description="暂无规则" />
|
|
</el-card>
|
|
</div>
|
|
|
|
<!-- 同步弹窗 -->
|
|
<el-dialog v-model="syncDialogVisible" title="同步到宿主机" width="420px" destroy-on-close>
|
|
<el-form label-width="100px">
|
|
<el-form-item label="安全组">{{ detail?.name || '-' }}</el-form-item>
|
|
<el-form-item label="目标宿主机">
|
|
<el-select v-model="syncHostId" placeholder="选择宿主机" filterable style="width: 100%">
|
|
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
|
|
</el-select>
|
|
</el-form-item>
|
|
</el-form>
|
|
<template #footer>
|
|
<el-button @click="syncDialogVisible = false">取消</el-button>
|
|
<el-button type="primary" :loading="actionLoading" @click="submitSync">同步</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
|
|
<!-- 绑定/解绑弹窗 -->
|
|
<el-dialog v-model="bindDialogVisible" :title="bindType === 'bind' ? '绑定安全组到虚拟机' : '解绑安全组'" width="420px" destroy-on-close>
|
|
<el-form label-width="100px">
|
|
<el-form-item label="安全组">{{ detail?.name || '-' }}</el-form-item>
|
|
<el-form-item label="虚拟机">
|
|
<div style="display: flex; gap: 8px; width: 100%">
|
|
<el-input :model-value="bindVmId ? `${bindVmName || ''} (ID: ${bindVmId})` : ''" readonly placeholder="请选择虚拟机" style="flex: 1" />
|
|
<el-button type="primary" @click="showVmSelector = true">选择</el-button>
|
|
</div>
|
|
</el-form-item>
|
|
</el-form>
|
|
<template #footer>
|
|
<el-button @click="bindDialogVisible = false">取消</el-button>
|
|
<el-button :type="bindType === 'bind' ? 'primary' : 'warning'" :loading="actionLoading" @click="submitBind">
|
|
{{ bindType === 'bind' ? '绑定' : '解绑' }}
|
|
</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
|
|
<VmSelectorPopup v-model="showVmSelector" :service-id="serviceId" :current-id="bindVmId" @confirm="vm => { bindVmId = vm.id; bindVmName = vm.name || '' }" />
|
|
|
|
<!-- 规则弹窗 -->
|
|
<el-dialog v-model="ruleDialogVisible" :title="ruleDialogType === 'add' ? '新增规则' : '编辑规则'" width="520px" destroy-on-close>
|
|
<el-form ref="ruleFormRef" :model="ruleForm" :rules="ruleRules" label-width="100px">
|
|
<el-form-item label="协议" prop="protocol">
|
|
<el-select v-model="ruleForm.protocol" style="width: 100%">
|
|
<el-option label="TCP" value="tcp" /><el-option label="UDP" value="udp" />
|
|
</el-select>
|
|
</el-form-item>
|
|
<el-form-item label="动作" prop="action">
|
|
<el-select v-model="ruleForm.action" style="width: 100%">
|
|
<el-option label="允许" value="allow" /><el-option label="拒绝" value="deny" />
|
|
</el-select>
|
|
</el-form-item>
|
|
<el-form-item label="端口范围"><el-input v-model="ruleForm.port_range" placeholder="如 80 或 80-90" /></el-form-item>
|
|
<el-form-item label="IP 范围"><el-input v-model="ruleForm.ip_range" placeholder="如 0.0.0.0/0" /></el-form-item>
|
|
<el-form-item label="优先级"><el-input-number v-model="ruleForm.priority" :min="0" :max="9999" style="width: 100%" /></el-form-item>
|
|
</el-form>
|
|
<template #footer>
|
|
<el-button @click="ruleDialogVisible = false">取消</el-button>
|
|
<el-button type="primary" :loading="actionLoading" @click="submitRule">确定</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, reactive, computed, onMounted } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
import { ArrowLeft, Refresh } from '@element-plus/icons-vue'
|
|
import {
|
|
getRemoteHostList, getSecurityGroupDetail,
|
|
syncSecurityGroup, bindSecurityGroup, unbindSecurityGroup,
|
|
deleteSecurityGroup, enableSecurityGroupWhitelist, disableSecurityGroupWhitelist,
|
|
applySecurityGroup, createSecurityGroupRule, updateSecurityGroupRule, deleteSecurityGroupRule
|
|
} from '@/api/admin/kvmService'
|
|
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
|
|
import { useTagsViewStore } from '@/store/tagsViewStore'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const tagsViewStore = useTagsViewStore()
|
|
|
|
const serviceId = computed(() => parseInt(route.query.service_id) || 0)
|
|
const serviceName = computed(() => route.query.service_name || '')
|
|
const sgId = computed(() => parseInt(route.query.id) || 0)
|
|
|
|
const loading = ref(false)
|
|
const actionLoading = ref(false)
|
|
const detail = ref(null)
|
|
const hostOptions = ref([])
|
|
|
|
// 同步
|
|
const syncDialogVisible = ref(false)
|
|
const syncHostId = ref(0)
|
|
|
|
// 绑定
|
|
const bindDialogVisible = ref(false)
|
|
const bindType = ref('bind')
|
|
const bindVmId = ref(0)
|
|
const bindVmName = ref('')
|
|
const showVmSelector = ref(false)
|
|
|
|
// 规则
|
|
const ruleDialogVisible = ref(false)
|
|
const ruleDialogType = ref('add')
|
|
const ruleFormRef = ref(null)
|
|
const ruleForm = reactive({ id: undefined, group_id: 0, protocol: 'tcp', action: 'allow', port_range: '', ip_range: '', priority: 0, port_group_id: 0 })
|
|
const ruleRules = {
|
|
protocol: [{ required: true, message: '请选择协议', trigger: 'change' }],
|
|
action: [{ required: true, message: '请选择动作', trigger: 'change' }]
|
|
}
|
|
|
|
const formatTimestamp = (ts) => {
|
|
if (!ts) return '-'
|
|
if (typeof ts === 'object' && ts.seconds) return new Date(Number(ts.seconds) * 1000).toLocaleString('zh-CN')
|
|
if (typeof ts === 'string' || typeof ts === 'number') { const d = new Date(ts); return isNaN(d.getTime()) ? String(ts) : d.toLocaleString('zh-CN') }
|
|
return '-'
|
|
}
|
|
const getHostLabel = (hid) => { const h = hostOptions.value.find(x => x.id === hid); return h ? h.name : (hid || '-') }
|
|
|
|
const loadHostOptions = async () => {
|
|
try {
|
|
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 100 })
|
|
if (res?.data?.code === 200 && res?.data?.data) {
|
|
const inner = res.data.data
|
|
hostOptions.value = inner.hosts || inner.data || (Array.isArray(inner) ? inner : [])
|
|
}
|
|
} catch { /* */ }
|
|
}
|
|
|
|
const loadDetail = async () => {
|
|
if (!sgId.value) return
|
|
loading.value = true
|
|
try {
|
|
const res = await getSecurityGroupDetail({ service_id: serviceId.value, id: sgId.value })
|
|
if (res?.data?.code === 200 && res?.data?.data) {
|
|
const inner = res.data.data
|
|
detail.value = inner.group || inner.data || inner
|
|
} else ElMessage.error(res?.data?.message || '加载失败')
|
|
} catch { ElMessage.error('加载失败') } finally { loading.value = false }
|
|
}
|
|
|
|
const handleSync = () => { syncHostId.value = detail.value?.host_id || 0; syncDialogVisible.value = true }
|
|
const submitSync = async () => {
|
|
if (!syncHostId.value) { ElMessage.warning('请选择宿主机'); return }
|
|
actionLoading.value = true
|
|
try {
|
|
const res = await syncSecurityGroup({ service_id: serviceId.value, id: sgId.value, host_id: syncHostId.value })
|
|
if (res?.data?.code === 200) { ElMessage.success('同步成功'); syncDialogVisible.value = false; loadDetail() }
|
|
else ElMessage.error(res?.data?.message || '同步失败')
|
|
} catch { ElMessage.error('同步失败') } finally { actionLoading.value = false }
|
|
}
|
|
|
|
const handleBind = () => { bindType.value = 'bind'; bindVmId.value = 0; bindVmName.value = ''; bindDialogVisible.value = true }
|
|
const handleUnbind = () => { bindType.value = 'unbind'; bindVmId.value = 0; bindVmName.value = ''; bindDialogVisible.value = true }
|
|
const submitBind = async () => {
|
|
if (!bindVmId.value) { ElMessage.warning('请选择虚拟机'); return }
|
|
actionLoading.value = true
|
|
try {
|
|
const api = bindType.value === 'bind' ? bindSecurityGroup : unbindSecurityGroup
|
|
const res = await api({ service_id: serviceId.value, id: sgId.value, vm_id: bindVmId.value })
|
|
if (res?.data?.code === 200) { ElMessage.success(bindType.value === 'bind' ? '绑定成功' : '解绑成功'); bindDialogVisible.value = false }
|
|
else ElMessage.error(res?.data?.message || '操作失败')
|
|
} catch { ElMessage.error('操作失败') } finally { actionLoading.value = false }
|
|
}
|
|
|
|
const handleToggleWhitelist = () => {
|
|
const action = detail.value.drop_all ? '关闭' : '开启'
|
|
ElMessageBox.confirm(`确定${action}白名单模式?`, `${action}白名单`, { type: 'warning' }).then(async () => {
|
|
try {
|
|
const api = detail.value.drop_all ? disableSecurityGroupWhitelist : enableSecurityGroupWhitelist
|
|
const res = await api({ service_id: serviceId.value, id: sgId.value })
|
|
if (res?.data?.code === 200) { ElMessage.success(`${action}成功`); loadDetail() }
|
|
else ElMessage.error(res?.data?.message || `${action}失败`)
|
|
} catch { ElMessage.error(`${action}失败`) }
|
|
}).catch(() => {})
|
|
}
|
|
|
|
const handleApply = () => {
|
|
ElMessageBox.confirm('确定应用安全组规则到所有绑定的虚拟机?', '应用安全组', { type: 'info' }).then(async () => {
|
|
try {
|
|
const res = await applySecurityGroup({ service_id: serviceId.value, id: sgId.value })
|
|
if (res?.data?.code === 200) ElMessage.success('应用成功')
|
|
else ElMessage.error(res?.data?.message || '应用失败')
|
|
} catch { ElMessage.error('应用失败') }
|
|
}).catch(() => {})
|
|
}
|
|
|
|
const handleDelete = () => {
|
|
ElMessageBox.confirm(`确定删除安全组「${detail.value?.name}」?`, '删除确认', { type: 'warning' }).then(async () => {
|
|
try {
|
|
const res = await deleteSecurityGroup({ service_id: serviceId.value, id: sgId.value })
|
|
if (res?.data?.code === 200) { ElMessage.success('删除成功'); goBack() }
|
|
else ElMessage.error(res?.data?.message || '删除失败')
|
|
} catch { ElMessage.error('删除失败') }
|
|
}).catch(() => {})
|
|
}
|
|
|
|
const handleAddRule = () => {
|
|
ruleDialogType.value = 'add'
|
|
Object.assign(ruleForm, { id: undefined, group_id: sgId.value, protocol: 'tcp', action: 'allow', port_range: '', ip_range: '', priority: 0, port_group_id: 0 })
|
|
ruleDialogVisible.value = true
|
|
}
|
|
const handleEditRule = (rule) => {
|
|
ruleDialogType.value = 'edit'
|
|
Object.assign(ruleForm, { id: rule.id, group_id: sgId.value, port_group_id: sgId.value, protocol: rule.protocol || 'tcp', action: rule.action || 'allow', port_range: rule.port_range || '', ip_range: rule.ip_range || '', priority: rule.priority || 0 })
|
|
ruleDialogVisible.value = true
|
|
}
|
|
const submitRule = () => {
|
|
ruleFormRef.value?.validate(async (valid) => {
|
|
if (!valid) return
|
|
actionLoading.value = true
|
|
try {
|
|
const api = ruleDialogType.value === 'add' ? createSecurityGroupRule : updateSecurityGroupRule
|
|
const res = await api({ service_id: serviceId.value, ...ruleForm })
|
|
if (res?.data?.code === 200) { ElMessage.success('操作成功'); ruleDialogVisible.value = false; loadDetail() }
|
|
else ElMessage.error(res?.data?.message || '操作失败')
|
|
} catch { ElMessage.error('操作失败') } finally { actionLoading.value = false }
|
|
})
|
|
}
|
|
const handleDeleteRule = (rule) => {
|
|
ElMessageBox.confirm('确定删除该规则?', '删除确认', { type: 'warning' }).then(async () => {
|
|
try {
|
|
const res = await deleteSecurityGroupRule({ service_id: serviceId.value, id: rule.id })
|
|
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadDetail() }
|
|
else ElMessage.error(res?.data?.message || '删除失败')
|
|
} catch { ElMessage.error('删除失败') }
|
|
}).catch(() => {})
|
|
}
|
|
|
|
const goBack = () => {
|
|
tagsViewStore.delVisitedView(route)
|
|
router.push({ path: '/virtualization/kvm-service-detail', query: { service_id: serviceId.value, service_name: serviceName.value } })
|
|
}
|
|
|
|
onMounted(() => { loadHostOptions(); loadDetail() })
|
|
</script>
|
|
|
|
<style scoped>
|
|
.sg-detail-page { padding: 0; }
|
|
.page-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; background: #fff; border-bottom: 1px solid #ebeef5; }
|
|
.header-left { display: flex; align-items: center; gap: 0; }
|
|
.back-btn { font-size: 14px; color: #606266; }
|
|
.back-btn:hover { color: #409eff; }
|
|
.page-title { font-size: 16px; font-weight: 600; color: #303133; }
|
|
.header-right { display: flex; gap: 8px; }
|
|
.main-content { padding: 20px; }
|
|
.info-card { margin-bottom: 20px; }
|
|
.card-title { font-weight: 600; font-size: 15px; color: #303133; }
|
|
.card-header-row { display: flex; justify-content: space-between; align-items: center; }
|
|
.action-buttons { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
</style>
|