feat
This commit is contained in:
@@ -117,3 +117,70 @@ export const fetchSecurityMetricsLatest = (serviceIdentity: string) => {
|
||||
params: { service_identity: serviceIdentity },
|
||||
})
|
||||
}
|
||||
|
||||
/** 采集配置补丁参数 */
|
||||
export interface SecurityServicePatchData {
|
||||
collect_on?: boolean
|
||||
collect_interval?: number
|
||||
}
|
||||
|
||||
/** 采集配置补丁 */
|
||||
export const patchSecurityServiceCollect = (id: number, data: SecurityServicePatchData) => {
|
||||
return request.patch<{ code: number; message: string; details: { message: string } }>(`/DC-Control/v1/security/${id}/collect`, data)
|
||||
}
|
||||
|
||||
/** 告警聚合查询参数 */
|
||||
export interface SecurityMetricsAggregateParams {
|
||||
service_identity: string
|
||||
metric_name: string
|
||||
start_time: string
|
||||
end_time: string
|
||||
aggregation: 'avg' | 'max' | 'min' | 'sum' | 'count'
|
||||
}
|
||||
|
||||
/** 告警聚合值 */
|
||||
export interface SecurityMetricsAggregateData {
|
||||
metric_name: string
|
||||
aggregation: string
|
||||
value: number
|
||||
unit: string
|
||||
start_time: string
|
||||
end_time: string
|
||||
count: number
|
||||
}
|
||||
|
||||
/** 获取告警聚合值 */
|
||||
export const fetchSecurityMetricsAggregate = (params: SecurityMetricsAggregateParams) => {
|
||||
return request.get<{ code: number; message: string; details: SecurityMetricsAggregateData }>(
|
||||
'/DC-Control/v1/services/metrics/security/aggregate',
|
||||
{ params }
|
||||
)
|
||||
}
|
||||
|
||||
/** 安全设备类型映射 */
|
||||
export const SECURITY_TYPE_MAP: Record<string, string> = {
|
||||
firewall: '防火墙',
|
||||
waf: 'WAF',
|
||||
ids: 'IDS',
|
||||
ips: 'IPS',
|
||||
vpn: 'VPN',
|
||||
other: '其他',
|
||||
}
|
||||
|
||||
/** 安全设备类型选项 */
|
||||
export const SECURITY_TYPE_OPTIONS = [
|
||||
{ label: '防火墙', value: 'firewall' },
|
||||
{ label: 'WAF', value: 'waf' },
|
||||
{ label: 'IDS', value: 'ids' },
|
||||
{ label: 'IPS', value: 'ips' },
|
||||
{ label: 'VPN', value: 'vpn' },
|
||||
{ label: '其他', value: 'other' },
|
||||
]
|
||||
|
||||
/** 运行状态映射 */
|
||||
export const STATUS_MAP: Record<string, string> = {
|
||||
online: '在线',
|
||||
offline: '离线',
|
||||
error: '异常',
|
||||
unknown: '未知',
|
||||
}
|
||||
|
||||
@@ -148,3 +148,14 @@ export const fetchStorageMetricsLatest = (serviceIdentity: string) => {
|
||||
params: { service_identity: serviceIdentity },
|
||||
})
|
||||
}
|
||||
|
||||
/** 采集配置补丁 */
|
||||
export interface StoragePatchData {
|
||||
collect_on?: boolean
|
||||
collect_interval?: number
|
||||
}
|
||||
|
||||
/** 采集配置补丁更新 */
|
||||
export const patchStorage = (id: number, data: StoragePatchData) => {
|
||||
return request.patch<{ message: string }>(`/DC-Control/v1/storage/${id}/collect`, data)
|
||||
}
|
||||
|
||||
202
src/views/ops/pages/dc/security/components/Detail.vue
Normal file
202
src/views/ops/pages/dc/security/components/Detail.vue
Normal file
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<div class="detail-container">
|
||||
<a-divider orientation="left">基础信息</a-divider>
|
||||
<a-descriptions :column="2" bordered>
|
||||
<a-descriptions-item label="ID">{{ record.id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="服务标识">{{ record.service_identity }}</a-descriptions-item>
|
||||
<a-descriptions-item label="服务名称">{{ record.name }}</a-descriptions-item>
|
||||
<a-descriptions-item label="设备类型">
|
||||
{{ SECURITY_TYPE_MAP[record.type] || record.type }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="OID">{{ record.oid || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="服务器标识">{{ record.server_identity || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="描述信息" :span="2">{{ record.description || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间">{{ formatTime(record.created_at) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="更新时间">{{ formatTime(record.updated_at) }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-divider orientation="left">采集配置</a-divider>
|
||||
<a-descriptions :column="2" bordered>
|
||||
<a-descriptions-item label="采集地址" :span="2">
|
||||
<a-link v-if="record.agent_config" :href="record.agent_config" target="_blank">{{ record.agent_config }}</a-link>
|
||||
<span v-else>-</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="启用周期采集">
|
||||
<a-tag :color="record.collect_on ? 'green' : 'gray'">
|
||||
{{ record.collect_on ? '已启用' : '未启用' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="采集间隔">{{ record.collect_interval }}秒</a-descriptions-item>
|
||||
<a-descriptions-item label="采集参数" :span="2">{{ record.collect_args || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="采集结果" :span="2">{{ record.collect_last_result || '-' }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-divider orientation="left">运行状态</a-divider>
|
||||
<a-descriptions :column="2" bordered>
|
||||
<a-descriptions-item label="运行状态">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ STATUS_MAP[record.status] || record.status }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="状态码">{{ record.status_code || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="状态信息" :span="2">{{ record.status_message || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="响应时间">
|
||||
{{ record.response_time ? `${record.response_time.toFixed(2)} ms` : '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="连续错误">
|
||||
<a-tag v-if="record.continuous_errors > 0" color="red">{{ record.continuous_errors }}</a-tag>
|
||||
<span v-else>0</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="最近检查">{{ formatTime(record.last_check_time) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="运行时长">{{ formatUptime(record.uptime) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="最近在线">{{ formatTime(record.last_online_time) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="最近离线">{{ formatTime(record.last_offline_time) }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<div class="action-bar">
|
||||
<a-space>
|
||||
<a-button type="primary" @click="$emit('edit')">
|
||||
<template #icon><icon-edit /></template>
|
||||
编辑
|
||||
</a-button>
|
||||
<a-button type="outline" @click="$emit('quick-config')">
|
||||
<template #icon><icon-settings /></template>
|
||||
采集配置
|
||||
</a-button>
|
||||
<a-button type="outline" @click="handleViewMetrics">
|
||||
<template #icon><icon-dashboard /></template>
|
||||
查看最新指标
|
||||
</a-button>
|
||||
<a-button type="outline" status="danger" @click="$emit('delete')">
|
||||
<template #icon><icon-delete /></template>
|
||||
删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-drawer v-model:visible="metricsVisible" :width="800" title="最新指标数据" :footer="false" unmount-on-close>
|
||||
<a-spin :loading="metricsLoading" style="width: 100%">
|
||||
<div v-if="metricsData && metricsData.metrics && metricsData.metrics.length > 0">
|
||||
<a-alert type="info" style="margin-bottom: 16px">
|
||||
<template #message>
|
||||
<div>最新采集时间: {{ formatTime(metricsData.latest_timestamp) || '-' }}</div>
|
||||
<div>指标数量: {{ metricsData.count }}</div>
|
||||
</template>
|
||||
</a-alert>
|
||||
<a-row :gutter="16">
|
||||
<a-col v-for="(item, index) in metricsData.metrics" :key="index" :span="8">
|
||||
<a-card class="metric-card" :bordered="false">
|
||||
<div class="metric-name">{{ item.metric_name }}</div>
|
||||
<div class="metric-value">{{ item.metric_value }} {{ item.metric_unit || '' }}</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
<a-empty v-else description="暂无指标数据" />
|
||||
</a-spin>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { IconEdit, IconDelete, IconSettings, IconDashboard } from '@arco-design/web-vue/es/icon'
|
||||
import {
|
||||
SECURITY_TYPE_MAP,
|
||||
STATUS_MAP,
|
||||
fetchSecurityMetricsLatest,
|
||||
type SecurityServiceItem,
|
||||
type SecurityMetricsLatestData,
|
||||
} from '@/api/ops/security'
|
||||
|
||||
interface Props {
|
||||
record: SecurityServiceItem
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
defineEmits(['edit', 'quick-config', 'delete'])
|
||||
|
||||
const metricsVisible = ref(false)
|
||||
const metricsLoading = ref(false)
|
||||
const metricsData = ref<SecurityMetricsLatestData | null>(null)
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
online: 'green',
|
||||
offline: 'gray',
|
||||
error: 'red',
|
||||
unknown: 'gray',
|
||||
}
|
||||
return colorMap[status] || 'gray'
|
||||
}
|
||||
|
||||
const handleViewMetrics = async () => {
|
||||
metricsVisible.value = true
|
||||
metricsLoading.value = true
|
||||
try {
|
||||
const response: any = await fetchSecurityMetricsLatest(props.record.service_identity)
|
||||
metricsData.value = response?.details || null
|
||||
} catch (error) {
|
||||
console.error('获取最新指标失败:', error)
|
||||
Message.error('获取最新指标失败')
|
||||
metricsData.value = null
|
||||
} finally {
|
||||
metricsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (time?: string) => {
|
||||
if (!time) return '-'
|
||||
const date = new Date(time)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
const formatUptime = (uptime: number) => {
|
||||
if (!uptime || uptime === 0) return '-'
|
||||
const days = Math.floor(uptime / 86400)
|
||||
const hours = Math.floor((uptime % 86400) / 3600)
|
||||
const minutes = Math.floor((uptime % 3600) / 60)
|
||||
if (days > 0) return `${days}天 ${hours}小时`
|
||||
if (hours > 0) return `${hours}小时 ${minutes}分钟`
|
||||
return `${minutes}分钟`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.detail-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
margin-bottom: 16px;
|
||||
|
||||
:deep(.arco-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.metric-name {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-3);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
257
src/views/ops/pages/dc/security/components/FormDialog.vue
Normal file
257
src/views/ops/pages/dc/security/components/FormDialog.vue
Normal file
@@ -0,0 +1,257 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:visible="visible"
|
||||
:title="isEdit ? '编辑安全设备服务' : '新增安全设备服务'"
|
||||
@ok="handleOk"
|
||||
@cancel="handleCancel"
|
||||
@update:visible="handleUpdateVisible"
|
||||
:confirm-loading="confirmLoading"
|
||||
width="800px"
|
||||
>
|
||||
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical">
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="name" label="服务名称">
|
||||
<a-input v-model="formData.name" placeholder="请输入服务名称" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="type" label="设备类型">
|
||||
<a-select v-model="formData.type" placeholder="请选择设备类型">
|
||||
<a-option v-for="item in SECURITY_TYPE_OPTIONS" :key="item.value" :value="item.value">
|
||||
{{ item.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="server_identity" label="服务器标识">
|
||||
<a-input v-model="formData.server_identity" placeholder="请输入服务器标识" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="oid" label="OID">
|
||||
<a-input v-model="formData.oid" placeholder="请输入OID" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="interval" label="检查间隔(秒)">
|
||||
<a-input-number v-model="formData.interval" placeholder="默认60秒" :min="10" :max="3600" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item field="description" label="描述信息">
|
||||
<a-textarea v-model="formData.description" placeholder="请输入描述信息" :rows="2" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item field="agent_config" label="采集地址(HTTP/HTTPS GET)">
|
||||
<a-input v-model="formData.agent_config" placeholder="周期采集地址,返回 JSON 格式指标数组" />
|
||||
</a-form-item>
|
||||
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="enabled" label="启用监控">
|
||||
<a-switch v-model="formData.enabled" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="collect_on" label="启用周期采集">
|
||||
<a-switch v-model="formData.collect_on" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item v-if="formData.collect_on" field="collect_interval" label="采集间隔(秒)">
|
||||
<a-input-number v-model="formData.collect_interval" placeholder="默认60秒" :min="10" :max="3600" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="collect_args" label="采集参数">
|
||||
<a-input v-model="formData.collect_args" placeholder="JSON 格式采集参数" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item field="extra" label="扩展配置(JSON)">
|
||||
<a-textarea v-model="formData.extra" placeholder="请输入 JSON 格式扩展配置" :rows="2" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item field="policy_ids" label="告警策略">
|
||||
<a-select v-model="formData.policy_ids" placeholder="请选择告警策略" multiple allow-clear>
|
||||
<a-option v-for="policy in policyOptions" :key="policy.id" :value="policy.id">
|
||||
{{ policy.name }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed, watch, onMounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import type { FormInstance } from '@arco-design/web-vue'
|
||||
import { createSecurityService, updateSecurityService, SECURITY_TYPE_OPTIONS, type SecurityServiceFormData } from '@/api/ops/security'
|
||||
import { fetchPolicyOptions, type PolicyOptionItem } from '@/api/ops/alertPolicy'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
record?: any
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
record: () => ({}),
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'success'])
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const confirmLoading = ref(false)
|
||||
const policyOptions = ref<PolicyOptionItem[]>([])
|
||||
|
||||
const isEdit = computed(() => !!props.record?.id)
|
||||
|
||||
const formData = reactive({
|
||||
service_identity: '',
|
||||
name: '',
|
||||
type: '',
|
||||
server_identity: '',
|
||||
oid: '',
|
||||
interval: 60,
|
||||
description: '',
|
||||
agent_config: '',
|
||||
enabled: true,
|
||||
collect_on: true,
|
||||
collect_interval: 60,
|
||||
collect_args: '',
|
||||
extra: '',
|
||||
policy_ids: [] as number[],
|
||||
})
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入服务名称' }],
|
||||
type: [{ required: true, message: '请选择设备类型' }],
|
||||
}
|
||||
|
||||
const loadPolicyOptions = async () => {
|
||||
try {
|
||||
const response: any = await fetchPolicyOptions({ enabled: true })
|
||||
if (Array.isArray(response)) {
|
||||
policyOptions.value = response
|
||||
} else if (response && response.details) {
|
||||
policyOptions.value = Array.isArray(response.details) ? response.details : response.details.data || []
|
||||
} else {
|
||||
policyOptions.value = []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载告警策略列表失败:', error)
|
||||
policyOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val) {
|
||||
if (isEdit.value && props.record) {
|
||||
Object.assign(formData, {
|
||||
service_identity: props.record.service_identity || '',
|
||||
name: props.record.name || '',
|
||||
type: props.record.type || '',
|
||||
server_identity: props.record.server_identity || '',
|
||||
oid: props.record.oid || '',
|
||||
interval: props.record.interval || 60,
|
||||
description: props.record.description || '',
|
||||
agent_config: props.record.agent_config || '',
|
||||
enabled: props.record.enabled ?? true,
|
||||
collect_on: props.record.collect_on ?? true,
|
||||
collect_interval: props.record.collect_interval || 60,
|
||||
collect_args: props.record.collect_args || '',
|
||||
extra: props.record.extra || '',
|
||||
policy_ids: props.record.policy_ids || [],
|
||||
})
|
||||
} else {
|
||||
Object.assign(formData, {
|
||||
service_identity: '',
|
||||
name: '',
|
||||
type: '',
|
||||
server_identity: '',
|
||||
oid: '',
|
||||
interval: 60,
|
||||
description: '',
|
||||
agent_config: '',
|
||||
enabled: true,
|
||||
collect_on: true,
|
||||
collect_interval: 60,
|
||||
collect_args: '',
|
||||
extra: '',
|
||||
policy_ids: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
|
||||
confirmLoading.value = true
|
||||
|
||||
const submitData: SecurityServiceFormData = {
|
||||
service_identity: formData.service_identity,
|
||||
name: formData.name,
|
||||
type: formData.type,
|
||||
server_identity: formData.server_identity,
|
||||
oid: formData.oid,
|
||||
interval: formData.interval,
|
||||
description: formData.description,
|
||||
agent_config: formData.agent_config,
|
||||
enabled: formData.enabled,
|
||||
collect_on: formData.collect_on,
|
||||
collect_interval: formData.collect_interval,
|
||||
collect_args: formData.collect_args,
|
||||
extra: formData.extra,
|
||||
policy_ids: formData.policy_ids,
|
||||
}
|
||||
|
||||
if (isEdit.value) {
|
||||
await updateSecurityService(props.record.id, submitData)
|
||||
Message.success('更新成功')
|
||||
} else {
|
||||
await createSecurityService(submitData)
|
||||
Message.success('创建成功')
|
||||
}
|
||||
|
||||
emit('success')
|
||||
handleCancel()
|
||||
} catch (error) {
|
||||
console.error('操作失败:', error)
|
||||
Message.error('操作失败')
|
||||
} finally {
|
||||
confirmLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateVisible = (value: boolean) => {
|
||||
emit('update:visible', value)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('update:visible', false)
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPolicyOptions()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:visible="visible"
|
||||
title="采集配置"
|
||||
@ok="handleOk"
|
||||
@cancel="handleCancel"
|
||||
@update:visible="handleUpdateVisible"
|
||||
:confirm-loading="confirmLoading"
|
||||
width="500px"
|
||||
>
|
||||
<a-form ref="formRef" :model="formData" layout="vertical">
|
||||
<a-form-item field="collect_on" label="启用周期采集">
|
||||
<a-switch v-model="formData.collect_on" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="formData.collect_on" field="collect_interval" label="采集间隔(秒)">
|
||||
<a-input-number v-model="formData.collect_interval" placeholder="默认60秒" :min="10" :max="3600" style="width: 100%" />
|
||||
</a-form-item>
|
||||
|
||||
<a-alert type="warning" style="margin-top: 16px">快速修改采集配置,无需编辑完整表单</a-alert>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import type { FormInstance } from '@arco-design/web-vue'
|
||||
import { patchSecurityServiceCollect, type SecurityServicePatchData } from '@/api/ops/security'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
record?: any
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
record: () => ({}),
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'success'])
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const confirmLoading = ref(false)
|
||||
|
||||
const formData = reactive({
|
||||
collect_on: true,
|
||||
collect_interval: 60,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val && props.record) {
|
||||
formData.collect_on = props.record.collect_on ?? true
|
||||
formData.collect_interval = props.record.collect_interval || 60
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
confirmLoading.value = true
|
||||
|
||||
const patchData: SecurityServicePatchData = {
|
||||
collect_on: formData.collect_on,
|
||||
collect_interval: formData.collect_interval,
|
||||
}
|
||||
|
||||
await patchSecurityServiceCollect(props.record.id, patchData)
|
||||
Message.success('采集配置已更新')
|
||||
emit('success')
|
||||
handleCancel()
|
||||
} catch (error) {
|
||||
console.error('更新采集配置失败:', error)
|
||||
Message.error('更新失败')
|
||||
} finally {
|
||||
confirmLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateVisible = (value: boolean) => {
|
||||
emit('update:visible', value)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
</script>
|
||||
86
src/views/ops/pages/dc/security/config/columns.ts
Normal file
86
src/views/ops/pages/dc/security/config/columns.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { SECURITY_TYPE_MAP, STATUS_MAP } from '@/api/ops/security'
|
||||
|
||||
export const columns = [
|
||||
{
|
||||
dataIndex: 'id',
|
||||
title: 'ID',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
dataIndex: 'name',
|
||||
title: '服务名称',
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
tooltip: true,
|
||||
},
|
||||
{
|
||||
dataIndex: 'service_identity',
|
||||
title: '服务标识',
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
tooltip: true,
|
||||
},
|
||||
{
|
||||
dataIndex: 'type',
|
||||
title: '设备类型',
|
||||
width: 100,
|
||||
render: ({ record }: any) => {
|
||||
return SECURITY_TYPE_MAP[record.type] || record.type
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: 'server_identity',
|
||||
title: '服务器标识',
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
tooltip: true,
|
||||
},
|
||||
{
|
||||
dataIndex: 'enabled',
|
||||
title: '启用状态',
|
||||
width: 100,
|
||||
slotName: 'enabled',
|
||||
},
|
||||
{
|
||||
dataIndex: 'collect_on',
|
||||
title: '采集状态',
|
||||
width: 100,
|
||||
slotName: 'collectOn',
|
||||
},
|
||||
{
|
||||
dataIndex: 'collect_interval',
|
||||
title: '采集间隔(秒)',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
dataIndex: 'status',
|
||||
title: '运行状态',
|
||||
width: 100,
|
||||
slotName: 'status',
|
||||
},
|
||||
{
|
||||
dataIndex: 'response_time',
|
||||
title: '响应时间',
|
||||
width: 120,
|
||||
slotName: 'responseTime',
|
||||
},
|
||||
{
|
||||
dataIndex: 'last_check_time',
|
||||
title: '最近检查',
|
||||
width: 180,
|
||||
slotName: 'lastCheckTime',
|
||||
},
|
||||
{
|
||||
dataIndex: 'continuous_errors',
|
||||
title: '连续错误',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
dataIndex: 'actions',
|
||||
title: '操作',
|
||||
width: 180,
|
||||
fixed: 'right' as const,
|
||||
slotName: 'actions',
|
||||
},
|
||||
]
|
||||
31
src/views/ops/pages/dc/security/config/search-form.ts
Normal file
31
src/views/ops/pages/dc/security/config/search-form.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { FormItem } from '@/components/search-form/types'
|
||||
import { SECURITY_TYPE_OPTIONS } from '@/api/ops/security'
|
||||
|
||||
export const searchFormConfig: FormItem[] = [
|
||||
{
|
||||
field: 'keyword',
|
||||
label: '关键词',
|
||||
type: 'input',
|
||||
placeholder: '名称/标识模糊搜索',
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
field: 'enabled',
|
||||
label: '启用状态',
|
||||
type: 'select',
|
||||
placeholder: '请选择启用状态',
|
||||
options: [
|
||||
{ label: '已启用', value: true },
|
||||
{ label: '已禁用', value: false },
|
||||
],
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
field: 'type',
|
||||
label: '设备类型',
|
||||
type: 'select',
|
||||
placeholder: '请选择设备类型',
|
||||
options: SECURITY_TYPE_OPTIONS,
|
||||
span: 6,
|
||||
},
|
||||
]
|
||||
305
src/views/ops/pages/dc/security/index.vue
Normal file
305
src/views/ops/pages/dc/security/index.vue
Normal file
@@ -0,0 +1,305 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<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 #enabled="{ record }">
|
||||
<a-tag :color="record.enabled ? 'green' : 'gray'">
|
||||
{{ record.enabled ? '已启用' : '已禁用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template #collectOn="{ record }">
|
||||
<a-tag :color="record.collect_on ? 'green' : 'gray'">
|
||||
{{ record.collect_on ? '已启用' : '未启用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template #status="{ record }">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ STATUS_MAP[record.status] || '未知' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template #responseTime="{ record }">
|
||||
<span v-if="record.response_time">{{ record.response_time.toFixed(2) }} ms</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
|
||||
<template #lastCheckTime="{ record }">
|
||||
<span>{{ formatTime(record.last_check_time) }}</span>
|
||||
</template>
|
||||
|
||||
<template #actions="{ record }">
|
||||
<a-space>
|
||||
<a-dropdown trigger="hover">
|
||||
<a-button type="primary" size="small">
|
||||
管理
|
||||
<icon-down />
|
||||
</a-button>
|
||||
<template #content>
|
||||
<a-doption @click="handleDetail(record)">
|
||||
<template #icon>
|
||||
<icon-eye />
|
||||
</template>
|
||||
详情
|
||||
</a-doption>
|
||||
<a-doption @click="handleEdit(record)">
|
||||
<template #icon>
|
||||
<icon-edit />
|
||||
</template>
|
||||
编辑
|
||||
</a-doption>
|
||||
<a-doption @click="handleQuickConfig(record)">
|
||||
<template #icon>
|
||||
<icon-settings />
|
||||
</template>
|
||||
采集配置
|
||||
</a-doption>
|
||||
<a-doption @click="handleDelete(record)" style="color: rgb(var(--danger-6))">
|
||||
<template #icon>
|
||||
<icon-delete />
|
||||
</template>
|
||||
删除
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-space>
|
||||
</template>
|
||||
</search-table>
|
||||
|
||||
<FormDialog v-model:visible="formDialogVisible" :record="currentRecord" @success="handleFormSuccess" />
|
||||
|
||||
<QuickConfigDialog v-model:visible="quickConfigVisible" :record="currentRecord" @success="handleFormSuccess" />
|
||||
|
||||
<a-drawer v-model:visible="detailVisible" :width="800" title="安全设备服务详情" :footer="false" unmount-on-close>
|
||||
<Detail
|
||||
v-if="currentRecord"
|
||||
:record="currentRecord"
|
||||
@edit="handleDetailEdit"
|
||||
@quick-config="handleDetailQuickConfig"
|
||||
@delete="handleDetailDelete"
|
||||
/>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import { IconPlus, IconDown, IconEdit, IconDelete, IconEye, IconSettings } from '@arco-design/web-vue/es/icon'
|
||||
import type { FormItem } from '@/components/search-form/types'
|
||||
import SearchTable from '@/components/search-table/index.vue'
|
||||
import { searchFormConfig } from './config/search-form'
|
||||
import FormDialog from './components/FormDialog.vue'
|
||||
import QuickConfigDialog from './components/QuickConfigDialog.vue'
|
||||
import Detail from './components/Detail.vue'
|
||||
import { columns as columnsConfig } from './config/columns'
|
||||
import {
|
||||
fetchSecurityServiceList,
|
||||
deleteSecurityService,
|
||||
STATUS_MAP,
|
||||
type SecurityServiceItem,
|
||||
type SecurityServiceListParams,
|
||||
} from '@/api/ops/security'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref<SecurityServiceItem[]>([])
|
||||
const formDialogVisible = ref(false)
|
||||
const quickConfigVisible = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
const currentRecord = ref<SecurityServiceItem | null>(null)
|
||||
const formModel = ref({
|
||||
keyword: '',
|
||||
enabled: undefined as boolean | undefined,
|
||||
type: undefined as string | undefined,
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
const formItems = computed<FormItem[]>(() => searchFormConfig)
|
||||
const columns = computed(() => columnsConfig)
|
||||
|
||||
const fetchSecurityServiceData = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const params: SecurityServiceListParams = {
|
||||
page: pagination.current,
|
||||
size: pagination.pageSize,
|
||||
keyword: formModel.value.keyword,
|
||||
enabled: formModel.value.enabled,
|
||||
}
|
||||
|
||||
if (formModel.value.type) {
|
||||
;(params as any).type = formModel.value.type
|
||||
}
|
||||
|
||||
const response: any = await fetchSecurityServiceList(params)
|
||||
|
||||
if (response && response.details) {
|
||||
tableData.value = response.details?.data || []
|
||||
pagination.total = response.details?.total || 0
|
||||
} else {
|
||||
tableData.value = []
|
||||
pagination.total = 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取安全设备服务列表失败:', error)
|
||||
Message.error('获取安全设备服务列表失败')
|
||||
tableData.value = []
|
||||
pagination.total = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1
|
||||
fetchSecurityServiceData()
|
||||
}
|
||||
|
||||
const handleFormModelUpdate = (value: any) => {
|
||||
formModel.value = value
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
formModel.value = {
|
||||
keyword: '',
|
||||
enabled: undefined,
|
||||
type: undefined,
|
||||
}
|
||||
pagination.current = 1
|
||||
fetchSecurityServiceData()
|
||||
}
|
||||
|
||||
const handlePageChange = (current: number) => {
|
||||
pagination.current = current
|
||||
fetchSecurityServiceData()
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchSecurityServiceData()
|
||||
Message.success('数据已刷新')
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
currentRecord.value = null
|
||||
formDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleQuickConfig = (record: SecurityServiceItem) => {
|
||||
currentRecord.value = record
|
||||
quickConfigVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (record: SecurityServiceItem) => {
|
||||
currentRecord.value = record
|
||||
formDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDetail = (record: SecurityServiceItem) => {
|
||||
currentRecord.value = record
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
const handleDetailEdit = () => {
|
||||
detailVisible.value = false
|
||||
formDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDetailQuickConfig = () => {
|
||||
detailVisible.value = false
|
||||
quickConfigVisible.value = true
|
||||
}
|
||||
|
||||
const handleDetailDelete = () => {
|
||||
detailVisible.value = false
|
||||
if (currentRecord.value) {
|
||||
handleDelete(currentRecord.value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFormSuccess = () => {
|
||||
fetchSecurityServiceData()
|
||||
}
|
||||
|
||||
const handleDelete = (record: SecurityServiceItem) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确认删除安全设备服务 "${record.name}" 吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteSecurityService(record.id)
|
||||
Message.success('删除成功')
|
||||
fetchSecurityServiceData()
|
||||
} catch (error) {
|
||||
console.error('删除安全设备服务失败:', error)
|
||||
Message.error('删除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
online: 'green',
|
||||
offline: 'gray',
|
||||
error: 'red',
|
||||
unknown: 'gray',
|
||||
}
|
||||
return colorMap[status] || 'gray'
|
||||
}
|
||||
|
||||
const formatTime = (time?: string) => {
|
||||
if (!time) return '-'
|
||||
const date = new Date(time)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
fetchSecurityServiceData()
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'SecurityServiceManagement',
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.container {
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
182
src/views/ops/pages/dc/storage/components/StorageDetail.vue
Normal file
182
src/views/ops/pages/dc/storage/components/StorageDetail.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<a-drawer
|
||||
:visible="visible"
|
||||
title="存储设备详情"
|
||||
placement="right"
|
||||
width="600px"
|
||||
@cancel="handleCancel"
|
||||
@update:visible="handleUpdateVisible"
|
||||
>
|
||||
<div class="detail-container">
|
||||
<a-spin :loading="loading" style="width: 100%">
|
||||
<a-descriptions title="基础信息" :column="2" bordered>
|
||||
<a-descriptions-item label="ID">{{ detailData?.id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="服务标识">{{ detailData?.service_identity }}</a-descriptions-item>
|
||||
<a-descriptions-item label="名称">{{ detailData?.name }}</a-descriptions-item>
|
||||
<a-descriptions-item label="类型">{{ detailData?.type }}</a-descriptions-item>
|
||||
<a-descriptions-item label="分类">{{ detailData?.category || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="OID">{{ detailData?.oid || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="服务器标识">{{ detailData?.server_identity || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="标签">{{ detailData?.tags || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="描述" :span="2">{{ detailData?.description || '-' }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-descriptions title="采集配置" :column="2" bordered style="margin-top: 20px">
|
||||
<a-descriptions-item label="Agent配置">{{ detailData?.agent_config || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="启用采集">
|
||||
<a-tag :color="detailData?.collect_on ? 'green' : 'gray'">
|
||||
{{ detailData?.collect_on ? '已启用' : '未启用' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="采集间隔">
|
||||
{{ detailData?.collect_interval ? `${detailData?.collect_interval}秒` : '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="采集参数">{{ detailData?.collect_args || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="采集结果" :span="2">{{ detailData?.collect_last_result || '-' }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-descriptions title="运行状态" :column="2" bordered style="margin-top: 20px">
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="getStatusColor(detailData?.status)">
|
||||
{{ getStatusText(detailData?.status) }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="状态码">{{ detailData?.status_code || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="状态消息" :span="2">{{ detailData?.status_message || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="响应时间">
|
||||
{{ detailData?.response_time ? `${detailData?.response_time}ms` : '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="最后检查时间">{{ formatDateTime(detailData?.last_check_time) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="最后在线时间">{{ formatDateTime(detailData?.last_online_time) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="最后离线时间">{{ formatDateTime(detailData?.last_offline_time) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="连续错误次数">{{ detailData?.continuous_errors || 0 }}</a-descriptions-item>
|
||||
<a-descriptions-item label="运行时长">{{ detailData?.uptime ? `${detailData?.uptime}秒` : '-' }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-descriptions title="最新指标" :column="2" bordered style="margin-top: 20px" v-if="metricsData?.metrics?.length">
|
||||
<a-descriptions-item label="数据时间">{{ formatDateTime(metricsData?.latest_timestamp) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="指标数量">{{ metricsData?.count || 0 }}</a-descriptions-item>
|
||||
<a-descriptions-item v-for="metric in metricsData?.metrics" :key="metric.metric_name" :label="metric.metric_name">
|
||||
{{ metric.metric_value }} {{ metric.metric_unit }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<a-empty v-else-if="!loading && !metricsData?.metrics?.length" description="暂无指标数据" style="margin-top: 20px" />
|
||||
</a-spin>
|
||||
</div>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { fetchStorageDetail, fetchStorageMetricsLatest } from '@/api/ops/storage'
|
||||
import type { StorageItem, StorageMetricsLatestResponse } from '@/api/ops/storage'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
record?: StorageItem | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
record: null,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible'])
|
||||
|
||||
const loading = ref(false)
|
||||
const detailData = ref<StorageItem | null>(null)
|
||||
const metricsData = ref<StorageMetricsLatestResponse | null>(null)
|
||||
|
||||
const getStatusColor = (status?: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
online: 'green',
|
||||
offline: 'red',
|
||||
error: 'orange',
|
||||
unknown: 'gray',
|
||||
}
|
||||
return colorMap[status || ''] || 'gray'
|
||||
}
|
||||
|
||||
const getStatusText = (status?: string) => {
|
||||
const textMap: Record<string, string> = {
|
||||
online: '在线',
|
||||
offline: '离线',
|
||||
error: '异常',
|
||||
unknown: '未知',
|
||||
}
|
||||
return textMap[status || ''] || '-'
|
||||
}
|
||||
|
||||
const formatDateTime = (dateTime: string | null | undefined) => {
|
||||
if (!dateTime || dateTime === '0001-01-01T00:00:00Z') {
|
||||
return '-'
|
||||
}
|
||||
try {
|
||||
const date = new Date(dateTime)
|
||||
if (isNaN(date.getTime())) {
|
||||
return '-'
|
||||
}
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
} catch {
|
||||
return '-'
|
||||
}
|
||||
}
|
||||
|
||||
const fetchDetail = async () => {
|
||||
if (!props.record?.id) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await fetchStorageDetail(props.record.id)
|
||||
if (res.code === 0) {
|
||||
detailData.value = res.details
|
||||
|
||||
if (detailData.value?.service_identity) {
|
||||
const metricsRes: any = await fetchStorageMetricsLatest(detailData.value.service_identity)
|
||||
if (metricsRes.code === 0) {
|
||||
metricsData.value = metricsRes.details
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Message.error(res.message || '获取详情失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取详情失败:', error)
|
||||
Message.error('获取详情失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val && props.record) {
|
||||
fetchDetail()
|
||||
} else {
|
||||
detailData.value = null
|
||||
metricsData.value = null
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleUpdateVisible = (value: boolean) => {
|
||||
emit('update:visible', value)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.detail-container {
|
||||
padding: 0 16px;
|
||||
}
|
||||
</style>
|
||||
243
src/views/ops/pages/dc/storage/components/StorageFormDialog.vue
Normal file
243
src/views/ops/pages/dc/storage/components/StorageFormDialog.vue
Normal file
@@ -0,0 +1,243 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:visible="visible"
|
||||
:title="isEdit ? '编辑存储设备' : '新增存储设备'"
|
||||
@ok="handleOk"
|
||||
@cancel="handleCancel"
|
||||
@update:visible="handleUpdateVisible"
|
||||
:confirm-loading="confirmLoading"
|
||||
width="800px"
|
||||
>
|
||||
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical">
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="name" label="名称" required>
|
||||
<a-input v-model="formData.name" placeholder="请输入存储设备名称" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="type" label="类型" required>
|
||||
<a-select v-model="formData.type" placeholder="请选择类型">
|
||||
<a-option value="nas">NAS存储</a-option>
|
||||
<a-option value="san">SAN存储</a-option>
|
||||
<a-option value="das">DAS存储</a-option>
|
||||
<a-option value="cloud">云存储</a-option>
|
||||
<a-option value="other">其他</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="category" label="分类">
|
||||
<a-input v-model="formData.category" placeholder="请输入分类" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="oid" label="OID">
|
||||
<a-input v-model="formData.oid" placeholder="请输入OID" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="server_identity" label="服务器标识">
|
||||
<a-input v-model="formData.server_identity" placeholder="请输入关联服务器标识" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="enabled" label="启用状态">
|
||||
<a-switch v-model="formData.enabled" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="interval" label="检查间隔(秒)">
|
||||
<a-input-number v-model="formData.interval" :min="10" :max="3600" placeholder="默认60秒" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="collect_on" label="启用采集">
|
||||
<a-switch v-model="formData.collect_on" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item v-if="formData.collect_on" field="collect_interval" label="采集间隔(秒)">
|
||||
<a-input-number v-model="formData.collect_interval" :min="10" :max="3600" placeholder="默认60秒" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item field="agent_config" label="Agent配置URL">
|
||||
<a-input v-model="formData.agent_config" placeholder="请输入Agent配置地址" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item field="collect_args" label="采集参数">
|
||||
<a-textarea v-model="formData.collect_args" placeholder="请输入采集参数(JSON格式)" :rows="3" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item field="tags" label="标签">
|
||||
<a-input v-model="formData.tags" placeholder="多个标签逗号分隔" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item field="extra" label="扩展信息">
|
||||
<a-textarea v-model="formData.extra" placeholder="请输入扩展信息(JSON格式)" :rows="3" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item field="description" label="描述">
|
||||
<a-textarea v-model="formData.description" placeholder="请输入描述信息" :rows="4" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import type { FormInstance } from '@arco-design/web-vue'
|
||||
import { createStorage, updateStorage } from '@/api/ops/storage'
|
||||
import type { StorageCreateData, StorageItem } from '@/api/ops/storage'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
record?: StorageItem | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
record: null,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'success'])
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const confirmLoading = ref(false)
|
||||
|
||||
const isEdit = computed(() => !!props.record?.id)
|
||||
|
||||
const formData = reactive<StorageCreateData>({
|
||||
name: '',
|
||||
type: '',
|
||||
category: '',
|
||||
oid: '',
|
||||
server_identity: '',
|
||||
description: '',
|
||||
enabled: true,
|
||||
interval: 60,
|
||||
extra: '',
|
||||
tags: '',
|
||||
agent_config: '',
|
||||
collect_on: true,
|
||||
collect_args: '',
|
||||
collect_interval: 60,
|
||||
})
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入名称' }],
|
||||
type: [{ required: true, message: '请选择类型' }],
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val) {
|
||||
if (isEdit.value && props.record) {
|
||||
Object.assign(formData, {
|
||||
name: props.record.name || '',
|
||||
type: props.record.type || '',
|
||||
category: props.record.category || '',
|
||||
oid: props.record.oid || '',
|
||||
server_identity: props.record.server_identity || '',
|
||||
description: props.record.description || '',
|
||||
enabled: props.record.enabled ?? true,
|
||||
interval: props.record.interval || 60,
|
||||
extra: props.record.extra || '',
|
||||
tags: props.record.tags || '',
|
||||
agent_config: props.record.agent_config || '',
|
||||
collect_on: props.record.collect_on ?? true,
|
||||
collect_args: props.record.collect_args || '',
|
||||
collect_interval: props.record.collect_interval || 60,
|
||||
})
|
||||
} else {
|
||||
Object.assign(formData, {
|
||||
name: '',
|
||||
type: '',
|
||||
category: '',
|
||||
oid: '',
|
||||
server_identity: '',
|
||||
description: '',
|
||||
enabled: true,
|
||||
interval: 60,
|
||||
extra: '',
|
||||
tags: '',
|
||||
agent_config: '',
|
||||
collect_on: true,
|
||||
collect_args: '',
|
||||
collect_interval: 60,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
|
||||
confirmLoading.value = true
|
||||
|
||||
const submitData: StorageCreateData = {
|
||||
name: formData.name,
|
||||
type: formData.type,
|
||||
category: formData.category,
|
||||
oid: formData.oid,
|
||||
server_identity: formData.server_identity,
|
||||
description: formData.description,
|
||||
enabled: formData.enabled,
|
||||
interval: formData.interval,
|
||||
extra: formData.extra,
|
||||
tags: formData.tags,
|
||||
agent_config: formData.agent_config,
|
||||
collect_on: formData.collect_on,
|
||||
collect_args: formData.collect_args,
|
||||
collect_interval: formData.collect_interval,
|
||||
}
|
||||
|
||||
if (isEdit.value && props.record?.id) {
|
||||
const res: any = await updateStorage(props.record.id, submitData)
|
||||
if (res.code === 0) {
|
||||
Message.success('更新成功')
|
||||
emit('success')
|
||||
handleCancel()
|
||||
} else {
|
||||
Message.error(res.message || '更新失败')
|
||||
}
|
||||
} else {
|
||||
const res: any = await createStorage(submitData)
|
||||
if (res.code === 0) {
|
||||
Message.success('创建成功')
|
||||
emit('success')
|
||||
handleCancel()
|
||||
} else {
|
||||
Message.error(res.message || '创建失败')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('验证失败:', error)
|
||||
} finally {
|
||||
confirmLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateVisible = (value: boolean) => {
|
||||
emit('update:visible', value)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('update:visible', false)
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
</script>
|
||||
74
src/views/ops/pages/dc/storage/config/columns.ts
Normal file
74
src/views/ops/pages/dc/storage/config/columns.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
export const columns = [
|
||||
{
|
||||
dataIndex: 'id',
|
||||
title: 'ID',
|
||||
width: 80,
|
||||
slotName: 'id',
|
||||
},
|
||||
{
|
||||
dataIndex: 'name',
|
||||
title: '名称',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
dataIndex: 'service_identity',
|
||||
title: '服务标识',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
dataIndex: 'type',
|
||||
title: '类型',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
dataIndex: 'server_identity',
|
||||
title: '服务器标识',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
dataIndex: 'enabled',
|
||||
title: '启用状态',
|
||||
width: 100,
|
||||
slotName: 'enabled',
|
||||
},
|
||||
{
|
||||
dataIndex: 'collect_on',
|
||||
title: '数据采集',
|
||||
width: 100,
|
||||
slotName: 'data_collection',
|
||||
},
|
||||
{
|
||||
dataIndex: 'collect_interval',
|
||||
title: '采集间隔(秒)',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
dataIndex: 'status',
|
||||
title: '运行状态',
|
||||
width: 100,
|
||||
slotName: 'status',
|
||||
},
|
||||
{
|
||||
dataIndex: 'response_time',
|
||||
title: '响应时间(ms)',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
dataIndex: 'last_check_time',
|
||||
title: '最后检查时间',
|
||||
width: 180,
|
||||
slotName: 'last_check_time',
|
||||
},
|
||||
{
|
||||
dataIndex: 'continuous_errors',
|
||||
title: '连续错误次数',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
dataIndex: 'actions',
|
||||
title: '操作',
|
||||
width: 200,
|
||||
fixed: 'right' as const,
|
||||
slotName: 'actions',
|
||||
},
|
||||
]
|
||||
22
src/views/ops/pages/dc/storage/config/search-form.ts
Normal file
22
src/views/ops/pages/dc/storage/config/search-form.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { FormItem } from '@/components/search-form/types'
|
||||
|
||||
export const searchFormConfig: FormItem[] = [
|
||||
{
|
||||
field: 'keyword',
|
||||
label: '关键词',
|
||||
type: 'input',
|
||||
placeholder: '请输入名称/标识',
|
||||
span: 8,
|
||||
},
|
||||
{
|
||||
field: 'enabled',
|
||||
label: '启用状态',
|
||||
type: 'select',
|
||||
placeholder: '请选择启用状态',
|
||||
options: [
|
||||
{ label: '启用', value: true },
|
||||
{ label: '停用', value: false },
|
||||
],
|
||||
span: 8,
|
||||
},
|
||||
]
|
||||
286
src/views/ops/pages/dc/storage/index.vue
Normal file
286
src/views/ops/pages/dc/storage/index.vue
Normal file
@@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<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 #id="{ record }">
|
||||
{{ record.id }}
|
||||
</template>
|
||||
|
||||
<template #enabled="{ record }">
|
||||
<a-tag :color="record.enabled ? 'green' : 'gray'">
|
||||
{{ record.enabled ? '启用' : '停用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template #data_collection="{ record }">
|
||||
<a-tag :color="record.collect_on ? 'green' : 'gray'">
|
||||
{{ record.collect_on ? '已启用' : '未启用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template #status="{ record }">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusText(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template #last_check_time="{ record }">
|
||||
{{ formatDateTime(record.last_check_time) }}
|
||||
</template>
|
||||
|
||||
<template #actions="{ record }">
|
||||
<a-space>
|
||||
<a-button type="outline" size="small" @click="handleDetail(record)">
|
||||
<template #icon>
|
||||
<icon-eye />
|
||||
</template>
|
||||
详情
|
||||
</a-button>
|
||||
<a-dropdown trigger="hover">
|
||||
<a-button type="primary" size="small">
|
||||
管理
|
||||
<icon-down />
|
||||
</a-button>
|
||||
<template #content>
|
||||
<a-doption @click="handleEdit(record)">
|
||||
<template #icon>
|
||||
<icon-edit />
|
||||
</template>
|
||||
编辑
|
||||
</a-doption>
|
||||
<a-doption @click="handleDelete(record)" style="color: rgb(var(--danger-6))">
|
||||
<template #icon>
|
||||
<icon-delete />
|
||||
</template>
|
||||
删除
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-space>
|
||||
</template>
|
||||
</search-table>
|
||||
|
||||
<StorageFormDialog v-model:visible="formDialogVisible" :record="currentRecord" @success="handleFormSuccess" />
|
||||
|
||||
<StorageDetail v-model:visible="detailVisible" :record="currentRecord" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import { IconPlus, IconDown, IconEdit, IconDelete, IconEye } from '@arco-design/web-vue/es/icon'
|
||||
import type { FormItem } from '@/components/search-form/types'
|
||||
import SearchTable from '@/components/search-table/index.vue'
|
||||
import { searchFormConfig } from './config/search-form'
|
||||
import StorageFormDialog from './components/StorageFormDialog.vue'
|
||||
import StorageDetail from './components/StorageDetail.vue'
|
||||
import { columns as columnsConfig } from './config/columns'
|
||||
import { fetchStorageList, deleteStorage } from '@/api/ops/storage'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref<any[]>([])
|
||||
const formDialogVisible = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
const currentRecord = ref<any>(null)
|
||||
const formModel = ref({
|
||||
keyword: '',
|
||||
enabled: undefined as boolean | undefined,
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
const formItems = computed<FormItem[]>(() => searchFormConfig)
|
||||
|
||||
const columns = computed(() => columnsConfig)
|
||||
|
||||
const getStatusColor = (status?: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
online: 'green',
|
||||
offline: 'red',
|
||||
error: 'orange',
|
||||
unknown: 'gray',
|
||||
}
|
||||
return colorMap[status || ''] || 'gray'
|
||||
}
|
||||
|
||||
const getStatusText = (status?: string) => {
|
||||
const textMap: Record<string, string> = {
|
||||
online: '在线',
|
||||
offline: '离线',
|
||||
error: '异常',
|
||||
unknown: '未知',
|
||||
}
|
||||
return textMap[status || ''] || '-'
|
||||
}
|
||||
|
||||
const formatDateTime = (dateTime: string | null | undefined) => {
|
||||
if (!dateTime || dateTime === '0001-01-01T00:00:00Z') {
|
||||
return '-'
|
||||
}
|
||||
try {
|
||||
const date = new Date(dateTime)
|
||||
if (isNaN(date.getTime())) {
|
||||
return '-'
|
||||
}
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
} catch {
|
||||
return '-'
|
||||
}
|
||||
}
|
||||
|
||||
const fetchStorages = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const params: any = {
|
||||
page: pagination.current,
|
||||
size: pagination.pageSize,
|
||||
}
|
||||
|
||||
if (formModel.value.keyword) {
|
||||
params.keyword = formModel.value.keyword
|
||||
}
|
||||
|
||||
if (formModel.value.enabled !== undefined) {
|
||||
params.enabled = formModel.value.enabled
|
||||
}
|
||||
|
||||
const res: any = await fetchStorageList(params)
|
||||
|
||||
if (res.code === 0) {
|
||||
const responseData = res.details || {}
|
||||
tableData.value = responseData.data || []
|
||||
pagination.total = responseData.total || 0
|
||||
} else {
|
||||
Message.error(res.message || '获取存储设备列表失败')
|
||||
tableData.value = []
|
||||
pagination.total = 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取存储设备列表失败:', error)
|
||||
Message.error('获取存储设备列表失败')
|
||||
tableData.value = []
|
||||
pagination.total = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1
|
||||
fetchStorages()
|
||||
}
|
||||
|
||||
const handleFormModelUpdate = (value: any) => {
|
||||
formModel.value = value
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
formModel.value = {
|
||||
keyword: '',
|
||||
enabled: undefined,
|
||||
}
|
||||
pagination.current = 1
|
||||
fetchStorages()
|
||||
}
|
||||
|
||||
const handlePageChange = (current: number) => {
|
||||
pagination.current = current
|
||||
fetchStorages()
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchStorages()
|
||||
Message.success('数据已刷新')
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
currentRecord.value = null
|
||||
formDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (record: any) => {
|
||||
currentRecord.value = record
|
||||
formDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDetail = (record: any) => {
|
||||
currentRecord.value = record
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
const handleFormSuccess = () => {
|
||||
fetchStorages()
|
||||
}
|
||||
|
||||
const handleDelete = async (record: any) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确认删除存储设备 ${record.name} 吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
const res: any = await deleteStorage(record.id)
|
||||
if (res.code === 0) {
|
||||
Message.success('删除成功')
|
||||
fetchStorages()
|
||||
} else {
|
||||
Message.error(res.message || '删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除存储设备失败:', error)
|
||||
Message.error('删除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchStorages()
|
||||
})
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'StorageDevice',
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.container {
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user