This commit is contained in:
zxr
2026-04-09 19:58:45 +08:00
parent 86bf1dd9b3
commit 70a7d89b4f
4 changed files with 225 additions and 9 deletions

View File

@@ -21,6 +21,7 @@ export interface IpScanTask {
online_count?: number
scan_count?: number
enable?: boolean
priority?: number
created_at?: string
updated_at?: string
}
@@ -41,9 +42,23 @@ export const fetchIpScanList = (params?: { page?: number; size?: number; keyword
export const fetchIpScanDetail = (id: number) =>
request.get<{ code: number; details?: IpScanTask; message?: string }>(`/DC-Control/v1/ipscans/${id}`)
/** 触发一次扫描(会创建 scan_run 并调用 Agent */
/** 触发一次扫描(会创建 scan_run 并调用 Agent;成功体见文档 { message: "scan triggered" } */
export const triggerIpScan = (id: number) =>
request.post<{ code: number; message?: string }>(`/DC-Control/v1/ipscans/${id}/trigger`)
request.post<{ code: number; details?: { message?: string }; message?: string }>(
`/DC-Control/v1/ipscans/${id}/trigger`,
)
/** 启动扫描任务(文档:将 status 置为 running与 Cron 调度语义相关) */
export const startIpScan = (id: number) =>
request.post<{ code: number; details?: { message?: string }; message?: string }>(
`/DC-Control/v1/ipscans/${id}/start`,
)
/** 停止扫描任务(文档:将 status 置为 stopped */
export const stopIpScan = (id: number) =>
request.post<{ code: number; details?: { message?: string }; message?: string }>(
`/DC-Control/v1/ipscans/${id}/stop`,
)
/** 创建扫描任务 */
export const createIpScan = (data: Partial<IpScanTask>) =>

View File

@@ -26,6 +26,8 @@ export interface ServerItem {
collect_args: string
collect_interval: number
collect_last_result: string
is_ip_scan_server: boolean
ip_scan_port: number
}
/** 服务器列表响应 */
@@ -42,6 +44,7 @@ export interface ServerListParams {
size?: number
keyword?: string
collect_on?: boolean
is_ip_scan_server?: boolean
}
/** 创建/更新服务器请求参数 */
@@ -66,6 +69,8 @@ export interface ServerFormData {
collect_args?: string
collect_interval?: number
collect_last_result?: string
is_ip_scan_server?: boolean
ip_scan_port?: number
}
/** 获取服务器列表(分页) */

View File

@@ -126,6 +126,19 @@
</a-col>
</a-row>
<a-row :gutter="20">
<a-col :span="8">
<a-form-item field="is_ip_scan_server" label="IP扫描执行服务器">
<a-switch v-model="formData.is_ip_scan_server" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item v-if="formData.is_ip_scan_server" field="ip_scan_port" label="IP扫描端口">
<a-input-number v-model="formData.ip_scan_port" :min="1" :max="65535" placeholder="默认12429" />
</a-form-item>
</a-col>
</a-row>
<a-form-item field="description" label="描述信息">
<a-textarea
v-model="formData.description"
@@ -178,6 +191,8 @@ const formData = reactive<ServerFormData>({
status: 'unknown',
collect_on: true,
collect_interval: 60,
is_ip_scan_server: false,
ip_scan_port: 12429,
})
const rules = {
@@ -185,6 +200,21 @@ const rules = {
host: [{ required: true, message: '请输入主机地址' }],
}
function validateAgentConfigURL(raw?: string): string | null {
const v = (raw || '').trim()
if (!v) return null
try {
const u = new URL(v)
const protocol = u.protocol.toLowerCase()
if (protocol === 'https:' && u.port && u.port !== '443') {
return `Agent 配置 URL 使用 https 且端口为 ${u.port},请确认该端口确实启用了 TLS若为明文服务请改为 http://`
}
return null
} catch {
return 'Agent 配置 URL 格式不合法,请输入完整 http(s) URL'
}
}
watch(
() => props.visible,
(val) => {
@@ -208,6 +238,8 @@ watch(
status: props.record.status || 'unknown',
collect_on: props.record.collect_on ?? true,
collect_interval: props.record.collect_interval || 60,
is_ip_scan_server: props.record.is_ip_scan_server ?? false,
ip_scan_port: props.record.ip_scan_port || 12429,
})
} else {
Object.assign(formData, {
@@ -228,6 +260,8 @@ watch(
status: 'unknown',
collect_on: true,
collect_interval: 60,
is_ip_scan_server: false,
ip_scan_port: 12429,
})
}
}
@@ -238,6 +272,12 @@ const handleOk = async () => {
try {
await formRef.value?.validate()
const agentConfigErr = validateAgentConfigURL(formData.agent_config)
if (agentConfigErr) {
Message.warning(agentConfigErr)
return
}
confirmLoading.value = true
const submitData: ServerFormData = {
@@ -258,6 +298,8 @@ const handleOk = async () => {
status: formData.status,
collect_on: formData.collect_on,
collect_interval: formData.collect_interval,
is_ip_scan_server: formData.is_ip_scan_server,
ip_scan_port: formData.ip_scan_port,
}
if (isEdit.value && props.record?.id) {

View File

@@ -14,7 +14,19 @@
</a-space>
</div>
<a-card :bordered="false">
<a-card :bordered="false" title="任务列表">
<template #extra>
<a-space>
<a-input
v-model="keyword"
allow-clear
placeholder="按名称搜索keyword"
style="width: 220px"
@press-enter="runSearch"
/>
<a-button type="primary" @click="runSearch">查询</a-button>
</a-space>
</template>
<a-table
row-key="id"
:columns="columns"
@@ -32,9 +44,17 @@
<template #status="{ record }">
<a-tag bordered>{{ record.status || '—' }}</a-tag>
</template>
<template #latestError="{ record }">
<span v-if="latestErrorMap[record.id]" class="error-text" :title="latestErrorMap[record.id]">
{{ latestErrorMap[record.id] }}
</span>
<span v-else class="text-muted"></span>
</template>
<template #actions="{ record }">
<a-space>
<a-space wrap>
<a-button type="text" size="small" @click="openEdit(record)">编辑</a-button>
<a-button type="text" size="small" @click="handleStart(record)">启动</a-button>
<a-button type="text" size="small" @click="handleStop(record)">停止</a-button>
<a-button type="text" size="small" @click="handleTrigger(record)">触发扫描</a-button>
<a-popconfirm content="确定删除该任务?" @ok="handleDelete(record)">
<a-button type="text" size="small" status="danger">删除</a-button>
@@ -101,6 +121,19 @@
<a-form-item label="Cron可选">
<a-input v-model="form.cron_expr" placeholder="定期扫描 Cron 表达式" allow-clear />
</a-form-item>
<a-form-item label="优先级">
<a-input-number v-model="form.priority" :min="0" :max="1000" style="width: 100%" />
</a-form-item>
<a-form-item label="扫描配置JSON" extra="须为合法 JSON可不改默认提交 {}">
<a-textarea
v-model="form.config"
placeholder="{}"
:auto-size="{ minRows: 2, maxRows: 6 }"
/>
</a-form-item>
<a-form-item label="启用">
<a-switch v-model="formEnable" />
</a-form-item>
<a-form-item label="描述">
<a-input v-model="form.description" allow-clear />
</a-form-item>
@@ -110,7 +143,7 @@
</template>
<script lang="ts" setup>
import { reactive, ref, onMounted } from 'vue'
import { reactive, ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { IconPlus } from '@arco-design/web-vue/es/icon'
import { Message } from '@arco-design/web-vue'
@@ -121,9 +154,12 @@ import {
updateIpScan,
deleteIpScan,
triggerIpScan,
startIpScan,
stopIpScan,
type IpScanTask,
} from '@/api/ops/ipScan'
import { fetchServerList, type ServerItem } from '@/api/ops/server'
import { fetchDiscoveryScanRuns, type DiscoveryScanRun } from '@/api/ops/discovery'
const router = useRouter()
@@ -132,6 +168,7 @@ const serversLoading = ref(false)
const submitting = ref(false)
const tableData = ref<IpScanTask[]>([])
const servers = ref<ServerItem[]>([])
const latestErrorMap = ref<Record<number, string>>({})
const pagination = ref({
current: 1,
@@ -141,6 +178,7 @@ const pagination = ref({
const modalVisible = ref(false)
const editingId = ref<number | null>(null)
const keyword = ref('')
const defaultForm = (): Partial<IpScanTask> => ({
name: '',
@@ -148,15 +186,48 @@ const defaultForm = (): Partial<IpScanTask> => ({
description: '',
target_range: '',
port_range: '',
config: '{}',
timeout: 5,
concurrency: 100,
cron_expr: '',
server_id: undefined,
status: 'stopped',
enable: true,
priority: 0,
})
const form = reactive<Partial<IpScanTask>>(defaultForm())
/** 与 a-switch 绑定form.enable 可能 undefined */
const formEnable = computed({
get: () => form.enable !== false,
set: (v: boolean) => {
form.enable = v
},
})
/** 表单中的 config 转为提交用字符串:空或仅空白 → "{}";否则须可 JSON.parse */
function configToSubmitJson(raw: string | undefined): { ok: true; value: string } | { ok: false } {
const s = raw?.trim() ?? ''
if (!s) return { ok: true, value: '{}' }
try {
return { ok: true, value: JSON.stringify(JSON.parse(s)) }
} catch {
return { ok: false }
}
}
/** 编辑回显:无配置或空串用 "{}";合法 JSON 则格式化便于阅读 */
function configForForm(raw?: string): string {
const s = raw?.trim() ?? ''
if (!s) return '{}'
try {
return JSON.stringify(JSON.parse(s), null, 2)
} catch {
return s
}
}
function unwrapList(res: any): { total: number; data: IpScanTask[] } | undefined {
if (!res || res.code !== 0) return undefined
const d = res.details ?? res.data
@@ -167,7 +238,7 @@ function unwrapList(res: any): { total: number; data: IpScanTask[] } | undefined
const loadServers = async () => {
serversLoading.value = true
try {
const res: any = await fetchServerList({ page: 1, size: 500 })
const res: any = await fetchServerList({ page: 1, size: 500, is_ip_scan_server: true })
if (res.code === 0) {
const d = res.details || {}
servers.value = d.data || []
@@ -183,10 +254,12 @@ const loadTable = async () => {
const res: any = await fetchIpScanList({
page: pagination.value.current,
size: pagination.value.pageSize,
keyword: keyword.value.trim() || undefined,
})
const page = unwrapList(res)
tableData.value = page?.data || []
pagination.value.total = page?.total || 0
await loadLatestErrorsForCurrentPage()
if (res.code !== 0) {
Message.error(res.message || '加载失败')
}
@@ -195,6 +268,31 @@ const loadTable = async () => {
}
}
const loadLatestErrorsForCurrentPage = async () => {
const rows = tableData.value || []
if (!rows.length) {
latestErrorMap.value = {}
return
}
const entries = await Promise.all(
rows.map(async (row) => {
try {
const res: any = await fetchDiscoveryScanRuns({ scan_id: row.id, page: 1, size: 1 })
if (res?.code !== 0) return [row.id, ''] as const
const d = (res.details ?? res.data) as { data?: DiscoveryScanRun[] } | undefined
const last = d?.data?.[0]
if (last?.status === 'failed' && last.error_message) {
return [row.id, last.error_message] as const
}
return [row.id, ''] as const
} catch {
return [row.id, ''] as const
}
}),
)
latestErrorMap.value = Object.fromEntries(entries)
}
const serverLabel = (id?: number) => {
if (!id) return '—'
const s = servers.value.find((x) => x.id === id)
@@ -206,6 +304,11 @@ const onPageChange = (current: number) => {
loadTable()
}
const runSearch = () => {
pagination.value.current = 1
loadTable()
}
const resetForm = () => {
Object.assign(form, defaultForm())
}
@@ -224,11 +327,14 @@ const openEdit = (row: IpScanTask) => {
description: row.description || '',
target_range: row.target_range,
port_range: row.port_range || '',
config: configForForm(row.config),
timeout: row.timeout ?? 5,
concurrency: row.concurrency ?? 100,
cron_expr: row.cron_expr || '',
server_id: row.server_id,
status: row.status || 'stopped',
enable: row.enable !== false,
priority: row.priority ?? 0,
})
modalVisible.value = true
}
@@ -255,6 +361,12 @@ const submitForm = async () => {
return
}
const cfg = configToSubmitJson(form.config)
if (!cfg.ok) {
Message.warning('扫描配置须为合法 JSON不填时请保留 {}')
return
}
submitting.value = true
try {
const body: Partial<IpScanTask> = {
@@ -263,11 +375,13 @@ const submitForm = async () => {
description: form.description,
target_range: form.target_range.trim(),
port_range: form.port_range,
config: cfg.value,
timeout: form.timeout,
concurrency: form.concurrency,
cron_expr: form.cron_expr,
server_id: form.server_id,
enable: true,
enable: form.enable !== false,
priority: form.priority ?? 0,
}
if (!editingId.value) {
body.status = 'stopped'
@@ -317,7 +431,8 @@ const handleTrigger = async (row: IpScanTask) => {
try {
const res: any = await triggerIpScan(row.id)
if (res?.code === 0) {
Message.success('已触发扫描')
const msg = res?.details?.message || res?.data?.message
Message.success(msg === 'scan triggered' ? '已触发扫描' : msg || '已触发扫描')
} else {
Message.error(res?.message || '触发失败')
}
@@ -327,6 +442,36 @@ const handleTrigger = async (row: IpScanTask) => {
}
}
const handleStart = async (row: IpScanTask) => {
try {
const res: any = await startIpScan(row.id)
if (res?.code === 0) {
Message.success('已启动任务')
await loadTable()
} else {
Message.error(res?.message || '启动失败')
}
} catch (e) {
console.error(e)
Message.error('启动失败')
}
}
const handleStop = async (row: IpScanTask) => {
try {
const res: any = await stopIpScan(row.id)
if (res?.code === 0) {
Message.success('已停止任务')
await loadTable()
} else {
Message.error(res?.message || '停止失败')
}
} catch (e) {
console.error(e)
Message.error('停止失败')
}
}
const goAutoTopology = () => {
router.push({ path: '/netarch/auto-topo' })
}
@@ -338,7 +483,8 @@ const columns: TableColumnData[] = [
{ title: '目标范围', slotName: 'target', minWidth: 200 },
{ title: 'Agent', slotName: 'server', width: 180, ellipsis: true, tooltip: true },
{ title: '状态', slotName: 'status', width: 100 },
{ title: '操作', slotName: 'actions', width: 220, fixed: 'right' },
{ title: '最近错误', slotName: 'latestError', minWidth: 260, ellipsis: true, tooltip: true },
{ title: '操作', slotName: 'actions', width: 300, fixed: 'right' },
]
onMounted(async () => {
@@ -384,4 +530,12 @@ export default {
font-family: monospace;
font-size: 13px;
}
.error-text {
color: #f53f3f;
}
.text-muted {
color: var(--color-text-3);
}
</style>