fix: harden disk monitor configuration flows
This commit is contained in:
@@ -721,10 +721,14 @@ CREATE TABLE IF NOT EXISTS `disk_monitor_notify_log` (
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"pageNum": 1,
|
"pageNum": 1,
|
||||||
"pageSize": 10
|
"pageSize": 100,
|
||||||
|
"sortField": "startedAt",
|
||||||
|
"sortOrder": "desc"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
说明:该接口默认用于“最近任务”区域,后端应按 `startedAt desc` 返回最近记录,前端再截取前 10 条进行展示。
|
||||||
|
|
||||||
### 列表字段
|
### 列表字段
|
||||||
|
|
||||||
- `jobId`
|
- `jobId`
|
||||||
|
|||||||
@@ -71,7 +71,10 @@ export namespace DiskMonitor {
|
|||||||
jobNo: string
|
jobNo: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JobListParams extends ReqPage {}
|
export interface JobListParams extends ReqPage {
|
||||||
|
sortField?: 'startedAt'
|
||||||
|
sortOrder?: 'asc' | 'desc'
|
||||||
|
}
|
||||||
|
|
||||||
export interface JobListItem {
|
export interface JobListItem {
|
||||||
id: number
|
id: number
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<el-input
|
<el-input
|
||||||
:model-value="props.modelValue.driveLetter"
|
:model-value="props.modelValue.driveLetter"
|
||||||
placeholder="例如 C:"
|
placeholder="例如 C:"
|
||||||
maxlength="10"
|
maxlength="2"
|
||||||
@update:model-value="value => patchTarget({ driveLetter: String(value).trim().toUpperCase() })"
|
@update:model-value="value => patchTarget({ driveLetter: String(value).trim().toUpperCase() })"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
<h3 class="table-title">监控目标</h3>
|
<h3 class="table-title">监控目标</h3>
|
||||||
<p class="table-description">维护需要监控的盘符与通知目标</p>
|
<p class="table-description">维护需要监控的盘符与通知目标</p>
|
||||||
</div>
|
</div>
|
||||||
<el-button type="primary" @click="emit('add')">新增目标</el-button>
|
<el-button type="primary" :disabled="props.disabled" @click="emit('add')">新增目标</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table :data="rows" border stripe>
|
<el-table :data="props.rows" border stripe>
|
||||||
<el-table-column prop="driveLetter" label="盘符" min-width="90" />
|
<el-table-column prop="driveLetter" label="盘符" min-width="90" />
|
||||||
<el-table-column label="是否监控" min-width="100">
|
<el-table-column label="是否监控" min-width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -44,8 +44,12 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="140" fixed="right">
|
<el-table-column label="操作" width="140" fixed="right">
|
||||||
<template #default="{ row, $index }">
|
<template #default="{ row, $index }">
|
||||||
<el-button link type="primary" @click="emit('edit', row, $index)">编辑</el-button>
|
<el-button link type="primary" :disabled="props.disabled" @click="emit('edit', row, $index)">
|
||||||
<el-button link type="danger" @click="emit('remove', $index)">删除</el-button>
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button link type="danger" :disabled="props.disabled" @click="emit('remove', $index)">
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -60,9 +64,15 @@ defineOptions({
|
|||||||
name: 'DiskMonitorTargetTable'
|
name: 'DiskMonitorTargetTable'
|
||||||
})
|
})
|
||||||
|
|
||||||
defineProps<{
|
const props = withDefaults(
|
||||||
rows: DiskMonitor.TargetItem[]
|
defineProps<{
|
||||||
}>()
|
rows: DiskMonitor.TargetItem[]
|
||||||
|
disabled?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
disabled: false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
add: []
|
add: []
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ const handleRemove = (index: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleTimeoutChange = (index: number, value: number | undefined) => {
|
const handleTimeoutChange = (index: number, value: number | undefined) => {
|
||||||
patchRow(index, 'timeoutMs', Number(value || 0))
|
const nextTimeout = typeof value === 'number' && value >= 100 ? value : 100
|
||||||
|
patchRow(index, 'timeoutMs', nextTimeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
const patchRow = <K extends keyof DiskMonitor.NotifyHttpTarget>(
|
const patchRow = <K extends keyof DiskMonitor.NotifyHttpTarget>(
|
||||||
|
|||||||
@@ -19,7 +19,13 @@
|
|||||||
@run="handleRun"
|
@run="handleRun"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DiskMonitorTargetTable :rows="targetList" @add="openAddTarget" @edit="openEditTarget" @remove="removeTarget" />
|
<DiskMonitorTargetTable
|
||||||
|
:rows="targetList"
|
||||||
|
:disabled="formBusy"
|
||||||
|
@add="openAddTarget"
|
||||||
|
@edit="openEditTarget"
|
||||||
|
@remove="removeTarget"
|
||||||
|
/>
|
||||||
|
|
||||||
<DiskMonitorTargetDialog
|
<DiskMonitorTargetDialog
|
||||||
v-model:visible="targetDialogVisible"
|
v-model:visible="targetDialogVisible"
|
||||||
@@ -62,6 +68,7 @@ import {
|
|||||||
normalizeTargetItem,
|
normalizeTargetItem,
|
||||||
validatePolicy,
|
validatePolicy,
|
||||||
validateTarget,
|
validateTarget,
|
||||||
|
validateTargetList,
|
||||||
validateTargetNotifications
|
validateTargetNotifications
|
||||||
} from './utils/form'
|
} from './utils/form'
|
||||||
|
|
||||||
@@ -167,15 +174,18 @@ const loadJobList = async () => {
|
|||||||
// 统一拉取最近任务列表,并按 startedAt 倒序确保摘要展示真实最新任务
|
// 统一拉取最近任务列表,并按 startedAt 倒序确保摘要展示真实最新任务
|
||||||
const response = await getDiskMonitorJobList({
|
const response = await getDiskMonitorJobList({
|
||||||
pageNum: 1,
|
pageNum: 1,
|
||||||
pageSize: 10
|
pageSize: 100,
|
||||||
|
sortField: 'startedAt',
|
||||||
|
sortOrder: 'desc'
|
||||||
})
|
})
|
||||||
const records = [...(response.data?.records || [])].sort((a, b) => {
|
const records = [...(response.data?.records || [])].sort((a, b) => {
|
||||||
const first = new Date(a.startedAt).getTime()
|
const first = new Date(a.startedAt).getTime()
|
||||||
const second = new Date(b.startedAt).getTime()
|
const second = new Date(b.startedAt).getTime()
|
||||||
return second - first
|
return second - first
|
||||||
})
|
})
|
||||||
jobList.value = records
|
// 前端保留 startedAt 的兜底排序,同时通过显式排序参数要求后端返回真正的最新任务。
|
||||||
latestJob.value = records[0] || null
|
jobList.value = records.slice(0, 10)
|
||||||
|
latestJob.value = jobList.value[0] || null
|
||||||
} finally {
|
} finally {
|
||||||
loading.jobs = false
|
loading.jobs = false
|
||||||
}
|
}
|
||||||
@@ -229,11 +239,23 @@ const handleSave = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedTargets = targetList.value.map(item => ({
|
||||||
|
...normalizeTargetItem(item),
|
||||||
|
driveLetter: item.driveLetter.trim().toUpperCase()
|
||||||
|
}))
|
||||||
|
const targetListErrorMessage = validateTargetList(normalizedTargets)
|
||||||
|
if (targetListErrorMessage) {
|
||||||
|
ElMessage.warning(targetListErrorMessage)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
loading.save = true
|
loading.save = true
|
||||||
try {
|
try {
|
||||||
|
// 整页保存前再次规范化所有盘符配置,避免历史脏数据绕过弹窗校验链路。
|
||||||
|
targetList.value = normalizedTargets
|
||||||
await saveDiskMonitorPolicy({
|
await saveDiskMonitorPolicy({
|
||||||
policy: policyForm.value,
|
policy: policyForm.value,
|
||||||
targets: targetList.value
|
targets: normalizedTargets
|
||||||
})
|
})
|
||||||
ElMessage.success('配置保存成功')
|
ElMessage.success('配置保存成功')
|
||||||
// 保存完成后重新拉取数据,避免本地状态与服务端策略偏差
|
// 保存完成后重新拉取数据,避免本地状态与服务端策略偏差
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
|
import type { DiskMonitor } from '@/api/system/diskMonitor/interface'
|
||||||
|
|
||||||
|
const DRIVE_LETTER_PATTERN = /^[A-Z]:$/
|
||||||
|
|
||||||
export const createDefaultPolicy = (): DiskMonitor.PolicyItem => ({
|
export const createDefaultPolicy = (): DiskMonitor.PolicyItem => ({
|
||||||
policyName: '默认磁盘监控策略',
|
policyName: '默认磁盘监控策略',
|
||||||
monitorEnabled: true,
|
monitorEnabled: true,
|
||||||
@@ -46,6 +48,7 @@ export const validatePolicy = (policy: DiskMonitor.PolicyItem) => {
|
|||||||
|
|
||||||
export const validateTarget = (target: DiskMonitor.TargetItem, exists: string[]) => {
|
export const validateTarget = (target: DiskMonitor.TargetItem, exists: string[]) => {
|
||||||
if (!target.driveLetter) return '盘符不能为空'
|
if (!target.driveLetter) return '盘符不能为空'
|
||||||
|
if (!DRIVE_LETTER_PATTERN.test(target.driveLetter)) return '盘符格式必须为类似 C: 的单个盘符'
|
||||||
if (exists.includes(target.driveLetter)) return '盘符不能重复'
|
if (exists.includes(target.driveLetter)) return '盘符不能重复'
|
||||||
if (target.warningUsagePercent < 1 || target.warningUsagePercent > 100) return '预警使用率必须在 1-100 之间'
|
if (target.warningUsagePercent < 1 || target.warningUsagePercent > 100) return '预警使用率必须在 1-100 之间'
|
||||||
if (target.alarmUsagePercent < 1 || target.alarmUsagePercent > 100) return '告警使用率必须在 1-100 之间'
|
if (target.alarmUsagePercent < 1 || target.alarmUsagePercent > 100) return '告警使用率必须在 1-100 之间'
|
||||||
@@ -55,16 +58,54 @@ export const validateTarget = (target: DiskMonitor.TargetItem, exists: string[])
|
|||||||
|
|
||||||
export const validateTargetNotifications = (target: DiskMonitor.TargetItem) => {
|
export const validateTargetNotifications = (target: DiskMonitor.TargetItem) => {
|
||||||
if (target.notifyPathEnabled) {
|
if (target.notifyPathEnabled) {
|
||||||
const hasInvalidPath = (target.notifyPathList || []).some(item => !item.path?.trim())
|
const enabledPathTargets = (target.notifyPathList || []).filter(item => item.enabled)
|
||||||
|
if (!enabledPathTargets.length) return '路径通知至少需要一个启用的通知目标'
|
||||||
|
|
||||||
|
const hasInvalidPath = enabledPathTargets.some(item => !item.path?.trim())
|
||||||
if (hasInvalidPath) return '路径通知目标路径不能为空'
|
if (hasInvalidPath) return '路径通知目标路径不能为空'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (target.notifyHttpEnabled) {
|
if (target.notifyHttpEnabled) {
|
||||||
const hasInvalidUrl = (target.notifyHttpList || []).some(item => {
|
const enabledHttpTargets = (target.notifyHttpList || []).filter(item => item.enabled)
|
||||||
|
if (!enabledHttpTargets.length) return 'HTTP 通知至少需要一个启用的通知目标'
|
||||||
|
|
||||||
|
const hasInvalidUrl = enabledHttpTargets.some(item => {
|
||||||
const url = item.url?.trim()
|
const url = item.url?.trim()
|
||||||
return !url || !/^https?:\/\/\S+$/i.test(url)
|
return !url || !/^https?:\/\/\S+$/i.test(url)
|
||||||
})
|
})
|
||||||
if (hasInvalidUrl) return 'HTTP 通知目标 URL 需要为有效的 HTTP/HTTPS 地址'
|
if (hasInvalidUrl) return 'HTTP 通知目标 URL 需要为有效的 HTTP/HTTPS 地址'
|
||||||
|
|
||||||
|
const hasInvalidTimeout = enabledHttpTargets.some(
|
||||||
|
item => !Number.isFinite(item.timeoutMs) || item.timeoutMs < 100
|
||||||
|
)
|
||||||
|
if (hasInvalidTimeout) return 'HTTP 通知超时时间不能小于 100 毫秒'
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export const validateTargetList = (targets: DiskMonitor.TargetItem[]) => {
|
||||||
|
for (let index = 0; index < targets.length; index += 1) {
|
||||||
|
const target = targets[index]
|
||||||
|
const normalizedTarget: DiskMonitor.TargetItem = {
|
||||||
|
...normalizeTargetItem(target),
|
||||||
|
driveLetter: target.driveLetter.trim().toUpperCase()
|
||||||
|
}
|
||||||
|
const exists = targets
|
||||||
|
.filter((_, targetIndex) => targetIndex !== index)
|
||||||
|
.map(item => item.driveLetter.trim().toUpperCase())
|
||||||
|
|
||||||
|
const targetErrorMessage = validateTarget(normalizedTarget, exists)
|
||||||
|
if (targetErrorMessage) {
|
||||||
|
const label = normalizedTarget.driveLetter || `第 ${index + 1} 个监控目标`
|
||||||
|
return `${label}:${targetErrorMessage}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifyErrorMessage = validateTargetNotifications(normalizedTarget)
|
||||||
|
if (notifyErrorMessage) {
|
||||||
|
const label = normalizedTarget.driveLetter || `第 ${index + 1} 个监控目标`
|
||||||
|
return `${label}:${notifyErrorMessage}`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ''
|
return ''
|
||||||
|
|||||||
@@ -1,9 +1,99 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>系统监控页面占位</div>
|
<div class="system-monitor-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h2 class="page-title">系统监控</h2>
|
||||||
|
<p class="page-description">从这里进入各类运行监控能力,目前已接入磁盘监控配置入口。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="monitor-grid">
|
||||||
|
<section class="monitor-card">
|
||||||
|
<div class="card-content">
|
||||||
|
<h3 class="card-title">磁盘监控</h3>
|
||||||
|
<p class="card-description">
|
||||||
|
配置多盘符监控、预警与告警阈值、启动监控、定时监控以及通知目标。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" @click="openDiskMonitor">进入配置</el-button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'SystemMonitorPage'
|
name: 'SystemMonitorPage'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const openDiskMonitor = async () => {
|
||||||
|
// 系统监控页作为模块入口,保持磁盘监控返回链路闭环。
|
||||||
|
await router.push('/systemMonitor/diskMonitor')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.system-monitor-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-description {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 180px;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-description {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user