feat
This commit is contained in:
440
src/api/ops/ipam.ts
Normal file
440
src/api/ops/ipam.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
import { request } from '@/api/request'
|
||||
|
||||
/** IP地址运行状态 */
|
||||
export type IPStatus = 'online' | 'offline' | 'unknown'
|
||||
|
||||
/** IP地址分配状态 */
|
||||
export type AllocationStatus = 'allocated' | 'unallocated' | 'reserved'
|
||||
|
||||
/** IP地址使用状态 */
|
||||
export type UsageStatus = 'used' | 'unused' | 'used_within_30d' | 'used_before_30d'
|
||||
|
||||
/** IP来源类型 */
|
||||
export type SourceType = 'scan' | 'dhcp' | 'manual'
|
||||
|
||||
/** 概览统计 */
|
||||
export interface IPAMOverview {
|
||||
total_ip: number
|
||||
allocated: number
|
||||
unallocated: number
|
||||
reserved: number
|
||||
used: number
|
||||
unused: number
|
||||
used_within_30d: number
|
||||
used_before_30d: number
|
||||
conflict_total: number
|
||||
conflict_unresolved: number
|
||||
subnet_usage_top10?: SubnetUsageItem[]
|
||||
}
|
||||
|
||||
/** 子网使用率项 */
|
||||
export interface SubnetUsageItem {
|
||||
subnet_id: number
|
||||
cidr: string
|
||||
name: string
|
||||
total: number
|
||||
used: number
|
||||
usage_percent: number
|
||||
}
|
||||
|
||||
/** IP地址项 */
|
||||
export interface IPAddressItem {
|
||||
id: number
|
||||
ip_address: string
|
||||
status: IPStatus
|
||||
allocation_status: AllocationStatus
|
||||
usage_status: UsageStatus
|
||||
subnet_id: number
|
||||
subnet_name?: string
|
||||
subnet_cidr?: string
|
||||
hostname: string
|
||||
mac_address: string
|
||||
last_used_at: string
|
||||
source_type: SourceType
|
||||
owner_type: string
|
||||
owner_id: number
|
||||
remark: string
|
||||
tags: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** IP地址列表参数 */
|
||||
export interface IPAddressListParams {
|
||||
page?: number
|
||||
size?: number
|
||||
scan_id?: number
|
||||
subnet_id?: number
|
||||
status?: IPStatus
|
||||
allocation_status?: AllocationStatus
|
||||
usage_status?: UsageStatus
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
/** IP地址列表响应 */
|
||||
export interface IPAddressListResponse {
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
data: IPAddressItem[]
|
||||
}
|
||||
|
||||
/** IP地址表单数据 */
|
||||
export interface IPAddressFormData {
|
||||
ip_address?: string
|
||||
status?: IPStatus
|
||||
allocation_status?: AllocationStatus
|
||||
subnet_id?: number
|
||||
hostname?: string
|
||||
mac_address?: string
|
||||
source_type?: SourceType
|
||||
owner_type?: string
|
||||
owner_id?: number
|
||||
remark?: string
|
||||
tags?: string
|
||||
}
|
||||
|
||||
/** IP分组项 */
|
||||
export interface IPGroupItem {
|
||||
id: number
|
||||
name: string
|
||||
parent_id: number
|
||||
description: string
|
||||
is_default: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
children?: IPGroupItem[]
|
||||
}
|
||||
|
||||
/** IP分组列表参数 */
|
||||
export interface IPGroupListParams {
|
||||
parent_id?: number
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
/** IP分组列表响应 */
|
||||
export interface IPGroupListResponse {
|
||||
data: IPGroupItem[]
|
||||
}
|
||||
|
||||
/** IP分组表单数据 */
|
||||
export interface IPGroupFormData {
|
||||
name?: string
|
||||
parent_id?: number
|
||||
description?: string
|
||||
}
|
||||
|
||||
/** IP子网项 */
|
||||
export interface IPSubnetItem {
|
||||
id: number
|
||||
cidr: string
|
||||
name: string
|
||||
group_id: number
|
||||
group_name?: string
|
||||
gateway: string
|
||||
vlan: string
|
||||
total: number
|
||||
used: number
|
||||
available: number
|
||||
reserved_ranges_json: string
|
||||
description: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** IP子网列表参数 */
|
||||
export interface IPSubnetListParams {
|
||||
page?: number
|
||||
size?: number
|
||||
group_id?: number
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
/** IP子网列表响应 */
|
||||
export interface IPSubnetListResponse {
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
data: IPSubnetItem[]
|
||||
}
|
||||
|
||||
/** IP子网表单数据 */
|
||||
export interface IPSubnetFormData {
|
||||
cidr?: string
|
||||
name?: string
|
||||
group_id?: number
|
||||
gateway?: string
|
||||
vlan?: string
|
||||
reserved_ranges_json?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
/** DHCP租约项 */
|
||||
export interface DHCPLeaseItem {
|
||||
id: number
|
||||
ip_address: string
|
||||
mac_address: string
|
||||
hostname: string
|
||||
subnet_id: number
|
||||
subnet_name?: string
|
||||
lease_start: string
|
||||
lease_end: string
|
||||
dhcp_server: string
|
||||
status: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** DHCP租约列表参数 */
|
||||
export interface DHCPLeaseListParams {
|
||||
page?: number
|
||||
size?: number
|
||||
subnet_id?: number
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
/** DHCP租约列表响应 */
|
||||
export interface DHCPLeaseListResponse {
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
data: DHCPLeaseItem[]
|
||||
}
|
||||
|
||||
/** DHCP租约表单数据 */
|
||||
export interface DHCPLeaseFormData {
|
||||
ip_address?: string
|
||||
mac_address?: string
|
||||
hostname?: string
|
||||
subnet_id?: number
|
||||
lease_start?: string
|
||||
lease_end?: string
|
||||
dhcp_server?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
/** IP冲突项 */
|
||||
export interface IPConflictItem {
|
||||
id: number
|
||||
ip_address: string
|
||||
mac_address_1: string
|
||||
mac_address_2: string
|
||||
hostname_1: string
|
||||
hostname_2: string
|
||||
subnet_id: number
|
||||
subnet_name?: string
|
||||
detected_at: string
|
||||
resolved_at: string
|
||||
status: string
|
||||
evidence_json: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** IP冲突列表参数 */
|
||||
export interface IPConflictListParams {
|
||||
page?: number
|
||||
size?: number
|
||||
subnet_id?: number
|
||||
status?: string
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
/** IP冲突列表响应 */
|
||||
export interface IPConflictListResponse {
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
data: IPConflictItem[]
|
||||
}
|
||||
|
||||
/** IP冲突表单数据 */
|
||||
export interface IPConflictFormData {
|
||||
ip_address?: string
|
||||
mac_address_1?: string
|
||||
mac_address_2?: string
|
||||
hostname_1?: string
|
||||
hostname_2?: string
|
||||
subnet_id?: number
|
||||
evidence_json?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
/** IP变更项 */
|
||||
export interface IPChangeItem {
|
||||
id: number
|
||||
ip_address: string
|
||||
change_type: string
|
||||
before_json: string
|
||||
after_json: string
|
||||
subnet_id: number
|
||||
subnet_name?: string
|
||||
changed_by: string
|
||||
changed_at: string
|
||||
remark: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** IP变更列表参数 */
|
||||
export interface IPChangeListParams {
|
||||
page?: number
|
||||
size?: number
|
||||
subnet_id?: number
|
||||
change_type?: string
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
/** IP变更列表响应 */
|
||||
export interface IPChangeListResponse {
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
data: IPChangeItem[]
|
||||
}
|
||||
|
||||
/** IP变更表单数据 */
|
||||
export interface IPChangeFormData {
|
||||
ip_address?: string
|
||||
change_type?: string
|
||||
before_json?: string
|
||||
after_json?: string
|
||||
subnet_id?: number
|
||||
changed_by?: string
|
||||
remark?: string
|
||||
}
|
||||
|
||||
/** IP异常项 */
|
||||
export interface IPAnomalyItem {
|
||||
id: number
|
||||
ip_address: string
|
||||
anomaly_type: string
|
||||
subnet_id: number
|
||||
subnet_name?: string
|
||||
detail_json: string
|
||||
detected_at: string
|
||||
status: string
|
||||
remark: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** IP异常列表参数 */
|
||||
export interface IPAnomalyListParams {
|
||||
page?: number
|
||||
size?: number
|
||||
subnet_id?: number
|
||||
anomaly_type?: string
|
||||
status?: string
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
/** IP异常列表响应 */
|
||||
export interface IPAnomalyListResponse {
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
data: IPAnomalyItem[]
|
||||
}
|
||||
|
||||
/** IP异常表单数据 */
|
||||
export interface IPAnomalyFormData {
|
||||
ip_address?: string
|
||||
anomaly_type?: string
|
||||
subnet_id?: number
|
||||
detail_json?: string
|
||||
status?: string
|
||||
remark?: string
|
||||
}
|
||||
|
||||
/** ========== 概览 API ========== */
|
||||
|
||||
/** 获取IPAM概览统计 */
|
||||
export const fetchIPAMOverview = () => request.get<IPAMOverview>('/DC-Control/v1/ipam/overview')
|
||||
|
||||
/** ========== IP地址 API ========== */
|
||||
|
||||
/** 获取IP地址列表 */
|
||||
export const fetchIPAddressList = (params?: IPAddressListParams) =>
|
||||
request.get<IPAddressListResponse>('/DC-Control/v1/ipaddresses', { params })
|
||||
|
||||
/** 获取IP地址详情 */
|
||||
export const fetchIPAddressDetail = (id: number) => request.get<IPAddressItem>(`/DC-Control/v1/ipaddresses/${id}`)
|
||||
|
||||
/** 创建IP地址 */
|
||||
export const createIPAddress = (data: IPAddressFormData) => request.post<IPAddressItem>('/DC-Control/v1/ipaddresses', data)
|
||||
|
||||
/** 更新IP地址 */
|
||||
export const updateIPAddress = (id: number, data: Partial<IPAddressFormData>) =>
|
||||
request.put<{ message: string }>(`/DC-Control/v1/ipaddresses/${id}`, data)
|
||||
|
||||
/** 删除IP地址 */
|
||||
export const deleteIPAddress = (id: number) => request.delete<{ message: string }>(`/DC-Control/v1/ipaddresses/${id}`)
|
||||
|
||||
/** ========== IP分组 API ========== */
|
||||
|
||||
/** 获取IP分组列表(树形) */
|
||||
export const fetchIPGroupList = (params?: IPGroupListParams) => request.get<IPGroupListResponse>('/DC-Control/v1/ip-groups', { params })
|
||||
|
||||
/** 创建IP分组 */
|
||||
export const createIPGroup = (data: IPGroupFormData) => request.post<IPGroupItem>('/DC-Control/v1/ip-groups', data)
|
||||
|
||||
/** 更新IP分组 */
|
||||
export const updateIPGroup = (id: number, data: Partial<IPGroupFormData>) =>
|
||||
request.put<{ message: string }>(`/DC-Control/v1/ip-groups/${id}`, data)
|
||||
|
||||
/** 删除IP分组 */
|
||||
export const deleteIPGroup = (id: number) => request.delete<{ message: string }>(`/DC-Control/v1/ip-groups/${id}`)
|
||||
|
||||
/** ========== IP子网 API ========== */
|
||||
|
||||
/** 获取IP子网列表 */
|
||||
export const fetchIPSubnetList = (params?: IPSubnetListParams) => request.get<IPSubnetListResponse>('/DC-Control/v1/ip-subnets', { params })
|
||||
|
||||
/** 获取IP子网详情 */
|
||||
export const fetchIPSubnetDetail = (id: number) => request.get<IPSubnetItem>(`/DC-Control/v1/ip-subnets/${id}`)
|
||||
|
||||
/** 创建IP子网 */
|
||||
export const createIPSubnet = (data: IPSubnetFormData) => request.post<IPSubnetItem>('/DC-Control/v1/ip-subnets', data)
|
||||
|
||||
/** 更新IP子网 */
|
||||
export const updateIPSubnet = (id: number, data: Partial<IPSubnetFormData>) =>
|
||||
request.put<{ message: string }>(`/DC-Control/v1/ip-subnets/${id}`, data)
|
||||
|
||||
/** 删除IP子网 */
|
||||
export const deleteIPSubnet = (id: number) => request.delete<{ message: string }>(`/DC-Control/v1/ip-subnets/${id}`)
|
||||
|
||||
/** ========== DHCP租约 API ========== */
|
||||
|
||||
/** 获取DHCP租约列表 */
|
||||
export const fetchDHCPLeaseList = (params?: DHCPLeaseListParams) =>
|
||||
request.get<DHCPLeaseListResponse>('/DC-Control/v1/ipam/dhcp-leases', { params })
|
||||
|
||||
/** 创建DHCP租约 */
|
||||
export const createDHCPLease = (data: DHCPLeaseFormData) => request.post<DHCPLeaseItem>('/DC-Control/v1/ipam/dhcp-leases', data)
|
||||
|
||||
/** ========== IP冲突 API ========== */
|
||||
|
||||
/** 获取IP冲突列表 */
|
||||
export const fetchIPConflictList = (params?: IPConflictListParams) =>
|
||||
request.get<IPConflictListResponse>('/DC-Control/v1/ipam/conflicts', { params })
|
||||
|
||||
/** 创建IP冲突记录 */
|
||||
export const createIPConflict = (data: IPConflictFormData) => request.post<IPConflictItem>('/DC-Control/v1/ipam/conflicts', data)
|
||||
|
||||
/** ========== IP变更 API ========== */
|
||||
|
||||
/** 获取IP变更列表 */
|
||||
export const fetchIPChangeList = (params?: IPChangeListParams) =>
|
||||
request.get<IPChangeListResponse>('/DC-Control/v1/ipam/changes', { params })
|
||||
|
||||
/** 创建IP变更记录 */
|
||||
export const createIPChange = (data: IPChangeFormData) => request.post<IPChangeItem>('/DC-Control/v1/ipam/changes', data)
|
||||
|
||||
/** ========== IP异常 API ========== */
|
||||
|
||||
/** 获取IP异常列表 */
|
||||
export const fetchIPAnomalyList = (params?: IPAnomalyListParams) =>
|
||||
request.get<IPAnomalyListResponse>('/DC-Control/v1/ipam/anomalies', { params })
|
||||
|
||||
/** 创建IP异常记录 */
|
||||
export const createIPAnomaly = (data: IPAnomalyFormData) => request.post<IPAnomalyItem>('/DC-Control/v1/ipam/anomalies', data)
|
||||
282
src/views/ops/pages/netarch/ip/components/AnomaliesTab.vue
Normal file
282
src/views/ops/pages/netarch/ip/components/AnomaliesTab.vue
Normal file
@@ -0,0 +1,282 @@
|
||||
<template>
|
||||
<div class="anomalies-tab">
|
||||
<search-table
|
||||
:form-model="formModel"
|
||||
:form-items="formItems"
|
||||
:data="tableData"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
title="IP异常记录"
|
||||
search-button-text="查询"
|
||||
reset-button-text="重置"
|
||||
@update:form-model="handleFormModelUpdate"
|
||||
@search="handleSearch"
|
||||
@reset="handleReset"
|
||||
@page-change="handlePageChange"
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #anomaly_type="{ record }">
|
||||
<a-tag :color="getAnomalyTypeColor(record.anomaly_type)" bordered>
|
||||
{{ getAnomalyTypeText(record.anomaly_type) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template #status="{ record }">
|
||||
<a-tag :color="record.status === 'resolved' ? 'green' : 'orange'" bordered>
|
||||
{{ record.status === 'resolved' ? '已处理' : '待处理' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template #detected_at="{ record }">
|
||||
{{ formatDateTime(record.detected_at) }}
|
||||
</template>
|
||||
|
||||
<template #actions="{ record }">
|
||||
<a-button type="text" size="small" @click="handleDetail(record)">详情</a-button>
|
||||
</template>
|
||||
</search-table>
|
||||
|
||||
<a-modal v-model:visible="showDetailDialog" title="异常详情" :width="700" :footer="false">
|
||||
<a-descriptions :column="2" bordered>
|
||||
<a-descriptions-item label="IP地址">{{ currentDetail?.ip_address }}</a-descriptions-item>
|
||||
<a-descriptions-item label="所属子网">{{ currentDetail?.subnet_name }}</a-descriptions-item>
|
||||
<a-descriptions-item label="异常类型">
|
||||
<a-tag :color="getAnomalyTypeColor(currentDetail?.anomaly_type)" bordered>
|
||||
{{ getAnomalyTypeText(currentDetail?.anomaly_type) }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="currentDetail?.status === 'resolved' ? 'green' : 'orange'" bordered>
|
||||
{{ currentDetail?.status === 'resolved' ? '已处理' : '待处理' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="发现时间">{{ formatDateTime(currentDetail?.detected_at) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="备注">{{ currentDetail?.remark || '-' }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<div v-if="currentDetail?.detail_json" class="detail-section">
|
||||
<div class="detail-title">详细信息</div>
|
||||
<pre class="detail-content">{{ formatJson(currentDetail?.detail_json) }}</pre>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import SearchTable from '@/components/search-table/index.vue'
|
||||
import {
|
||||
fetchIPAnomalyList,
|
||||
fetchIPSubnetList,
|
||||
type IPAnomalyItem,
|
||||
type IPAnomalyListParams,
|
||||
type IPSubnetItem,
|
||||
} from '@/api/ops/ipam'
|
||||
import type { FormItem } from '@/components/search-form/types'
|
||||
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref<IPAnomalyItem[]>([])
|
||||
const subnets = ref<IPSubnetItem[]>([])
|
||||
const showDetailDialog = ref(false)
|
||||
const currentDetail = ref<IPAnomalyItem | null>(null)
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
const formModel = reactive({
|
||||
keyword: '',
|
||||
subnet_id: '',
|
||||
anomaly_type: '',
|
||||
status: '',
|
||||
})
|
||||
|
||||
const formItems = computed<FormItem[]>(() => [
|
||||
{
|
||||
field: 'keyword',
|
||||
label: '关键词',
|
||||
type: 'input',
|
||||
placeholder: '请输入IP地址',
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
field: 'subnet_id',
|
||||
label: '子网',
|
||||
type: 'select',
|
||||
placeholder: '全部子网',
|
||||
options: subnets.value.map((s) => ({ label: `${s.name} (${s.cidr})`, value: s.id })),
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
field: 'anomaly_type',
|
||||
label: '异常类型',
|
||||
type: 'select',
|
||||
placeholder: '全部类型',
|
||||
options: [
|
||||
{ label: 'IP扫描异常', value: 'scan_anomaly' },
|
||||
{ label: 'MAC地址变更', value: 'mac_change' },
|
||||
{ label: '未知设备', value: 'unknown_device' },
|
||||
{ label: 'IP地址泄漏', value: 'ip_leak' },
|
||||
],
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
label: '状态',
|
||||
type: 'select',
|
||||
placeholder: '全部状态',
|
||||
options: [
|
||||
{ label: '待处理', value: 'pending' },
|
||||
{ label: '已处理', value: 'resolved' },
|
||||
],
|
||||
span: 6,
|
||||
},
|
||||
])
|
||||
|
||||
const columns: TableColumnData[] = [
|
||||
{ title: 'IP地址', dataIndex: 'ip_address', width: 150 },
|
||||
{ title: '异常类型', dataIndex: 'anomaly_type', slotName: 'anomaly_type', width: 120, align: 'center' },
|
||||
{ title: '所属子网', dataIndex: 'subnet_name', width: 150 },
|
||||
{ title: '发现时间', dataIndex: 'detected_at', slotName: 'detected_at', width: 180 },
|
||||
{ title: '状态', dataIndex: 'status', slotName: 'status', width: 100, align: 'center' },
|
||||
{ title: '备注', dataIndex: 'remark', width: 200 },
|
||||
{ title: '操作', dataIndex: 'actions', slotName: 'actions', width: 80, fixed: 'right' },
|
||||
]
|
||||
|
||||
const loadSubnets = async () => {
|
||||
try {
|
||||
const response = await fetchIPSubnetList({ size: 1000 })
|
||||
if (response && response.code === 0) {
|
||||
subnets.value = response.details?.data || response.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load subnets:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: IPAnomalyListParams = {
|
||||
page: pagination.current,
|
||||
size: pagination.pageSize,
|
||||
...formModel,
|
||||
}
|
||||
|
||||
Object.keys(params).forEach((key) => {
|
||||
if (!params[key as keyof IPAnomalyListParams]) {
|
||||
delete params[key as keyof IPAnomalyListParams]
|
||||
}
|
||||
})
|
||||
|
||||
const response = await fetchIPAnomalyList(params)
|
||||
if (response && response.code === 0) {
|
||||
tableData.value = response.details?.data || response.data || []
|
||||
pagination.total = response.details?.total || response.total || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load anomalies:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleFormModelUpdate = (model: typeof formModel) => {
|
||||
Object.assign(formModel, model)
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
Object.assign(formModel, {
|
||||
keyword: '',
|
||||
subnet_id: '',
|
||||
anomaly_type: '',
|
||||
status: '',
|
||||
})
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.current = page
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleDetail = (record: IPAnomalyItem) => {
|
||||
currentDetail.value = record
|
||||
showDetailDialog.value = true
|
||||
}
|
||||
|
||||
const getAnomalyTypeColor = (type?: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
scan_anomaly: 'red',
|
||||
mac_change: 'orange',
|
||||
unknown_device: 'purple',
|
||||
ip_leak: 'arcoblue',
|
||||
}
|
||||
return colorMap[type || ''] || 'gray'
|
||||
}
|
||||
|
||||
const getAnomalyTypeText = (type?: string) => {
|
||||
const textMap: Record<string, string> = {
|
||||
scan_anomaly: 'IP扫描异常',
|
||||
mac_change: 'MAC地址变更',
|
||||
unknown_device: '未知设备',
|
||||
ip_leak: 'IP地址泄漏',
|
||||
}
|
||||
return textMap[type || ''] || type || '-'
|
||||
}
|
||||
|
||||
const formatDateTime = (datetime?: string) => {
|
||||
if (!datetime) return '-'
|
||||
return new Date(datetime).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const formatJson = (jsonStr?: string) => {
|
||||
if (!jsonStr) return '{}'
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(jsonStr), null, 2)
|
||||
} catch {
|
||||
return jsonStr
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSubnets()
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.detail-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
background: var(--color-fill-1);
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
275
src/views/ops/pages/netarch/ip/components/ChangesTab.vue
Normal file
275
src/views/ops/pages/netarch/ip/components/ChangesTab.vue
Normal file
@@ -0,0 +1,275 @@
|
||||
<template>
|
||||
<div class="changes-tab">
|
||||
<search-table
|
||||
:form-model="formModel"
|
||||
:form-items="formItems"
|
||||
:data="tableData"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
title="IP变更记录"
|
||||
search-button-text="查询"
|
||||
reset-button-text="重置"
|
||||
@update:form-model="handleFormModelUpdate"
|
||||
@search="handleSearch"
|
||||
@reset="handleReset"
|
||||
@page-change="handlePageChange"
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #change_type="{ record }">
|
||||
<a-tag :color="getChangeTypeColor(record.change_type)" bordered>
|
||||
{{ getChangeTypeText(record.change_type) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template #changed_at="{ record }">
|
||||
{{ formatDateTime(record.changed_at) }}
|
||||
</template>
|
||||
|
||||
<template #actions="{ record }">
|
||||
<a-button type="text" size="small" @click="handleDetail(record)">详情</a-button>
|
||||
</template>
|
||||
</search-table>
|
||||
|
||||
<a-modal v-model:visible="showDetailDialog" title="变更详情" :width="800" :footer="false">
|
||||
<a-descriptions :column="2" bordered>
|
||||
<a-descriptions-item label="IP地址">{{ currentDetail?.ip_address }}</a-descriptions-item>
|
||||
<a-descriptions-item label="所属子网">{{ currentDetail?.subnet_name }}</a-descriptions-item>
|
||||
<a-descriptions-item label="变更类型">
|
||||
<a-tag :color="getChangeTypeColor(currentDetail?.change_type)" bordered>
|
||||
{{ getChangeTypeText(currentDetail?.change_type) }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="变更人">{{ currentDetail?.changed_by || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="变更时间" :span="2">{{ formatDateTime(currentDetail?.changed_at) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="备注" :span="2">{{ currentDetail?.remark || '-' }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<div v-if="currentDetail?.before_json || currentDetail?.after_json" class="diff-section">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<div class="diff-title">变更前</div>
|
||||
<pre class="diff-content before">{{ formatJson(currentDetail?.before_json) }}</pre>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<div class="diff-title">变更后</div>
|
||||
<pre class="diff-content after">{{ formatJson(currentDetail?.after_json) }}</pre>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import SearchTable from '@/components/search-table/index.vue'
|
||||
import {
|
||||
fetchIPChangeList,
|
||||
fetchIPSubnetList,
|
||||
type IPChangeItem,
|
||||
type IPChangeListParams,
|
||||
type IPSubnetItem,
|
||||
} from '@/api/ops/ipam'
|
||||
import type { FormItem } from '@/components/search-form/types'
|
||||
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref<IPChangeItem[]>([])
|
||||
const subnets = ref<IPSubnetItem[]>([])
|
||||
const showDetailDialog = ref(false)
|
||||
const currentDetail = ref<IPChangeItem | null>(null)
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
const formModel = reactive({
|
||||
keyword: '',
|
||||
subnet_id: '',
|
||||
change_type: '',
|
||||
})
|
||||
|
||||
const formItems = computed<FormItem[]>(() => [
|
||||
{
|
||||
field: 'keyword',
|
||||
label: '关键词',
|
||||
type: 'input',
|
||||
placeholder: '请输入IP地址',
|
||||
span: 8,
|
||||
},
|
||||
{
|
||||
field: 'subnet_id',
|
||||
label: '子网',
|
||||
type: 'select',
|
||||
placeholder: '全部子网',
|
||||
options: subnets.value.map((s) => ({ label: `${s.name} (${s.cidr})`, value: s.id })),
|
||||
span: 8,
|
||||
},
|
||||
{
|
||||
field: 'change_type',
|
||||
label: '变更类型',
|
||||
type: 'select',
|
||||
placeholder: '全部类型',
|
||||
options: [
|
||||
{ label: '分配', value: 'allocate' },
|
||||
{ label: '释放', value: 'release' },
|
||||
{ label: '修改', value: 'modify' },
|
||||
{ label: '状态变更', value: 'status_change' },
|
||||
],
|
||||
span: 8,
|
||||
},
|
||||
])
|
||||
|
||||
const columns: TableColumnData[] = [
|
||||
{ title: 'IP地址', dataIndex: 'ip_address', width: 150 },
|
||||
{ title: '变更类型', dataIndex: 'change_type', slotName: 'change_type', width: 120, align: 'center' },
|
||||
{ title: '所属子网', dataIndex: 'subnet_name', width: 150 },
|
||||
{ title: '变更人', dataIndex: 'changed_by', width: 120 },
|
||||
{ title: '变更时间', dataIndex: 'changed_at', slotName: 'changed_at', width: 180 },
|
||||
{ title: '备注', dataIndex: 'remark', width: 200 },
|
||||
{ title: '操作', dataIndex: 'actions', slotName: 'actions', width: 80, fixed: 'right' },
|
||||
]
|
||||
|
||||
const loadSubnets = async () => {
|
||||
try {
|
||||
const response = await fetchIPSubnetList({ size: 1000 })
|
||||
if (response && response.code === 0) {
|
||||
subnets.value = response.details?.data || response.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load subnets:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: IPChangeListParams = {
|
||||
page: pagination.current,
|
||||
size: pagination.pageSize,
|
||||
...formModel,
|
||||
}
|
||||
|
||||
Object.keys(params).forEach((key) => {
|
||||
if (!params[key as keyof IPChangeListParams]) {
|
||||
delete params[key as keyof IPChangeListParams]
|
||||
}
|
||||
})
|
||||
|
||||
const response = await fetchIPChangeList(params)
|
||||
if (response && response.code === 0) {
|
||||
tableData.value = response.details?.data || response.data || []
|
||||
pagination.total = response.details?.total || response.total || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load changes:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleFormModelUpdate = (model: typeof formModel) => {
|
||||
Object.assign(formModel, model)
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
Object.assign(formModel, {
|
||||
keyword: '',
|
||||
subnet_id: '',
|
||||
change_type: '',
|
||||
})
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.current = page
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleDetail = (record: IPChangeItem) => {
|
||||
currentDetail.value = record
|
||||
showDetailDialog.value = true
|
||||
}
|
||||
|
||||
const getChangeTypeColor = (type?: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
allocate: 'green',
|
||||
release: 'orange',
|
||||
modify: 'blue',
|
||||
status_change: 'purple',
|
||||
}
|
||||
return colorMap[type || ''] || 'gray'
|
||||
}
|
||||
|
||||
const getChangeTypeText = (type?: string) => {
|
||||
const textMap: Record<string, string> = {
|
||||
allocate: '分配',
|
||||
release: '释放',
|
||||
modify: '修改',
|
||||
status_change: '状态变更',
|
||||
}
|
||||
return textMap[type || ''] || type || '-'
|
||||
}
|
||||
|
||||
const formatDateTime = (datetime?: string) => {
|
||||
if (!datetime) return '-'
|
||||
return new Date(datetime).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const formatJson = (jsonStr?: string) => {
|
||||
if (!jsonStr) return '{}'
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(jsonStr), null, 2)
|
||||
} catch {
|
||||
return jsonStr
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSubnets()
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.diff-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.diff-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.diff-content {
|
||||
background: var(--color-fill-1);
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
|
||||
&.before {
|
||||
border-left: 3px solid #f53f3f;
|
||||
}
|
||||
|
||||
&.after {
|
||||
border-left: 3px solid #00b42a;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
247
src/views/ops/pages/netarch/ip/components/ConflictsTab.vue
Normal file
247
src/views/ops/pages/netarch/ip/components/ConflictsTab.vue
Normal file
@@ -0,0 +1,247 @@
|
||||
<template>
|
||||
<div class="conflicts-tab">
|
||||
<search-table
|
||||
:form-model="formModel"
|
||||
:form-items="formItems"
|
||||
:data="tableData"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
title="IP冲突记录"
|
||||
search-button-text="查询"
|
||||
reset-button-text="重置"
|
||||
@update:form-model="handleFormModelUpdate"
|
||||
@search="handleSearch"
|
||||
@reset="handleReset"
|
||||
@page-change="handlePageChange"
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #status="{ record }">
|
||||
<a-tag :color="record.status === 'resolved' ? 'green' : 'red'" bordered>
|
||||
{{ record.status === 'resolved' ? '已解决' : '未解决' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template #detected_at="{ record }">
|
||||
{{ formatDateTime(record.detected_at) }}
|
||||
</template>
|
||||
|
||||
<template #resolved_at="{ record }">
|
||||
{{ record.resolved_at ? formatDateTime(record.resolved_at) : '-' }}
|
||||
</template>
|
||||
|
||||
<template #actions="{ record }">
|
||||
<a-button type="text" size="small" @click="handleDetail(record)">详情</a-button>
|
||||
</template>
|
||||
</search-table>
|
||||
|
||||
<a-modal v-model:visible="showDetailDialog" title="冲突详情" :width="700" :footer="false">
|
||||
<a-descriptions :column="2" bordered>
|
||||
<a-descriptions-item label="IP地址">{{ currentDetail?.ip_address }}</a-descriptions-item>
|
||||
<a-descriptions-item label="所属子网">{{ currentDetail?.subnet_name }}</a-descriptions-item>
|
||||
<a-descriptions-item label="MAC地址1">{{ currentDetail?.mac_address_1 }}</a-descriptions-item>
|
||||
<a-descriptions-item label="主机名1">{{ currentDetail?.hostname_1 }}</a-descriptions-item>
|
||||
<a-descriptions-item label="MAC地址2">{{ currentDetail?.mac_address_2 }}</a-descriptions-item>
|
||||
<a-descriptions-item label="主机名2">{{ currentDetail?.hostname_2 }}</a-descriptions-item>
|
||||
<a-descriptions-item label="发现时间">{{ formatDateTime(currentDetail?.detected_at) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="解决时间">{{ currentDetail?.resolved_at ? formatDateTime(currentDetail?.resolved_at) : '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="currentDetail?.status === 'resolved' ? 'green' : 'red'" bordered>
|
||||
{{ currentDetail?.status === 'resolved' ? '已解决' : '未解决' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<div v-if="currentDetail?.evidence_json" class="evidence-section">
|
||||
<div class="evidence-title">证据信息</div>
|
||||
<pre class="evidence-content">{{ formatJson(currentDetail?.evidence_json) }}</pre>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import SearchTable from '@/components/search-table/index.vue'
|
||||
import {
|
||||
fetchIPConflictList,
|
||||
fetchIPSubnetList,
|
||||
type IPConflictItem,
|
||||
type IPConflictListParams,
|
||||
type IPSubnetItem,
|
||||
} from '@/api/ops/ipam'
|
||||
import type { FormItem } from '@/components/search-form/types'
|
||||
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref<IPConflictItem[]>([])
|
||||
const subnets = ref<IPSubnetItem[]>([])
|
||||
const showDetailDialog = ref(false)
|
||||
const currentDetail = ref<IPConflictItem | null>(null)
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
const formModel = reactive({
|
||||
keyword: '',
|
||||
subnet_id: '',
|
||||
status: '',
|
||||
})
|
||||
|
||||
const formItems = computed<FormItem[]>(() => [
|
||||
{
|
||||
field: 'keyword',
|
||||
label: '关键词',
|
||||
type: 'input',
|
||||
placeholder: '请输入IP地址',
|
||||
span: 8,
|
||||
},
|
||||
{
|
||||
field: 'subnet_id',
|
||||
label: '子网',
|
||||
type: 'select',
|
||||
placeholder: '全部子网',
|
||||
options: subnets.value.map((s) => ({ label: `${s.name} (${s.cidr})`, value: s.id })),
|
||||
span: 8,
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
label: '状态',
|
||||
type: 'select',
|
||||
placeholder: '全部状态',
|
||||
options: [
|
||||
{ label: '未解决', value: 'unresolved' },
|
||||
{ label: '已解决', value: 'resolved' },
|
||||
],
|
||||
span: 8,
|
||||
},
|
||||
])
|
||||
|
||||
const columns: TableColumnData[] = [
|
||||
{ title: 'IP地址', dataIndex: 'ip_address', width: 150 },
|
||||
{ title: 'MAC地址1', dataIndex: 'mac_address_1', width: 180 },
|
||||
{ title: '主机名1', dataIndex: 'hostname_1', width: 150 },
|
||||
{ title: 'MAC地址2', dataIndex: 'mac_address_2', width: 180 },
|
||||
{ title: '主机名2', dataIndex: 'hostname_2', width: 150 },
|
||||
{ title: '所属子网', dataIndex: 'subnet_name', width: 150 },
|
||||
{ title: '发现时间', dataIndex: 'detected_at', slotName: 'detected_at', width: 180 },
|
||||
{ title: '解决时间', dataIndex: 'resolved_at', slotName: 'resolved_at', width: 180 },
|
||||
{ title: '状态', dataIndex: 'status', slotName: 'status', width: 100, align: 'center' },
|
||||
{ title: '操作', dataIndex: 'actions', slotName: 'actions', width: 80, fixed: 'right' },
|
||||
]
|
||||
|
||||
const loadSubnets = async () => {
|
||||
try {
|
||||
const response = await fetchIPSubnetList({ size: 1000 })
|
||||
if (response && response.code === 0) {
|
||||
subnets.value = response.details?.data || response.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load subnets:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: IPConflictListParams = {
|
||||
page: pagination.current,
|
||||
size: pagination.pageSize,
|
||||
...formModel,
|
||||
}
|
||||
|
||||
Object.keys(params).forEach((key) => {
|
||||
if (!params[key as keyof IPConflictListParams]) {
|
||||
delete params[key as keyof IPConflictListParams]
|
||||
}
|
||||
})
|
||||
|
||||
const response = await fetchIPConflictList(params)
|
||||
if (response && response.code === 0) {
|
||||
tableData.value = response.details?.data || response.data || []
|
||||
pagination.total = response.details?.total || response.total || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load conflicts:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleFormModelUpdate = (model: typeof formModel) => {
|
||||
Object.assign(formModel, model)
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
Object.assign(formModel, {
|
||||
keyword: '',
|
||||
subnet_id: '',
|
||||
status: '',
|
||||
})
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.current = page
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleDetail = (record: IPConflictItem) => {
|
||||
currentDetail.value = record
|
||||
showDetailDialog.value = true
|
||||
}
|
||||
|
||||
const formatDateTime = (datetime?: string) => {
|
||||
if (!datetime) return '-'
|
||||
return new Date(datetime).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const formatJson = (jsonStr?: string) => {
|
||||
if (!jsonStr) return '{}'
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(jsonStr), null, 2)
|
||||
} catch {
|
||||
return jsonStr
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSubnets()
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.evidence-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.evidence-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.evidence-content {
|
||||
background: var(--color-fill-1);
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
192
src/views/ops/pages/netarch/ip/components/DHCPLeasesTab.vue
Normal file
192
src/views/ops/pages/netarch/ip/components/DHCPLeasesTab.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div class="dhcp-leases-tab">
|
||||
<search-table
|
||||
:form-model="formModel"
|
||||
:form-items="formItems"
|
||||
:data="tableData"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
title="DHCP租约"
|
||||
search-button-text="查询"
|
||||
reset-button-text="重置"
|
||||
@update:form-model="handleFormModelUpdate"
|
||||
@search="handleSearch"
|
||||
@reset="handleReset"
|
||||
@page-change="handlePageChange"
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #status="{ record }">
|
||||
<a-tag :color="record.status === 'active' ? 'green' : 'gray'" bordered>
|
||||
{{ record.status === 'active' ? '活跃' : '已过期' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template #lease_start="{ record }">
|
||||
{{ formatDateTime(record.lease_start) }}
|
||||
</template>
|
||||
|
||||
<template #lease_end="{ record }">
|
||||
{{ formatDateTime(record.lease_end) }}
|
||||
</template>
|
||||
|
||||
<template #actions="{ record }">
|
||||
<a-button type="text" size="small" @click="handleDetail(record)">详情</a-button>
|
||||
</template>
|
||||
</search-table>
|
||||
|
||||
<a-modal v-model:visible="showDetailDialog" title="租约详情" :width="600" :footer="false">
|
||||
<a-descriptions :data="detailData" :column="2" bordered />
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { IconPlus } from '@arco-design/web-vue/es/icon'
|
||||
import SearchTable from '@/components/search-table/index.vue'
|
||||
import { fetchDHCPLeaseList, fetchIPSubnetList, type DHCPLeaseItem, type DHCPLeaseListParams, type IPSubnetItem } from '@/api/ops/ipam'
|
||||
import type { FormItem } from '@/components/search-form/types'
|
||||
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref<DHCPLeaseItem[]>([])
|
||||
const subnets = ref<IPSubnetItem[]>([])
|
||||
const showDetailDialog = ref(false)
|
||||
const currentDetail = ref<DHCPLeaseItem | null>(null)
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
const formModel = reactive({
|
||||
keyword: '',
|
||||
subnet_id: '',
|
||||
})
|
||||
|
||||
const formItems = computed<FormItem[]>(() => [
|
||||
{
|
||||
field: 'keyword',
|
||||
label: '关键词',
|
||||
type: 'input',
|
||||
placeholder: '请输入IP地址、MAC地址',
|
||||
span: 8,
|
||||
},
|
||||
{
|
||||
field: 'subnet_id',
|
||||
label: '子网',
|
||||
type: 'select',
|
||||
placeholder: '全部子网',
|
||||
options: subnets.value.map((s) => ({ label: `${s.name} (${s.cidr})`, value: s.id })),
|
||||
span: 8,
|
||||
},
|
||||
])
|
||||
|
||||
const columns: TableColumnData[] = [
|
||||
{ title: 'IP地址', dataIndex: 'ip_address', width: 150 },
|
||||
{ title: 'MAC地址', dataIndex: 'mac_address', width: 180 },
|
||||
{ title: '主机名', dataIndex: 'hostname', width: 200 },
|
||||
{ title: '所属子网', dataIndex: 'subnet_name', width: 150 },
|
||||
{ title: 'DHCP服务器', dataIndex: 'dhcp_server', width: 150 },
|
||||
{ title: '租约开始', dataIndex: 'lease_start', slotName: 'lease_start', width: 180 },
|
||||
{ title: '租约结束', dataIndex: 'lease_end', slotName: 'lease_end', width: 180 },
|
||||
{ title: '状态', dataIndex: 'status', slotName: 'status', width: 100, align: 'center' },
|
||||
{ title: '操作', dataIndex: 'actions', slotName: 'actions', width: 100, fixed: 'right' },
|
||||
]
|
||||
|
||||
const detailData = computed(() => {
|
||||
if (!currentDetail.value) return []
|
||||
return [
|
||||
{ label: 'IP地址', value: currentDetail.value.ip_address },
|
||||
{ label: 'MAC地址', value: currentDetail.value.mac_address },
|
||||
{ label: '主机名', value: currentDetail.value.hostname },
|
||||
{ label: '所属子网', value: currentDetail.value.subnet_name },
|
||||
{ label: 'DHCP服务器', value: currentDetail.value.dhcp_server },
|
||||
{ label: '租约开始', value: formatDateTime(currentDetail.value.lease_start) },
|
||||
{ label: '租约结束', value: formatDateTime(currentDetail.value.lease_end) },
|
||||
{ label: '状态', value: currentDetail.value.status === 'active' ? '活跃' : '已过期' },
|
||||
]
|
||||
})
|
||||
|
||||
const loadSubnets = async () => {
|
||||
try {
|
||||
const response = await fetchIPSubnetList({ size: 1000 })
|
||||
if (response && response.code === 0) {
|
||||
subnets.value = response.details?.data || response.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load subnets:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: DHCPLeaseListParams = {
|
||||
page: pagination.current,
|
||||
size: pagination.pageSize,
|
||||
...formModel,
|
||||
}
|
||||
|
||||
Object.keys(params).forEach((key) => {
|
||||
if (!params[key as keyof DHCPLeaseListParams]) {
|
||||
delete params[key as keyof DHCPLeaseListParams]
|
||||
}
|
||||
})
|
||||
|
||||
const response = await fetchDHCPLeaseList(params)
|
||||
if (response && response.code === 0) {
|
||||
tableData.value = response.details?.data || response.data || []
|
||||
pagination.total = response.details?.total || response.total || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load DHCP leases:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleFormModelUpdate = (model: typeof formModel) => {
|
||||
Object.assign(formModel, model)
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
Object.assign(formModel, {
|
||||
keyword: '',
|
||||
subnet_id: '',
|
||||
})
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.current = page
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleDetail = (record: DHCPLeaseItem) => {
|
||||
currentDetail.value = record
|
||||
showDetailDialog.value = true
|
||||
}
|
||||
|
||||
const formatDateTime = (datetime: string) => {
|
||||
if (!datetime) return '-'
|
||||
return new Date(datetime).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSubnets()
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
343
src/views/ops/pages/netarch/ip/components/IPAddressListTab.vue
Normal file
343
src/views/ops/pages/netarch/ip/components/IPAddressListTab.vue
Normal file
@@ -0,0 +1,343 @@
|
||||
<template>
|
||||
<div class="ip-list-tab">
|
||||
<search-table
|
||||
:form-model="formModel"
|
||||
:form-items="formItems"
|
||||
:data="tableData"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
title="IP地址列表"
|
||||
search-button-text="查询"
|
||||
reset-button-text="重置"
|
||||
@update:form-model="handleFormModelUpdate"
|
||||
@search="handleSearch"
|
||||
@reset="handleReset"
|
||||
@page-change="handlePageChange"
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #toolbar-left>
|
||||
<a-button type="primary" @click="handleAdd">
|
||||
<template #icon>
|
||||
<icon-plus />
|
||||
</template>
|
||||
新增IP
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<template #allocation_status="{ record }">
|
||||
<a-tag :color="getAllocationStatusColor(record.allocation_status)" bordered>
|
||||
{{ getAllocationStatusText(record.allocation_status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template #status="{ record }">
|
||||
<a-tag :color="getStatusColor(record.status)" bordered>
|
||||
{{ getStatusText(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template #usage_status="{ record }">
|
||||
<a-tag :color="getUsageStatusColor(record.usage_status)" bordered>
|
||||
{{ getUsageStatusText(record.usage_status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template #source_type="{ record }">
|
||||
<a-tag :color="getSourceTypeColor(record.source_type)" bordered>
|
||||
{{ getSourceTypeText(record.source_type) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template #last_used_at="{ record }">
|
||||
{{ formatDateTime(record.last_used_at) }}
|
||||
</template>
|
||||
|
||||
<template #actions="{ record }">
|
||||
<a-space>
|
||||
<a-button type="text" size="small" @click="handleEdit(record)">编辑</a-button>
|
||||
<a-popconfirm content="确定删除该IP地址吗?" @ok="handleDelete(record)">
|
||||
<a-button type="text" size="small" status="danger">删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</search-table>
|
||||
|
||||
<IPFormDialog v-model="showFormDialog" :edit-data="currentEditData" :subnets="subnets" @success="handleFormSuccess" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { IconPlus } from '@arco-design/web-vue/es/icon'
|
||||
import SearchTable from '@/components/search-table/index.vue'
|
||||
import IPFormDialog from './IPFormDialog.vue'
|
||||
import { ipAllocationColumns } from '../config/columns'
|
||||
import {
|
||||
fetchIPAddressList,
|
||||
deleteIPAddress,
|
||||
fetchIPSubnetList,
|
||||
type IPAddressItem,
|
||||
type IPAddressListParams,
|
||||
type IPSubnetItem,
|
||||
type AllocationStatus,
|
||||
type IPStatus,
|
||||
type UsageStatus,
|
||||
type SourceType,
|
||||
} from '@/api/ops/ipam'
|
||||
import type { FormItem } from '@/components/search-form/types'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref<IPAddressItem[]>([])
|
||||
const subnets = ref<IPSubnetItem[]>([])
|
||||
const showFormDialog = ref(false)
|
||||
const currentEditData = ref<IPAddressItem | null>(null)
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
const formModel = reactive({
|
||||
keyword: '',
|
||||
subnet_id: '',
|
||||
allocation_status: '',
|
||||
status: '',
|
||||
usage_status: '',
|
||||
})
|
||||
|
||||
const formItems = computed<FormItem[]>(() => [
|
||||
{
|
||||
field: 'keyword',
|
||||
label: '关键词',
|
||||
type: 'input',
|
||||
placeholder: '请输入IP地址、主机名',
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
field: 'subnet_id',
|
||||
label: '子网',
|
||||
type: 'select',
|
||||
placeholder: '全部子网',
|
||||
options: subnets.value.map((s) => ({ label: `${s.name} (${s.cidr})`, value: s.id })),
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
field: 'allocation_status',
|
||||
label: '分配状态',
|
||||
type: 'select',
|
||||
placeholder: '全部状态',
|
||||
options: [
|
||||
{ label: '已分配', value: 'allocated' },
|
||||
{ label: '未分配', value: 'unallocated' },
|
||||
{ label: '保留', value: 'reserved' },
|
||||
],
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
label: '运行状态',
|
||||
type: 'select',
|
||||
placeholder: '全部状态',
|
||||
options: [
|
||||
{ label: '在线', value: 'online' },
|
||||
{ label: '离线', value: 'offline' },
|
||||
{ label: '未知', value: 'unknown' },
|
||||
],
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
field: 'usage_status',
|
||||
label: '使用状态',
|
||||
type: 'select',
|
||||
placeholder: '全部状态',
|
||||
options: [
|
||||
{ label: '已使用', value: 'used' },
|
||||
{ label: '未使用', value: 'unused' },
|
||||
{ label: '30天内使用', value: 'used_within_30d' },
|
||||
{ label: '30天前使用', value: 'used_before_30d' },
|
||||
],
|
||||
span: 6,
|
||||
},
|
||||
])
|
||||
|
||||
const columns = ipAllocationColumns
|
||||
|
||||
const loadSubnets = async () => {
|
||||
try {
|
||||
const response = await fetchIPSubnetList({ size: 1000 })
|
||||
if (response && response.code === 0) {
|
||||
subnets.value = response.details?.data || response.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load subnets:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: IPAddressListParams = {
|
||||
page: pagination.current,
|
||||
size: pagination.pageSize,
|
||||
...formModel,
|
||||
}
|
||||
|
||||
Object.keys(params).forEach((key) => {
|
||||
if (!params[key as keyof IPAddressListParams]) {
|
||||
delete params[key as keyof IPAddressListParams]
|
||||
}
|
||||
})
|
||||
|
||||
const response = await fetchIPAddressList(params)
|
||||
if (response && response.code === 0) {
|
||||
tableData.value = response.details?.data || response.data || []
|
||||
pagination.total = response.details?.total || response.total || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load IP addresses:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleFormModelUpdate = (model: typeof formModel) => {
|
||||
Object.assign(formModel, model)
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
Object.assign(formModel, {
|
||||
keyword: '',
|
||||
subnet_id: '',
|
||||
allocation_status: '',
|
||||
status: '',
|
||||
usage_status: '',
|
||||
})
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.current = page
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
currentEditData.value = null
|
||||
showFormDialog.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (record: IPAddressItem) => {
|
||||
currentEditData.value = record
|
||||
showFormDialog.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async (record: IPAddressItem) => {
|
||||
try {
|
||||
await deleteIPAddress(record.id)
|
||||
Message.success('删除成功')
|
||||
loadData()
|
||||
} catch (error) {
|
||||
Message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleFormSuccess = () => {
|
||||
loadData()
|
||||
}
|
||||
|
||||
const getAllocationStatusColor = (status: AllocationStatus) => {
|
||||
const colorMap: Record<AllocationStatus, string> = {
|
||||
allocated: 'blue',
|
||||
unallocated: 'gray',
|
||||
reserved: 'orange',
|
||||
}
|
||||
return colorMap[status] || 'gray'
|
||||
}
|
||||
|
||||
const getAllocationStatusText = (status: AllocationStatus) => {
|
||||
const textMap: Record<AllocationStatus, string> = {
|
||||
allocated: '已分配',
|
||||
unallocated: '未分配',
|
||||
reserved: '保留',
|
||||
}
|
||||
return textMap[status] || status
|
||||
}
|
||||
|
||||
const getStatusColor = (status: IPStatus) => {
|
||||
const colorMap: Record<IPStatus, string> = {
|
||||
online: 'green',
|
||||
offline: 'red',
|
||||
unknown: 'gray',
|
||||
}
|
||||
return colorMap[status] || 'gray'
|
||||
}
|
||||
|
||||
const getStatusText = (status: IPStatus) => {
|
||||
const textMap: Record<IPStatus, string> = {
|
||||
online: '在线',
|
||||
offline: '离线',
|
||||
unknown: '未知',
|
||||
}
|
||||
return textMap[status] || status
|
||||
}
|
||||
|
||||
const getUsageStatusColor = (status: UsageStatus) => {
|
||||
const colorMap: Record<UsageStatus, string> = {
|
||||
used: 'green',
|
||||
unused: 'gray',
|
||||
used_within_30d: 'blue',
|
||||
used_before_30d: 'orange',
|
||||
}
|
||||
return colorMap[status] || 'gray'
|
||||
}
|
||||
|
||||
const getUsageStatusText = (status: UsageStatus) => {
|
||||
const textMap: Record<UsageStatus, string> = {
|
||||
used: '已使用',
|
||||
unused: '未使用',
|
||||
used_within_30d: '30天内使用',
|
||||
used_before_30d: '30天前使用',
|
||||
}
|
||||
return textMap[status] || status
|
||||
}
|
||||
|
||||
const getSourceTypeColor = (type: SourceType) => {
|
||||
const colorMap: Record<SourceType, string> = {
|
||||
scan: 'cyan',
|
||||
dhcp: 'purple',
|
||||
manual: 'arcoblue',
|
||||
}
|
||||
return colorMap[type] || 'gray'
|
||||
}
|
||||
|
||||
const getSourceTypeText = (type: SourceType) => {
|
||||
const textMap: Record<SourceType, string> = {
|
||||
scan: '扫描',
|
||||
dhcp: 'DHCP',
|
||||
manual: '手工',
|
||||
}
|
||||
return textMap[type] || type
|
||||
}
|
||||
|
||||
const formatDateTime = (datetime: string) => {
|
||||
if (!datetime) return '-'
|
||||
return new Date(datetime).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSubnets()
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
176
src/views/ops/pages/netarch/ip/components/IPFormDialog.vue
Normal file
176
src/views/ops/pages/netarch/ip/components/IPFormDialog.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:visible="visible"
|
||||
:title="isEdit ? '编辑IP地址' : '新增IP地址'"
|
||||
:width="600"
|
||||
:mask-closable="false"
|
||||
@ok="handleOk"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="ip_address" label="IP地址">
|
||||
<a-input v-model="formData.ip_address" placeholder="例如: 10.0.1.10" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="subnet_id" label="所属子网">
|
||||
<a-select v-model="formData.subnet_id" placeholder="请选择子网" allow-clear>
|
||||
<a-option v-for="subnet in subnets" :key="subnet.id" :value="subnet.id">{{ subnet.name }} ({{ subnet.cidr }})</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="hostname" label="主机名">
|
||||
<a-input v-model="formData.hostname" placeholder="请输入主机名" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="mac_address" label="MAC地址">
|
||||
<a-input v-model="formData.mac_address" placeholder="例如: 00:50:56:AB:01:10" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="8">
|
||||
<a-form-item field="allocation_status" label="分配状态">
|
||||
<a-select v-model="formData.allocation_status" placeholder="请选择">
|
||||
<a-option value="allocated">已分配</a-option>
|
||||
<a-option value="unallocated">未分配</a-option>
|
||||
<a-option value="reserved">保留</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item field="source_type" label="来源类型">
|
||||
<a-select v-model="formData.source_type" placeholder="请选择">
|
||||
<a-option value="manual">手工</a-option>
|
||||
<a-option value="scan">扫描</a-option>
|
||||
<a-option value="dhcp">DHCP</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item field="status" label="运行状态">
|
||||
<a-select v-model="formData.status" placeholder="请选择">
|
||||
<a-option value="online">在线</a-option>
|
||||
<a-option value="offline">离线</a-option>
|
||||
<a-option value="unknown">未知</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item field="tags" label="标签">
|
||||
<a-input v-model="formData.tags" placeholder="多个标签用逗号分隔" />
|
||||
</a-form-item>
|
||||
<a-form-item field="remark" label="备注">
|
||||
<a-textarea v-model="formData.remark" placeholder="请输入备注" :max-length="200" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import type { FormInstance } from '@arco-design/web-vue/es/form'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { type IPAddressItem, type IPAddressFormData, type IPSubnetItem, createIPAddress, updateIPAddress } from '@/api/ops/ipam'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
editData?: IPAddressItem | null
|
||||
subnets?: IPSubnetItem[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'success'): void
|
||||
}>()
|
||||
|
||||
const visible = ref(props.modelValue)
|
||||
const formRef = ref<FormInstance>()
|
||||
const isEdit = ref(false)
|
||||
|
||||
const formData = reactive<IPAddressFormData>({
|
||||
ip_address: '',
|
||||
subnet_id: undefined,
|
||||
hostname: '',
|
||||
mac_address: '',
|
||||
allocation_status: 'allocated',
|
||||
source_type: 'manual',
|
||||
status: 'unknown',
|
||||
tags: '',
|
||||
remark: '',
|
||||
})
|
||||
|
||||
const rules = {
|
||||
ip_address: [{ required: true, message: '请输入IP地址' }],
|
||||
subnet_id: [{ required: true, message: '请选择所属子网' }],
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
visible.value = val
|
||||
if (val) {
|
||||
if (props.editData) {
|
||||
isEdit.value = true
|
||||
Object.assign(formData, {
|
||||
ip_address: props.editData.ip_address,
|
||||
subnet_id: props.editData.subnet_id,
|
||||
hostname: props.editData.hostname,
|
||||
mac_address: props.editData.mac_address,
|
||||
allocation_status: props.editData.allocation_status,
|
||||
source_type: props.editData.source_type,
|
||||
status: props.editData.status,
|
||||
tags: props.editData.tags,
|
||||
remark: props.editData.remark,
|
||||
})
|
||||
} else {
|
||||
isEdit.value = false
|
||||
Object.assign(formData, {
|
||||
ip_address: '',
|
||||
subnet_id: undefined,
|
||||
hostname: '',
|
||||
mac_address: '',
|
||||
allocation_status: 'allocated',
|
||||
source_type: 'manual',
|
||||
status: 'unknown',
|
||||
tags: '',
|
||||
remark: '',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(visible, (val) => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
const valid = await formRef.value?.validate()
|
||||
if (valid) return
|
||||
|
||||
if (isEdit.value && props.editData) {
|
||||
await updateIPAddress(props.editData.id, formData)
|
||||
Message.success('更新成功')
|
||||
} else {
|
||||
await createIPAddress(formData)
|
||||
Message.success('创建成功')
|
||||
}
|
||||
emit('success')
|
||||
visible.value = false
|
||||
} catch (error) {
|
||||
Message.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
332
src/views/ops/pages/netarch/ip/components/OverviewTab.vue
Normal file
332
src/views/ops/pages/netarch/ip/components/OverviewTab.vue
Normal file
@@ -0,0 +1,332 @@
|
||||
<template>
|
||||
<div class="overview-tab">
|
||||
<a-row :gutter="16" class="stats-row">
|
||||
<a-col :xs="24" :sm="12" :lg="6">
|
||||
<a-card class="stats-card" :bordered="false" :loading="loading">
|
||||
<div class="stats-content">
|
||||
<div class="stats-icon stats-icon-primary">
|
||||
<icon-apps />
|
||||
</div>
|
||||
<div class="stats-info">
|
||||
<div class="stats-title">总IP数</div>
|
||||
<div class="stats-value">{{ formatNumber(overview?.total_ip || 0) }}</div>
|
||||
<div class="stats-desc">可分配地址</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :lg="6">
|
||||
<a-card class="stats-card" :bordered="false" :loading="loading">
|
||||
<div class="stats-content">
|
||||
<div class="stats-icon stats-icon-cyan">
|
||||
<icon-storage />
|
||||
</div>
|
||||
<div class="stats-info">
|
||||
<div class="stats-title">已分配</div>
|
||||
<div class="stats-value">{{ formatNumber(overview?.allocated || 0) }}</div>
|
||||
<div class="stats-desc">使用率 {{ getPercent(overview?.allocated, overview?.total_ip) }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :lg="6">
|
||||
<a-card class="stats-card" :bordered="false" :loading="loading">
|
||||
<div class="stats-content">
|
||||
<div class="stats-icon stats-icon-blue">
|
||||
<icon-check-circle />
|
||||
</div>
|
||||
<div class="stats-info">
|
||||
<div class="stats-title">已使用</div>
|
||||
<div class="stats-value">{{ formatNumber(overview?.used || 0) }}</div>
|
||||
<div class="stats-desc">30天内使用 {{ overview?.used_within_30d || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :lg="6">
|
||||
<a-card class="stats-card" :bordered="false" :loading="loading">
|
||||
<div class="stats-content">
|
||||
<div class="stats-icon stats-icon-warning">
|
||||
<icon-exclamation-circle />
|
||||
</div>
|
||||
<div class="stats-info">
|
||||
<div class="stats-title">冲突告警</div>
|
||||
<div class="stats-value">{{ overview?.conflict_unresolved || 0 }}</div>
|
||||
<div class="stats-desc">未解决冲突</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16" class="mt-6">
|
||||
<a-col :span="24">
|
||||
<a-card :bordered="false" :loading="loading">
|
||||
<template #title>
|
||||
<div class="card-title-row">
|
||||
<span class="card-title">子网使用率 TOP10</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="overview?.subnet_usage_top10?.length" class="chart-container">
|
||||
<div v-for="subnet in overview.subnet_usage_top10" :key="subnet.subnet_id" class="subnet-usage-item">
|
||||
<div class="subnet-info">
|
||||
<span class="subnet-name">{{ subnet.name }}</span>
|
||||
<span class="subnet-cidr">{{ subnet.cidr }}</span>
|
||||
</div>
|
||||
<div class="subnet-usage-bar">
|
||||
<a-progress :percent="subnet.usage_percent" :stroke-width="12" :color="getUsageColor(subnet.usage_percent)" />
|
||||
</div>
|
||||
<div class="subnet-usage-text">{{ subnet.used }} / {{ subnet.total }} ({{ subnet.usage_percent }}%)</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-empty v-else description="暂无数据" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16" class="mt-6">
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :bordered="false" :loading="loading">
|
||||
<template #title>
|
||||
<span class="card-title">分配状态统计</span>
|
||||
</template>
|
||||
<div class="stat-grid">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">已分配</span>
|
||||
<span class="stat-value">{{ formatNumber(overview?.allocated || 0) }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">未分配</span>
|
||||
<span class="stat-value">{{ formatNumber(overview?.unallocated || 0) }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">保留</span>
|
||||
<span class="stat-value">{{ formatNumber(overview?.reserved || 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card :bordered="false" :loading="loading">
|
||||
<template #title>
|
||||
<span class="card-title">使用状态统计</span>
|
||||
</template>
|
||||
<div class="stat-grid">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">已使用</span>
|
||||
<span class="stat-value">{{ formatNumber(overview?.used || 0) }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">未使用</span>
|
||||
<span class="stat-value">{{ formatNumber(overview?.unused || 0) }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">30天内使用</span>
|
||||
<span class="stat-value">{{ formatNumber(overview?.used_within_30d || 0) }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">30天前使用</span>
|
||||
<span class="stat-value">{{ formatNumber(overview?.used_before_30d || 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { IconApps, IconStorage, IconCheckCircle, IconExclamationCircle } from '@arco-design/web-vue/es/icon'
|
||||
import { fetchIPAMOverview, type IPAMOverview } from '@/api/ops/ipam'
|
||||
|
||||
const loading = ref(false)
|
||||
const overview = ref<IPAMOverview | null>(null)
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await fetchIPAMOverview()
|
||||
if (response && response.code === 0) {
|
||||
overview.value = response.details || response.data || response
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load overview:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
return num.toLocaleString()
|
||||
}
|
||||
|
||||
const getPercent = (used?: number, total?: number) => {
|
||||
if (!used || !total) return 0
|
||||
return Math.round((used / total) * 100)
|
||||
}
|
||||
|
||||
const getUsageColor = (percent: number) => {
|
||||
if (percent >= 80) return '#F53F3F'
|
||||
if (percent >= 70) return '#FF7D00'
|
||||
return '#165DFF'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.stats-row {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
.stats-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stats-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
|
||||
&-primary {
|
||||
background: rgba(22, 93, 255, 0.1);
|
||||
color: #165dff;
|
||||
}
|
||||
|
||||
&-cyan {
|
||||
background: rgba(20, 201, 201, 0.1);
|
||||
color: #14c9c9;
|
||||
}
|
||||
|
||||
&-blue {
|
||||
background: rgba(22, 93, 255, 0.1);
|
||||
color: #165dff;
|
||||
}
|
||||
|
||||
&-warning {
|
||||
background: rgba(255, 125, 0, 0.1);
|
||||
color: #ff7d00;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stats-title {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-3);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stats-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stats-desc {
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
}
|
||||
|
||||
.mt-6 {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.card-title-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.subnet-usage-item {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr 120px;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.subnet-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.subnet-name {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.subnet-cidr {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.subnet-usage-bar {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.subnet-usage-text {
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
background: var(--color-fill-1);
|
||||
border-radius: 8px;
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
147
src/views/ops/pages/netarch/ip/components/SubnetFormDialog.vue
Normal file
147
src/views/ops/pages/netarch/ip/components/SubnetFormDialog.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:visible="visible"
|
||||
:title="isEdit ? '编辑子网' : '新增子网'"
|
||||
:width="600"
|
||||
:mask-closable="false"
|
||||
@ok="handleOk"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="cidr" label="子网CIDR">
|
||||
<a-input v-model="formData.cidr" placeholder="例如: 10.0.1.0/24" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="name" label="名称">
|
||||
<a-input v-model="formData.name" placeholder="例如: 服务器网段" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="gateway" label="网关">
|
||||
<a-input v-model="formData.gateway" placeholder="例如: 10.0.1.1" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="vlan" label="VLAN">
|
||||
<a-input v-model="formData.vlan" placeholder="例如: VLAN 10" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item field="group_id" label="所属分组">
|
||||
<a-select v-model="formData.group_id" placeholder="请选择分组" allow-clear>
|
||||
<a-option v-for="group in groups" :key="group.id" :value="group.id">
|
||||
{{ group.name }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item field="reserved_ranges_json" label="保留地址范围">
|
||||
<a-input v-model="formData.reserved_ranges_json" placeholder='例如: [{"start":"10.0.1.1","end":"10.0.1.10"}]' />
|
||||
</a-form-item>
|
||||
<a-form-item field="description" label="描述">
|
||||
<a-textarea v-model="formData.description" placeholder="请输入描述" :max-length="200" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import type { FormInstance } from '@arco-design/web-vue/es/form'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { type IPSubnetItem, type IPSubnetFormData, type IPGroupItem, createIPSubnet, updateIPSubnet } from '@/api/ops/ipam'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
editData?: IPSubnetItem | null
|
||||
groups?: IPGroupItem[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'success'): void
|
||||
}>()
|
||||
|
||||
const visible = ref(props.modelValue)
|
||||
const formRef = ref<FormInstance>()
|
||||
const isEdit = ref(false)
|
||||
|
||||
const formData = reactive<IPSubnetFormData>({
|
||||
cidr: '',
|
||||
name: '',
|
||||
gateway: '',
|
||||
vlan: '',
|
||||
group_id: undefined,
|
||||
reserved_ranges_json: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const rules = {
|
||||
cidr: [{ required: true, message: '请输入子网CIDR' }],
|
||||
name: [{ required: true, message: '请输入名称' }],
|
||||
gateway: [{ required: true, message: '请输入网关' }],
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
visible.value = val
|
||||
if (val) {
|
||||
if (props.editData) {
|
||||
isEdit.value = true
|
||||
Object.assign(formData, {
|
||||
cidr: props.editData.cidr,
|
||||
name: props.editData.name,
|
||||
gateway: props.editData.gateway,
|
||||
vlan: props.editData.vlan,
|
||||
group_id: props.editData.group_id,
|
||||
reserved_ranges_json: props.editData.reserved_ranges_json,
|
||||
description: props.editData.description,
|
||||
})
|
||||
} else {
|
||||
isEdit.value = false
|
||||
Object.assign(formData, {
|
||||
cidr: '',
|
||||
name: '',
|
||||
gateway: '',
|
||||
vlan: '',
|
||||
group_id: undefined,
|
||||
reserved_ranges_json: '',
|
||||
description: '',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(visible, (val) => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
const valid = await formRef.value?.validate()
|
||||
if (valid) return
|
||||
|
||||
if (isEdit.value && props.editData) {
|
||||
await updateIPSubnet(props.editData.id, formData)
|
||||
Message.success('更新成功')
|
||||
} else {
|
||||
await createIPSubnet(formData)
|
||||
Message.success('创建成功')
|
||||
}
|
||||
emit('success')
|
||||
visible.value = false
|
||||
} catch (error) {
|
||||
Message.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
233
src/views/ops/pages/netarch/ip/components/SubnetListTab.vue
Normal file
233
src/views/ops/pages/netarch/ip/components/SubnetListTab.vue
Normal file
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<div class="subnet-list-tab">
|
||||
<search-table
|
||||
:form-model="formModel"
|
||||
:form-items="formItems"
|
||||
:data="tableData"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
title="子网管理"
|
||||
search-button-text="查询"
|
||||
reset-button-text="重置"
|
||||
@update:form-model="handleFormModelUpdate"
|
||||
@search="handleSearch"
|
||||
@reset="handleReset"
|
||||
@page-change="handlePageChange"
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #toolbar-left>
|
||||
<a-button type="primary" @click="handleAdd">
|
||||
<template #icon>
|
||||
<icon-plus />
|
||||
</template>
|
||||
新增子网
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<template #usage="{ record }">
|
||||
<a-progress
|
||||
:percent="Math.round((record.used / record.total) * 100)"
|
||||
:stroke-width="8"
|
||||
:show-text="false"
|
||||
:color="getUsageColor((record.used / record.total) * 100)"
|
||||
/>
|
||||
<div class="usage-text">{{ record.used }} / {{ record.total }} ({{ Math.round((record.used / record.total) * 100) }}%)</div>
|
||||
</template>
|
||||
|
||||
<template #status="{ record }">
|
||||
<a-tag :color="getUsageStatusColor(record.used, record.total)" bordered>
|
||||
{{ getUsageStatusText(record.used, record.total) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template #actions="{ record }">
|
||||
<a-space>
|
||||
<a-button type="text" size="small" @click="handleEdit(record)">编辑</a-button>
|
||||
<a-popconfirm content="确定删除该子网吗?" @ok="handleDelete(record)">
|
||||
<a-button type="text" size="small" status="danger">删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</search-table>
|
||||
|
||||
<SubnetFormDialog v-model="showFormDialog" :edit-data="currentEditData" :groups="groups" @success="handleFormSuccess" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { IconPlus } from '@arco-design/web-vue/es/icon'
|
||||
import SearchTable from '@/components/search-table/index.vue'
|
||||
import SubnetFormDialog from './SubnetFormDialog.vue'
|
||||
import { subnetColumns } from '../config/columns'
|
||||
import {
|
||||
fetchIPSubnetList,
|
||||
deleteIPSubnet,
|
||||
fetchIPGroupList,
|
||||
type IPSubnetItem,
|
||||
type IPSubnetListParams,
|
||||
type IPGroupItem,
|
||||
} from '@/api/ops/ipam'
|
||||
import type { FormItem } from '@/components/search-form/types'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref<IPSubnetItem[]>([])
|
||||
const groups = ref<IPGroupItem[]>([])
|
||||
const showFormDialog = ref(false)
|
||||
const currentEditData = ref<IPSubnetItem | null>(null)
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
const formModel = reactive({
|
||||
keyword: '',
|
||||
group_id: '',
|
||||
})
|
||||
|
||||
const formItems = computed<FormItem[]>(() => [
|
||||
{
|
||||
field: 'keyword',
|
||||
label: '关键词',
|
||||
type: 'input',
|
||||
placeholder: '请输入子网CIDR、名称',
|
||||
span: 8,
|
||||
},
|
||||
{
|
||||
field: 'group_id',
|
||||
label: '分组',
|
||||
type: 'select',
|
||||
placeholder: '全部分组',
|
||||
options: groups.value.map((g) => ({ label: g.name, value: g.id })),
|
||||
span: 8,
|
||||
},
|
||||
])
|
||||
|
||||
const columns = subnetColumns
|
||||
|
||||
const loadGroups = async () => {
|
||||
try {
|
||||
const response = await fetchIPGroupList()
|
||||
if (response && response.code === 0) {
|
||||
groups.value = response.details?.data || response.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load groups:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: IPSubnetListParams = {
|
||||
page: pagination.current,
|
||||
size: pagination.pageSize,
|
||||
...formModel,
|
||||
}
|
||||
|
||||
Object.keys(params).forEach((key) => {
|
||||
if (!params[key as keyof IPSubnetListParams]) {
|
||||
delete params[key as keyof IPSubnetListParams]
|
||||
}
|
||||
})
|
||||
|
||||
const response = await fetchIPSubnetList(params)
|
||||
if (response && response.code === 0) {
|
||||
tableData.value = response.details?.data || response.data || []
|
||||
pagination.total = response.details?.total || response.total || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load subnets:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleFormModelUpdate = (model: typeof formModel) => {
|
||||
Object.assign(formModel, model)
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
Object.assign(formModel, {
|
||||
keyword: '',
|
||||
group_id: '',
|
||||
})
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.current = page
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
currentEditData.value = null
|
||||
showFormDialog.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (record: IPSubnetItem) => {
|
||||
currentEditData.value = record
|
||||
showFormDialog.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async (record: IPSubnetItem) => {
|
||||
try {
|
||||
await deleteIPSubnet(record.id)
|
||||
Message.success('删除成功')
|
||||
loadData()
|
||||
} catch (error) {
|
||||
Message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleFormSuccess = () => {
|
||||
loadData()
|
||||
}
|
||||
|
||||
const getUsageColor = (percent: number) => {
|
||||
if (percent >= 80) return '#F53F3F'
|
||||
if (percent >= 70) return '#FF7D00'
|
||||
return '#165DFF'
|
||||
}
|
||||
|
||||
const getUsageStatusColor = (used: number, total: number) => {
|
||||
const percent = (used / total) * 100
|
||||
if (percent >= 80) return 'red'
|
||||
if (percent >= 70) return 'orange'
|
||||
return 'green'
|
||||
}
|
||||
|
||||
const getUsageStatusText = (used: number, total: number) => {
|
||||
const percent = (used / total) * 100
|
||||
if (percent >= 80) return '严重'
|
||||
if (percent >= 70) return '警告'
|
||||
return '正常'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadGroups()
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.usage-text {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
325
src/views/ops/pages/netarch/ip/config/columns.ts
Normal file
325
src/views/ops/pages/netarch/ip/config/columns.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
export const subnetColumns = [
|
||||
{
|
||||
dataIndex: 'cidr',
|
||||
title: '子网',
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
dataIndex: 'name',
|
||||
title: '名称',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
dataIndex: 'vlan',
|
||||
title: 'VLAN',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
dataIndex: 'gateway',
|
||||
title: '网关',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
dataIndex: 'total',
|
||||
title: '总数',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
dataIndex: 'used',
|
||||
title: '已用',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
dataIndex: 'available',
|
||||
title: '可用',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
dataIndex: 'usage',
|
||||
title: '使用率',
|
||||
width: 160,
|
||||
slotName: 'usage',
|
||||
},
|
||||
{
|
||||
dataIndex: 'status',
|
||||
title: '状态',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
slotName: 'status',
|
||||
},
|
||||
{
|
||||
dataIndex: 'actions',
|
||||
title: '操作',
|
||||
width: 120,
|
||||
fixed: 'right' as const,
|
||||
slotName: 'actions',
|
||||
},
|
||||
]
|
||||
|
||||
export const ipAllocationColumns = [
|
||||
{
|
||||
dataIndex: 'ip_address',
|
||||
title: 'IP地址',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
dataIndex: 'hostname',
|
||||
title: '主机名',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
dataIndex: 'mac_address',
|
||||
title: 'MAC地址',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
dataIndex: 'allocation_status',
|
||||
title: '分配状态',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
slotName: 'allocation_status',
|
||||
},
|
||||
{
|
||||
dataIndex: 'status',
|
||||
title: '运行状态',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
slotName: 'status',
|
||||
},
|
||||
{
|
||||
dataIndex: 'usage_status',
|
||||
title: '使用状态',
|
||||
width: 120,
|
||||
align: 'center' as const,
|
||||
slotName: 'usage_status',
|
||||
},
|
||||
{
|
||||
dataIndex: 'source_type',
|
||||
title: '来源',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
slotName: 'source_type',
|
||||
},
|
||||
{
|
||||
dataIndex: 'last_used_at',
|
||||
title: '最后使用',
|
||||
width: 180,
|
||||
slotName: 'last_used_at',
|
||||
},
|
||||
{
|
||||
dataIndex: 'remark',
|
||||
title: '备注',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
dataIndex: 'actions',
|
||||
title: '操作',
|
||||
width: 100,
|
||||
fixed: 'right' as const,
|
||||
slotName: 'actions',
|
||||
},
|
||||
]
|
||||
|
||||
export const dhcpLeaseColumns = [
|
||||
{
|
||||
dataIndex: 'ip_address',
|
||||
title: 'IP地址',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
dataIndex: 'mac_address',
|
||||
title: 'MAC地址',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
dataIndex: 'hostname',
|
||||
title: '主机名',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
dataIndex: 'subnet_name',
|
||||
title: '所属子网',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
dataIndex: 'dhcp_server',
|
||||
title: 'DHCP服务器',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
dataIndex: 'lease_start',
|
||||
title: '租约开始',
|
||||
width: 180,
|
||||
slotName: 'lease_start',
|
||||
},
|
||||
{
|
||||
dataIndex: 'lease_end',
|
||||
title: '租约结束',
|
||||
width: 180,
|
||||
slotName: 'lease_end',
|
||||
},
|
||||
{
|
||||
dataIndex: 'status',
|
||||
title: '状态',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
slotName: 'status',
|
||||
},
|
||||
{
|
||||
dataIndex: 'actions',
|
||||
title: '操作',
|
||||
width: 80,
|
||||
fixed: 'right' as const,
|
||||
slotName: 'actions',
|
||||
},
|
||||
]
|
||||
|
||||
export const conflictColumns = [
|
||||
{
|
||||
dataIndex: 'ip_address',
|
||||
title: 'IP地址',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
dataIndex: 'mac_address_1',
|
||||
title: 'MAC地址1',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
dataIndex: 'hostname_1',
|
||||
title: '主机名1',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
dataIndex: 'mac_address_2',
|
||||
title: 'MAC地址2',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
dataIndex: 'hostname_2',
|
||||
title: '主机名2',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
dataIndex: 'subnet_name',
|
||||
title: '所属子网',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
dataIndex: 'detected_at',
|
||||
title: '发现时间',
|
||||
width: 180,
|
||||
slotName: 'detected_at',
|
||||
},
|
||||
{
|
||||
dataIndex: 'resolved_at',
|
||||
title: '解决时间',
|
||||
width: 180,
|
||||
slotName: 'resolved_at',
|
||||
},
|
||||
{
|
||||
dataIndex: 'status',
|
||||
title: '状态',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
slotName: 'status',
|
||||
},
|
||||
{
|
||||
dataIndex: 'actions',
|
||||
title: '操作',
|
||||
width: 80,
|
||||
fixed: 'right' as const,
|
||||
slotName: 'actions',
|
||||
},
|
||||
]
|
||||
|
||||
export const changeColumns = [
|
||||
{
|
||||
dataIndex: 'ip_address',
|
||||
title: 'IP地址',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
dataIndex: 'change_type',
|
||||
title: '变更类型',
|
||||
width: 120,
|
||||
align: 'center' as const,
|
||||
slotName: 'change_type',
|
||||
},
|
||||
{
|
||||
dataIndex: 'subnet_name',
|
||||
title: '所属子网',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
dataIndex: 'changed_by',
|
||||
title: '变更人',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
dataIndex: 'changed_at',
|
||||
title: '变更时间',
|
||||
width: 180,
|
||||
slotName: 'changed_at',
|
||||
},
|
||||
{
|
||||
dataIndex: 'remark',
|
||||
title: '备注',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
dataIndex: 'actions',
|
||||
title: '操作',
|
||||
width: 80,
|
||||
fixed: 'right' as const,
|
||||
slotName: 'actions',
|
||||
},
|
||||
]
|
||||
|
||||
export const anomalyColumns = [
|
||||
{
|
||||
dataIndex: 'ip_address',
|
||||
title: 'IP地址',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
dataIndex: 'anomaly_type',
|
||||
title: '异常类型',
|
||||
width: 120,
|
||||
align: 'center' as const,
|
||||
slotName: 'anomaly_type',
|
||||
},
|
||||
{
|
||||
dataIndex: 'subnet_name',
|
||||
title: '所属子网',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
dataIndex: 'detected_at',
|
||||
title: '发现时间',
|
||||
width: 180,
|
||||
slotName: 'detected_at',
|
||||
},
|
||||
{
|
||||
dataIndex: 'status',
|
||||
title: '状态',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
slotName: 'status',
|
||||
},
|
||||
{
|
||||
dataIndex: 'remark',
|
||||
title: '备注',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
dataIndex: 'actions',
|
||||
title: '操作',
|
||||
width: 80,
|
||||
fixed: 'right' as const,
|
||||
slotName: 'actions',
|
||||
},
|
||||
]
|
||||
194
src/views/ops/pages/netarch/ip/config/search-form.ts
Normal file
194
src/views/ops/pages/netarch/ip/config/search-form.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import type { FormItem } from '@/components/search-form/types'
|
||||
|
||||
export const subnetSearchForm: FormItem[] = [
|
||||
{
|
||||
field: 'keyword',
|
||||
label: '关键词',
|
||||
type: 'input',
|
||||
placeholder: '请输入子网CIDR、名称',
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
field: 'group_id',
|
||||
label: '分组',
|
||||
type: 'select',
|
||||
placeholder: '全部分组',
|
||||
options: [],
|
||||
span: 6,
|
||||
},
|
||||
]
|
||||
|
||||
export const ipSearchForm: FormItem[] = [
|
||||
{
|
||||
field: 'keyword',
|
||||
label: '关键词',
|
||||
type: 'input',
|
||||
placeholder: '请输入IP地址、主机名',
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
field: 'subnet_id',
|
||||
label: '子网',
|
||||
type: 'select',
|
||||
placeholder: '全部子网',
|
||||
options: [],
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
field: 'allocation_status',
|
||||
label: '分配状态',
|
||||
type: 'select',
|
||||
placeholder: '全部状态',
|
||||
options: [
|
||||
{ label: '已分配', value: 'allocated' },
|
||||
{ label: '未分配', value: 'unallocated' },
|
||||
{ label: '保留', value: 'reserved' },
|
||||
],
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
label: '运行状态',
|
||||
type: 'select',
|
||||
placeholder: '全部状态',
|
||||
options: [
|
||||
{ label: '在线', value: 'online' },
|
||||
{ label: '离线', value: 'offline' },
|
||||
{ label: '未知', value: 'unknown' },
|
||||
],
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
field: 'usage_status',
|
||||
label: '使用状态',
|
||||
type: 'select',
|
||||
placeholder: '全部状态',
|
||||
options: [
|
||||
{ label: '已使用', value: 'used' },
|
||||
{ label: '未使用', value: 'unused' },
|
||||
{ label: '30天内使用', value: 'used_within_30d' },
|
||||
{ label: '30天前使用', value: 'used_before_30d' },
|
||||
],
|
||||
span: 6,
|
||||
},
|
||||
]
|
||||
|
||||
export const dhcpLeaseSearchForm: FormItem[] = [
|
||||
{
|
||||
field: 'keyword',
|
||||
label: '关键词',
|
||||
type: 'input',
|
||||
placeholder: '请输入IP地址、MAC地址',
|
||||
span: 8,
|
||||
},
|
||||
{
|
||||
field: 'subnet_id',
|
||||
label: '子网',
|
||||
type: 'select',
|
||||
placeholder: '全部子网',
|
||||
options: [],
|
||||
span: 8,
|
||||
},
|
||||
]
|
||||
|
||||
export const conflictSearchForm: FormItem[] = [
|
||||
{
|
||||
field: 'keyword',
|
||||
label: '关键词',
|
||||
type: 'input',
|
||||
placeholder: '请输入IP地址',
|
||||
span: 8,
|
||||
},
|
||||
{
|
||||
field: 'subnet_id',
|
||||
label: '子网',
|
||||
type: 'select',
|
||||
placeholder: '全部子网',
|
||||
options: [],
|
||||
span: 8,
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
label: '状态',
|
||||
type: 'select',
|
||||
placeholder: '全部状态',
|
||||
options: [
|
||||
{ label: '未解决', value: 'unresolved' },
|
||||
{ label: '已解决', value: 'resolved' },
|
||||
],
|
||||
span: 8,
|
||||
},
|
||||
]
|
||||
|
||||
export const changeSearchForm: FormItem[] = [
|
||||
{
|
||||
field: 'keyword',
|
||||
label: '关键词',
|
||||
type: 'input',
|
||||
placeholder: '请输入IP地址',
|
||||
span: 8,
|
||||
},
|
||||
{
|
||||
field: 'subnet_id',
|
||||
label: '子网',
|
||||
type: 'select',
|
||||
placeholder: '全部子网',
|
||||
options: [],
|
||||
span: 8,
|
||||
},
|
||||
{
|
||||
field: 'change_type',
|
||||
label: '变更类型',
|
||||
type: 'select',
|
||||
placeholder: '全部类型',
|
||||
options: [
|
||||
{ label: '分配', value: 'allocate' },
|
||||
{ label: '释放', value: 'release' },
|
||||
{ label: '修改', value: 'modify' },
|
||||
{ label: '状态变更', value: 'status_change' },
|
||||
],
|
||||
span: 8,
|
||||
},
|
||||
]
|
||||
|
||||
export const anomalySearchForm: FormItem[] = [
|
||||
{
|
||||
field: 'keyword',
|
||||
label: '关键词',
|
||||
type: 'input',
|
||||
placeholder: '请输入IP地址',
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
field: 'subnet_id',
|
||||
label: '子网',
|
||||
type: 'select',
|
||||
placeholder: '全部子网',
|
||||
options: [],
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
field: 'anomaly_type',
|
||||
label: '异常类型',
|
||||
type: 'select',
|
||||
placeholder: '全部类型',
|
||||
options: [
|
||||
{ label: 'IP扫描异常', value: 'scan_anomaly' },
|
||||
{ label: 'MAC地址变更', value: 'mac_change' },
|
||||
{ label: '未知设备', value: 'unknown_device' },
|
||||
{ label: 'IP地址泄漏', value: 'ip_leak' },
|
||||
],
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
label: '状态',
|
||||
type: 'select',
|
||||
placeholder: '全部状态',
|
||||
options: [
|
||||
{ label: '待处理', value: 'pending' },
|
||||
{ label: '已处理', value: 'resolved' },
|
||||
],
|
||||
span: 6,
|
||||
},
|
||||
]
|
||||
@@ -1,420 +1,42 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<!-- 页面标题 -->
|
||||
<!-- <div class="page-header">
|
||||
<div class="page-title">
|
||||
<h2>IP地址管理</h2>
|
||||
<p class="page-subtitle">管理IP地址分配,监控地址池使用情况</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<a-button type="secondary" class="mr-2">
|
||||
<template #icon><icon-download /></template>
|
||||
导出
|
||||
</a-button>
|
||||
<a-button type="primary">
|
||||
<template #icon><icon-plus /></template>
|
||||
添加子网
|
||||
</a-button>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<a-row :gutter="16" class="stats-row">
|
||||
<a-col :xs="24" :sm="12" :lg="6">
|
||||
<a-card class="stats-card" :bordered="false">
|
||||
<div class="stats-content">
|
||||
<div class="stats-icon stats-icon-primary">
|
||||
<icon-wifi />
|
||||
</div>
|
||||
<div class="stats-info">
|
||||
<div class="stats-title">IP地址池</div>
|
||||
<div class="stats-value">6</div>
|
||||
<div class="stats-desc">管理中的子网</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :lg="6">
|
||||
<a-card class="stats-card" :bordered="false">
|
||||
<div class="stats-content">
|
||||
<div class="stats-icon stats-icon-cyan">
|
||||
<icon-apps />
|
||||
</div>
|
||||
<div class="stats-info">
|
||||
<div class="stats-title">总IP数</div>
|
||||
<div class="stats-value">1,524</div>
|
||||
<div class="stats-desc">可分配地址</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :lg="6">
|
||||
<a-card class="stats-card" :bordered="false">
|
||||
<div class="stats-content">
|
||||
<div class="stats-icon stats-icon-blue">
|
||||
<icon-storage />
|
||||
</div>
|
||||
<div class="stats-info">
|
||||
<div class="stats-title">已分配</div>
|
||||
<div class="stats-value">630</div>
|
||||
<div class="stats-desc">使用率 41.3%</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :lg="6">
|
||||
<a-card class="stats-card" :bordered="false">
|
||||
<div class="stats-content">
|
||||
<div class="stats-icon stats-icon-success">
|
||||
<icon-exclamation-circle />
|
||||
</div>
|
||||
<div class="stats-info">
|
||||
<div class="stats-title">冲突告警</div>
|
||||
<div class="stats-value">0</div>
|
||||
<div class="stats-desc text-success">无IP冲突</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 地址池概览和快速查询 -->
|
||||
<a-row :gutter="16" class="mt-6 overview-row">
|
||||
<a-col :xs="24" :lg="16" class="overview-col">
|
||||
<a-card :bordered="false" class="overview-card">
|
||||
<template #title>
|
||||
<div class="card-title-row">
|
||||
<span class="card-title">地址池概览</span>
|
||||
<span class="card-subtitle">各子网使用情况可视化</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="subnet-list">
|
||||
<div
|
||||
v-for="subnet in ipSubnets"
|
||||
:key="subnet.subnet"
|
||||
:class="['subnet-item', { active: selectedSubnet === subnet.subnet }]"
|
||||
@click="selectedSubnet = selectedSubnet === subnet.subnet ? '' : subnet.subnet"
|
||||
>
|
||||
<div class="subnet-header">
|
||||
<div>
|
||||
<p class="subnet-name">{{ subnet.name }}</p>
|
||||
<p class="subnet-info">{{ subnet.subnet }} · {{ subnet.vlan }}</p>
|
||||
</div>
|
||||
<div class="subnet-usage">
|
||||
<p class="usage-value">{{ subnet.used }} / {{ subnet.total }}</p>
|
||||
<p class="usage-label">已用 / 总数</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="subnet-progress">
|
||||
<a-progress
|
||||
:percent="Math.round((subnet.used / subnet.total) * 100)"
|
||||
:stroke-width="8"
|
||||
:show-text="false"
|
||||
:color="getUsageColor((subnet.used / subnet.total) * 100)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="8" class="overview-col">
|
||||
<a-card :bordered="false" class="overview-card">
|
||||
<template #title>
|
||||
<div class="card-title-row">
|
||||
<span class="card-title">快速查询</span>
|
||||
<span class="card-subtitle">搜索IP地址或主机名</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="search-section">
|
||||
<a-input v-model="searchQuery" placeholder="输入IP或主机名..." allow-clear>
|
||||
<template #prefix><icon-search /></template>
|
||||
</a-input>
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<div class="stats-section">
|
||||
<h4 class="section-title">分配类型统计</h4>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">
|
||||
<span class="color-dot color-dot-blue"></span>
|
||||
<span>静态分配</span>
|
||||
</div>
|
||||
<span class="stat-value">245 (39%)</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">
|
||||
<span class="color-dot color-dot-cyan"></span>
|
||||
<span>DHCP分配</span>
|
||||
</div>
|
||||
<span class="stat-value">385 (61%)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<div class="stats-section">
|
||||
<h4 class="section-title">DHCP服务器</h4>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">主DHCP</span>
|
||||
<a-tag color="green" bordered>在线</a-tag>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">备DHCP</span>
|
||||
<a-tag color="green" bordered>在线</a-tag>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">租约数</span>
|
||||
<span class="stat-value">385</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">平均租期</span>
|
||||
<span class="stat-value">8小时</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 子网列表 -->
|
||||
<a-card class="mt-6" :bordered="false">
|
||||
<template #title>
|
||||
<div class="table-header">
|
||||
<span>子网列表</span>
|
||||
<a-select v-model="subnetFilter" placeholder="全部子网" style="width: 140px">
|
||||
<a-option value="">全部子网</a-option>
|
||||
<a-option value="server">服务器网段</a-option>
|
||||
<a-option value="office">办公网段</a-option>
|
||||
<a-option value="guest">访客网段</a-option>
|
||||
</a-select>
|
||||
</div>
|
||||
</template>
|
||||
<a-table
|
||||
:data="ipSubnets"
|
||||
:columns="subnetColumns"
|
||||
:pagination="false"
|
||||
row-key="subnet"
|
||||
>
|
||||
<template #usage="{ record }">
|
||||
<a-progress
|
||||
:percent="Math.round((record.used / record.total) * 100)"
|
||||
:stroke-width="8"
|
||||
:show-text="false"
|
||||
:color="getUsageColor((record.used / record.total) * 100)"
|
||||
/>
|
||||
</template>
|
||||
<template #status="{ record }">
|
||||
<a-tag
|
||||
v-if="record.usageStatus === 'warning'"
|
||||
color="orange"
|
||||
bordered
|
||||
>
|
||||
使用率高
|
||||
</a-tag>
|
||||
<a-tag v-else color="green" bordered>正常</a-tag>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- IP分配记录 -->
|
||||
<a-card class="mt-6" :bordered="false">
|
||||
<template #title>
|
||||
<div class="table-header">
|
||||
<span>IP分配记录</span>
|
||||
<a-space>
|
||||
<a-select v-model="typeFilter" placeholder="全部类型" style="width: 120px">
|
||||
<a-option value="">全部类型</a-option>
|
||||
<a-option value="static">静态</a-option>
|
||||
<a-option value="dhcp">DHCP</a-option>
|
||||
</a-select>
|
||||
<a-select v-model="statusFilter" placeholder="全部状态" style="width: 120px">
|
||||
<a-option value="">全部状态</a-option>
|
||||
<a-option value="online">在线</a-option>
|
||||
<a-option value="offline">离线</a-option>
|
||||
</a-select>
|
||||
</a-space>
|
||||
</div>
|
||||
</template>
|
||||
<a-table
|
||||
:data="ipAllocations"
|
||||
:columns="allocationColumns"
|
||||
:pagination="false"
|
||||
row-key="ip"
|
||||
>
|
||||
<template #status="{ record }">
|
||||
<a-tag :color="record.status === 'online' ? 'green' : 'red'" bordered>
|
||||
{{ record.status === 'online' ? '在线' : '离线' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
<a-tabs v-model:active-key="activeTab" type="card-gutter">
|
||||
<a-tab-pane key="overview" title="概览">
|
||||
<OverviewTab />
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="ip-list" title="IP地址列表">
|
||||
<IPAddressListTab />
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="subnets" title="子网管理">
|
||||
<SubnetListTab />
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="dhcp-leases" title="DHCP租约">
|
||||
<DHCPLeasesTab />
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="conflicts" title="冲突记录">
|
||||
<ConflictsTab />
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="changes" title="变更记录">
|
||||
<ChangesTab />
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="anomalies" title="异常记录">
|
||||
<AnomaliesTab />
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
IconWifi,
|
||||
IconApps,
|
||||
IconStorage,
|
||||
IconExclamationCircle,
|
||||
IconDownload,
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
} from '@arco-design/web-vue/es/icon'
|
||||
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
|
||||
import OverviewTab from './components/OverviewTab.vue'
|
||||
import IPAddressListTab from './components/IPAddressListTab.vue'
|
||||
import SubnetListTab from './components/SubnetListTab.vue'
|
||||
import DHCPLeasesTab from './components/DHCPLeasesTab.vue'
|
||||
import ConflictsTab from './components/ConflictsTab.vue'
|
||||
import ChangesTab from './components/ChangesTab.vue'
|
||||
import AnomaliesTab from './components/AnomaliesTab.vue'
|
||||
|
||||
// 搜索和筛选
|
||||
const searchQuery = ref('')
|
||||
const selectedSubnet = ref('')
|
||||
const subnetFilter = ref('')
|
||||
const typeFilter = ref('')
|
||||
const statusFilter = ref('')
|
||||
|
||||
// 获取使用率颜色
|
||||
const getUsageColor = (percent: number) => {
|
||||
if (percent >= 70) return '#FF7D00'
|
||||
return '#165DFF'
|
||||
}
|
||||
|
||||
// 子网数据
|
||||
const ipSubnets = ref([
|
||||
{
|
||||
subnet: '10.0.0.0/24',
|
||||
name: '核心网络',
|
||||
vlan: 'VLAN 1',
|
||||
gateway: '10.0.0.1',
|
||||
total: 254,
|
||||
used: 45,
|
||||
available: 209,
|
||||
usageStatus: 'success',
|
||||
},
|
||||
{
|
||||
subnet: '10.0.1.0/24',
|
||||
name: '服务器网段',
|
||||
vlan: 'VLAN 10',
|
||||
gateway: '10.0.1.1',
|
||||
total: 254,
|
||||
used: 186,
|
||||
available: 68,
|
||||
usageStatus: 'warning',
|
||||
},
|
||||
{
|
||||
subnet: '10.0.2.0/24',
|
||||
name: '虚拟化网段',
|
||||
vlan: 'VLAN 20',
|
||||
gateway: '10.0.2.1',
|
||||
total: 254,
|
||||
used: 124,
|
||||
available: 130,
|
||||
usageStatus: 'success',
|
||||
},
|
||||
{
|
||||
subnet: '10.0.3.0/24',
|
||||
name: '存储网段',
|
||||
vlan: 'VLAN 30',
|
||||
gateway: '10.0.3.1',
|
||||
total: 254,
|
||||
used: 32,
|
||||
available: 222,
|
||||
usageStatus: 'success',
|
||||
},
|
||||
{
|
||||
subnet: '192.168.1.0/24',
|
||||
name: '办公网络',
|
||||
vlan: 'VLAN 100',
|
||||
gateway: '192.168.1.1',
|
||||
total: 254,
|
||||
used: 198,
|
||||
available: 56,
|
||||
usageStatus: 'warning',
|
||||
},
|
||||
{
|
||||
subnet: '192.168.2.0/24',
|
||||
name: '访客网络',
|
||||
vlan: 'VLAN 200',
|
||||
gateway: '192.168.2.1',
|
||||
total: 254,
|
||||
used: 45,
|
||||
available: 209,
|
||||
usageStatus: 'success',
|
||||
},
|
||||
])
|
||||
|
||||
// IP分配数据
|
||||
const ipAllocations = ref([
|
||||
{
|
||||
ip: '10.0.1.10',
|
||||
hostname: 'Web-Server-01',
|
||||
mac: '00:50:56:AB:01:10',
|
||||
type: '静态',
|
||||
status: 'online',
|
||||
lastSeen: '在线',
|
||||
},
|
||||
{
|
||||
ip: '10.0.1.11',
|
||||
hostname: 'Web-Server-02',
|
||||
mac: '00:50:56:AB:01:11',
|
||||
type: '静态',
|
||||
status: 'online',
|
||||
lastSeen: '在线',
|
||||
},
|
||||
{
|
||||
ip: '10.0.1.50',
|
||||
hostname: 'DB-Master',
|
||||
mac: '00:50:56:AB:01:50',
|
||||
type: '静态',
|
||||
status: 'online',
|
||||
lastSeen: '在线',
|
||||
},
|
||||
{
|
||||
ip: '10.0.1.51',
|
||||
hostname: 'DB-Slave',
|
||||
mac: '00:50:56:AB:01:51',
|
||||
type: '静态',
|
||||
status: 'online',
|
||||
lastSeen: '在线',
|
||||
},
|
||||
{
|
||||
ip: '192.168.1.100',
|
||||
hostname: 'PC-Zhang',
|
||||
mac: '00:1A:2B:3C:4D:10',
|
||||
type: 'DHCP',
|
||||
status: 'online',
|
||||
lastSeen: '在线',
|
||||
},
|
||||
{
|
||||
ip: '192.168.1.101',
|
||||
hostname: 'PC-Li',
|
||||
mac: '00:1A:2B:3C:4D:11',
|
||||
type: 'DHCP',
|
||||
status: 'offline',
|
||||
lastSeen: '2小时前',
|
||||
},
|
||||
])
|
||||
|
||||
// 子网表格列
|
||||
const subnetColumns: TableColumnData[] = [
|
||||
{ title: '子网', dataIndex: 'subnet', width: 140 },
|
||||
{ title: '名称', dataIndex: 'name', width: 120 },
|
||||
{ title: 'VLAN', dataIndex: 'vlan', width: 100 },
|
||||
{ title: '网关', dataIndex: 'gateway', width: 120 },
|
||||
{ title: '总数', dataIndex: 'total', width: 80, align: 'center' },
|
||||
{ title: '已用', dataIndex: 'used', width: 80, align: 'center' },
|
||||
{ title: '可用', dataIndex: 'available', width: 80, align: 'center' },
|
||||
{ title: '使用率', dataIndex: 'usage', slotName: 'usage', width: 160 },
|
||||
{ title: '状态', dataIndex: 'status', slotName: 'status', width: 100, align: 'center' },
|
||||
]
|
||||
|
||||
// IP分配表格列
|
||||
const allocationColumns: TableColumnData[] = [
|
||||
{ title: 'IP地址', dataIndex: 'ip', width: 150 },
|
||||
{ title: '主机名', dataIndex: 'hostname', width: 200 },
|
||||
{ title: 'MAC地址', dataIndex: 'mac', width: 180 },
|
||||
{ title: '分配类型', dataIndex: 'type', width: 100, align: 'center' },
|
||||
{ title: '状态', dataIndex: 'status', slotName: 'status', width: 100, align: 'center' },
|
||||
{ title: '最后在线', dataIndex: 'lastSeen', width: 120 },
|
||||
]
|
||||
const activeTab = ref('overview')
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -428,268 +50,9 @@ export default {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.page-title {
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin: 8px 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
:deep(.arco-tabs-card-gutter) {
|
||||
.arco-tabs-content {
|
||||
padding: 16px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mr-2 {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
.stats-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stats-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
|
||||
&-primary {
|
||||
background: rgba(22, 93, 255, 0.1);
|
||||
color: #165dff;
|
||||
}
|
||||
|
||||
&-cyan {
|
||||
background: rgba(20, 201, 201, 0.1);
|
||||
color: #14c9c9;
|
||||
}
|
||||
|
||||
&-blue {
|
||||
background: rgba(22, 93, 255, 0.1);
|
||||
color: #165dff;
|
||||
}
|
||||
|
||||
&-success {
|
||||
background: rgba(0, 180, 42, 0.1);
|
||||
color: #00b42a;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stats-title {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-3);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stats-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stats-desc {
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
color: var(--color-text-3);
|
||||
|
||||
&.text-success {
|
||||
color: #00b42a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mt-6 {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.overview-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.overview-col {
|
||||
display: flex;
|
||||
|
||||
:deep(.arco-card) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.arco-card-body {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.overview-card {
|
||||
height: 100%;
|
||||
|
||||
.card-title-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
}
|
||||
|
||||
.subnet-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.subnet-item {
|
||||
padding: 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(22, 93, 255, 0.5);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: #165dff;
|
||||
background: rgba(22, 93, 255, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.subnet-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.subnet-name {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subnet-info {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.subnet-usage {
|
||||
text-align: right;
|
||||
|
||||
.usage-value {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.usage-label {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.subnet-progress {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.search-section {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
}
|
||||
|
||||
.color-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
|
||||
&-blue {
|
||||
background: #165dff;
|
||||
}
|
||||
|
||||
&-cyan {
|
||||
background: #14c9c9;
|
||||
}
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user