296 lines
11 KiB
Vue
296 lines
11 KiB
Vue
<script setup lang="ts">
|
|
import { NButton, NInput, NInputNumber, NSelect } from 'naive-ui'
|
|
import { reactive, ref } from 'vue'
|
|
|
|
import { updateSecurityList } from '@/api/networks'
|
|
import ConfirmPop from '@/components/ConfirmPop.vue'
|
|
import type { SecurityList, SecurityRule } from '@/types/api'
|
|
import { useToast } from '@/composables/useToast'
|
|
|
|
const props = defineProps<{
|
|
cfgId: number
|
|
list: SecurityList
|
|
region?: string
|
|
}>()
|
|
const emit = defineEmits<{ updated: [] }>()
|
|
|
|
const message = useToast()
|
|
|
|
type Dir = 'ingress' | 'egress'
|
|
|
|
const PROTOCOLS = [
|
|
{ label: '全部', value: 'all' },
|
|
{ label: 'TCP', value: '6' },
|
|
{ label: 'UDP', value: '17' },
|
|
{ label: 'ICMP', value: '1' },
|
|
{ label: 'ICMPv6', value: '58' },
|
|
]
|
|
|
|
function protoLabel(p: string): string {
|
|
return PROTOCOLS.find((o) => o.value === p)?.label ?? p
|
|
}
|
|
|
|
function portText(r: SecurityRule): string {
|
|
if (r.protocol !== '6' && r.protocol !== '17') return '—'
|
|
if (!r.portMin) return '全部'
|
|
return r.portMax && r.portMax !== r.portMin ? `${r.portMin}-${r.portMax}` : String(r.portMin)
|
|
}
|
|
|
|
/** 编辑缓冲;idx 为 -1 表示新增行 */
|
|
const editing = ref<{ dir: Dir; idx: number } | null>(null)
|
|
const draft = reactive<SecurityRule>({ protocol: '6', isStateless: false })
|
|
const saving = ref(false)
|
|
|
|
function rulesOf(dir: Dir): SecurityRule[] {
|
|
return dir === 'ingress' ? (props.list.ingressRules ?? []) : (props.list.egressRules ?? [])
|
|
}
|
|
|
|
function startEdit(dir: Dir, idx: number) {
|
|
const src = idx >= 0 ? rulesOf(dir)[idx] : undefined
|
|
Object.assign(draft, {
|
|
protocol: src?.protocol ?? '6',
|
|
source: src?.source,
|
|
destination: src?.destination,
|
|
isStateless: src?.isStateless ?? false,
|
|
portMin: src?.portMin,
|
|
portMax: src?.portMax,
|
|
description: src?.description,
|
|
})
|
|
if (!src) {
|
|
if (dir === 'ingress') draft.source = '0.0.0.0/0'
|
|
else draft.destination = '0.0.0.0/0'
|
|
}
|
|
editing.value = { dir, idx }
|
|
}
|
|
|
|
function buildDraft(dir: Dir): SecurityRule {
|
|
const r: SecurityRule = { protocol: draft.protocol, isStateless: false }
|
|
if (dir === 'ingress') r.source = draft.source?.trim()
|
|
else r.destination = draft.destination?.trim()
|
|
if (draft.protocol === '6' || draft.protocol === '17') {
|
|
if (draft.portMin) {
|
|
r.portMin = draft.portMin
|
|
r.portMax = draft.portMax || draft.portMin
|
|
}
|
|
}
|
|
if (draft.description?.trim()) r.description = draft.description.trim()
|
|
return r
|
|
}
|
|
|
|
/** 整表替换提交:规则字段一经提供即全量覆盖,两方向都要带上 */
|
|
async function submit(ingress: SecurityRule[], egress: SecurityRule[]) {
|
|
saving.value = true
|
|
try {
|
|
await updateSecurityList(props.cfgId, props.list.id, {
|
|
region: props.region,
|
|
ingressRules: ingress,
|
|
egressRules: egress,
|
|
})
|
|
message.success('规则已保存')
|
|
editing.value = null
|
|
emit('updated')
|
|
} catch (e) {
|
|
message.error(e instanceof Error ? e.message : '保存失败')
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
function saveEditing() {
|
|
const ed = editing.value
|
|
if (!ed) return
|
|
const cidr = ed.dir === 'ingress' ? draft.source : draft.destination
|
|
if (!cidr?.trim()) {
|
|
message.warning('CIDR 不能为空')
|
|
return
|
|
}
|
|
const next = rulesOf(ed.dir).slice()
|
|
const rule = buildDraft(ed.dir)
|
|
if (ed.idx >= 0) next[ed.idx] = rule
|
|
else next.push(rule)
|
|
const ing = ed.dir === 'ingress' ? next : (props.list.ingressRules ?? [])
|
|
const eg = ed.dir === 'egress' ? next : (props.list.egressRules ?? [])
|
|
void submit(ing, eg)
|
|
}
|
|
|
|
function removeRule(dir: Dir, idx: number): Promise<void> {
|
|
const next = rulesOf(dir).filter((_, i) => i !== idx)
|
|
const ing = dir === 'ingress' ? next : (props.list.ingressRules ?? [])
|
|
const eg = dir === 'egress' ? next : (props.list.egressRules ?? [])
|
|
return submit(ing, eg)
|
|
}
|
|
|
|
function isEditing(dir: Dir, idx: number): boolean {
|
|
return editing.value?.dir === dir && editing.value.idx === idx
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="flex flex-col gap-3 py-1">
|
|
<div v-for="dir in ['ingress', 'egress'] as const" :key="dir">
|
|
<div class="mb-1.5 text-[11.5px] font-semibold tracking-wide text-ink-3 uppercase">
|
|
{{ dir === 'ingress' ? '入站规则' : '出站规则' }}
|
|
</div>
|
|
<div class="overflow-hidden rounded-lg border border-line-soft bg-white">
|
|
<table class="w-full border-collapse text-[12.5px]">
|
|
<thead>
|
|
<tr class="text-left text-xs text-ink-3">
|
|
<th class="border-b border-line-soft px-3.5 py-1.5 font-semibold w-28">协议</th>
|
|
<th class="border-b border-line-soft px-3.5 py-1.5 font-semibold w-44">
|
|
{{ dir === 'ingress' ? '源' : '目标' }}
|
|
</th>
|
|
<th class="border-b border-line-soft px-3.5 py-1.5 font-semibold w-36">端口</th>
|
|
<th class="border-b border-line-soft px-3.5 py-1.5 font-semibold">描述</th>
|
|
<th class="border-b border-line-soft px-3.5 py-1.5 text-right font-semibold w-24">操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="(r, idx) in rulesOf(dir)" :key="idx" :class="isEditing(dir, idx) ? 'bg-wash/60' : ''">
|
|
<template v-if="isEditing(dir, idx)">
|
|
<td class="border-b border-line-soft px-3.5 py-1.5">
|
|
<NSelect
|
|
v-model:value="draft.protocol"
|
|
size="tiny"
|
|
:options="PROTOCOLS"
|
|
class="!w-24"
|
|
:consistent-menu-width="false"
|
|
/>
|
|
</td>
|
|
<td class="border-b border-line-soft px-3.5 py-1.5">
|
|
<NInput
|
|
v-if="dir === 'ingress'"
|
|
v-model:value="draft.source"
|
|
size="tiny"
|
|
placeholder="0.0.0.0/0"
|
|
/>
|
|
<NInput v-else v-model:value="draft.destination" size="tiny" placeholder="0.0.0.0/0" />
|
|
</td>
|
|
<td class="border-b border-line-soft px-3.5 py-1.5">
|
|
<div
|
|
v-if="draft.protocol === '6' || draft.protocol === '17'"
|
|
class="flex items-center gap-1"
|
|
>
|
|
<NInputNumber
|
|
v-model:value="draft.portMin"
|
|
size="tiny"
|
|
:show-button="false"
|
|
placeholder="起"
|
|
class="!w-16"
|
|
/>
|
|
<span class="text-ink-3">-</span>
|
|
<NInputNumber
|
|
v-model:value="draft.portMax"
|
|
size="tiny"
|
|
:show-button="false"
|
|
placeholder="止"
|
|
class="!w-16"
|
|
/>
|
|
</div>
|
|
<span v-else class="text-ink-3">—</span>
|
|
</td>
|
|
<td class="border-b border-line-soft px-3.5 py-1.5">
|
|
<NInput v-model:value="draft.description" size="tiny" placeholder="描述(可选)" />
|
|
</td>
|
|
<td class="border-b border-line-soft px-3.5 py-1.5 text-right whitespace-nowrap">
|
|
<NButton size="tiny" type="primary" :loading="saving" @click="saveEditing">保存</NButton>
|
|
<NButton size="tiny" quaternary class="ml-1" :disabled="saving" @click="editing = null">
|
|
取消
|
|
</NButton>
|
|
</td>
|
|
</template>
|
|
<template v-else>
|
|
<td class="border-b border-line-soft px-3.5 py-1.5">{{ protoLabel(r.protocol) }}</td>
|
|
<td class="mono border-b border-line-soft px-3.5 py-1.5">
|
|
{{ dir === 'ingress' ? r.source : r.destination }}
|
|
</td>
|
|
<td class="mono border-b border-line-soft px-3.5 py-1.5">{{ portText(r) }}</td>
|
|
<td class="border-b border-line-soft px-3.5 py-1.5 text-ink-2">{{ r.description || '—' }}</td>
|
|
<td class="border-b border-line-soft px-3.5 py-1.5 text-right whitespace-nowrap">
|
|
<NButton size="tiny" quaternary :disabled="!!editing" @click="startEdit(dir, idx)">
|
|
编辑
|
|
</NButton>
|
|
<ConfirmPop
|
|
title="删除该条规则?"
|
|
:content="`${protoLabel(r.protocol)} ${dir === 'ingress' ? r.source : r.destination} ${portText(r)}`"
|
|
:on-confirm="() => removeRule(dir, idx)"
|
|
>
|
|
<template #trigger>
|
|
<NButton size="tiny" quaternary type="error" class="ml-1" :disabled="!!editing">
|
|
删除
|
|
</NButton>
|
|
</template>
|
|
</ConfirmPop>
|
|
</td>
|
|
</template>
|
|
</tr>
|
|
<tr v-if="editing && editing.dir === dir && editing.idx === -1" class="bg-wash/60">
|
|
<td class="border-b border-line-soft px-3.5 py-1.5">
|
|
<NSelect
|
|
v-model:value="draft.protocol"
|
|
size="tiny"
|
|
:options="PROTOCOLS"
|
|
class="!w-24"
|
|
:consistent-menu-width="false"
|
|
/>
|
|
</td>
|
|
<td class="border-b border-line-soft px-3.5 py-1.5">
|
|
<NInput
|
|
v-if="dir === 'ingress'"
|
|
v-model:value="draft.source"
|
|
size="tiny"
|
|
placeholder="0.0.0.0/0"
|
|
/>
|
|
<NInput v-else v-model:value="draft.destination" size="tiny" placeholder="0.0.0.0/0" />
|
|
</td>
|
|
<td class="border-b border-line-soft px-3.5 py-1.5">
|
|
<div
|
|
v-if="draft.protocol === '6' || draft.protocol === '17'"
|
|
class="flex items-center gap-1"
|
|
>
|
|
<NInputNumber
|
|
v-model:value="draft.portMin"
|
|
size="tiny"
|
|
:show-button="false"
|
|
placeholder="起"
|
|
class="!w-16"
|
|
/>
|
|
<span class="text-ink-3">-</span>
|
|
<NInputNumber
|
|
v-model:value="draft.portMax"
|
|
size="tiny"
|
|
:show-button="false"
|
|
placeholder="止"
|
|
class="!w-16"
|
|
/>
|
|
</div>
|
|
<span v-else class="text-ink-3">—</span>
|
|
</td>
|
|
<td class="border-b border-line-soft px-3.5 py-1.5">
|
|
<NInput v-model:value="draft.description" size="tiny" placeholder="描述(可选)" />
|
|
</td>
|
|
<td class="border-b border-line-soft px-3.5 py-1.5 text-right whitespace-nowrap">
|
|
<NButton size="tiny" type="primary" :loading="saving" @click="saveEditing">保存</NButton>
|
|
<NButton size="tiny" quaternary class="ml-1" :disabled="saving" @click="editing = null">
|
|
取消
|
|
</NButton>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td colspan="5" class="px-3.5 py-1.5">
|
|
<NButton
|
|
size="tiny"
|
|
dashed
|
|
:disabled="!!editing"
|
|
@click="startEdit(dir, -1)"
|
|
>
|
|
+ 添加{{ dir === 'ingress' ? '入站' : '出站' }}规则
|
|
</NButton>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|