This commit is contained in:
2026-04-05 15:47:06 +08:00
parent 1195ac2f12
commit 13dda2dc6d
9 changed files with 1109 additions and 174 deletions

145
src/api/ops/url-device.ts Normal file
View File

@@ -0,0 +1,145 @@
import { request } from '@/api/request'
export interface UrlDeviceItem {
id: number
created_at: string
updated_at: string
deleted_at: string | null
service_identity: string
name: string
description: string
target_url: string
method: string
enabled: boolean
interval: number
extra: string
tags: string
collect_on: boolean
collect_interval: number
collect_last_result: string
status: string
status_code: number
response_time: number
last_check_time: string
last_online_time: string | null
last_offline_time: string | null
continuous_errors: number
}
export interface UrlDeviceListResponse {
total: number
page: number
page_size: number
data: UrlDeviceItem[]
}
export interface UrlDeviceListParams {
page?: number
size?: number
keyword?: string
enabled?: boolean
}
export interface UrlDeviceCreateData {
service_identity?: string
name: string
description?: string
target_url: string
method?: string
enabled?: boolean
interval?: number
extra?: string
tags?: string
collect_on?: boolean
collect_interval?: number
collect_last_result?: string
}
export interface UrlDeviceUpdateData {
name?: string
description?: string
target_url?: string
method?: string
enabled?: boolean
interval?: number
extra?: string
tags?: string
collect_on?: boolean
collect_interval?: number
collect_last_result?: string
status?: string
status_code?: number
response_time?: number
last_check_time?: string
last_online_time?: string | null
last_offline_time?: string | null
continuous_errors?: number
}
export interface UrlDeviceCollectData {
collect_on?: boolean
collect_interval?: number
collect_last_result?: string
}
export const fetchUrlDeviceList = (params?: UrlDeviceListParams) => {
return request.get<UrlDeviceListResponse>('/DC-Control/v1/url-devices', { params })
}
export const fetchUrlDeviceDetail = (id: number) => {
return request.get<UrlDeviceItem>(`/DC-Control/v1/url-devices/${id}`)
}
export const createUrlDevice = (data: UrlDeviceCreateData) => {
return request.post<{ message: string; id: number }>('/DC-Control/v1/url-devices', data)
}
export const updateUrlDevice = (id: number, data: UrlDeviceUpdateData) => {
return request.put<{ message: string }>(`/DC-Control/v1/url-devices/${id}`, data)
}
export const patchUrlDeviceCollect = (id: number, data: UrlDeviceCollectData) => {
return request.patch<{ message: string }>(`/DC-Control/v1/url-devices/${id}/collect`, data)
}
export const deleteUrlDevice = (id: number) => {
return request.delete<{ message: string }>(`/DC-Control/v1/url-devices/${id}`)
}
export interface UrlDeviceStatsOverview {
total: number
online: number
today_alert_count: number
avg_response_time_ms: number
}
export interface ResponseTimePoint {
hour: string
avg_response_time_ms: number
}
export interface UrlDeviceResponseTimeTrend {
points: ResponseTimePoint[]
}
export interface StatusDistributionItem {
status: string
count: number
}
export interface UrlDeviceStatusDistribution {
total: number
by_status: StatusDistributionItem[]
}
export const fetchUrlDeviceStatsOverview = () => {
return request.get<UrlDeviceStatsOverview>('/DC-Control/v1/url-devices/stats/overview')
}
export const fetchUrlDeviceResponseTimeTrend = () => {
return request.get<UrlDeviceResponseTimeTrend>('/DC-Control/v1/url-devices/stats/response-time-trend')
}
export const fetchUrlDeviceStatusDistribution = () => {
return request.get<UrlDeviceStatusDistribution>('/DC-Control/v1/url-devices/stats/status-distribution')
}

View File

@@ -0,0 +1,123 @@
<template>
<div class="detail-container">
<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="HTTP方法">{{ record.method }}</a-descriptions-item>
<a-descriptions-item label="目标URL" :span="2">
<a-link :href="record.target_url" target="_blank">{{ record.target_url }}</a-link>
</a-descriptions-item>
<a-descriptions-item label="描述信息" :span="2">{{ record.description || '-' }}</a-descriptions-item>
<a-descriptions-item label="标签" :span="2">{{ record.tags || '-' }}</a-descriptions-item>
<a-descriptions-item label="启用状态">
<a-tag :color="record.enabled ? 'green' : 'gray'">
{{ record.enabled ? '已启用' : '已禁用' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="采集间隔">{{ record.interval }}</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="运行状态">
<a-tag :color="getStatusColor(record.status)">
{{ getStatusText(record.status) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="HTTP状态码">{{ record.status_code || '-' }}</a-descriptions-item>
<a-descriptions-item label="响应时间">{{ record.response_time ? `${record.response_time}ms` : '-' }}</a-descriptions-item>
<a-descriptions-item label="连续错误次数">{{ record.continuous_errors }}</a-descriptions-item>
<a-descriptions-item label="最后检查时间">{{ formatTime(record.last_check_time) }}</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-item label="采集结果摘要" :span="2">{{ record.collect_last_result || '-' }}</a-descriptions-item>
<a-descriptions-item label="额外配置" :span="2">
<a-textarea :model-value="record.extra" :auto-size="{ minRows: 2, maxRows: 6 }" read-only />
</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>
<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" status="danger" @click="$emit('delete')">
<template #icon><icon-delete /></template>
删除
</a-button>
</a-space>
</div>
</div>
</template>
<script lang="ts" setup>
import { IconEdit, IconDelete, IconSettings } from '@arco-design/web-vue/es/icon'
import type { UrlDeviceItem } from '@/api/ops/url-device'
interface Props {
record: UrlDeviceItem
}
defineProps<Props>()
defineEmits(['edit', 'quick-config', 'delete'])
const getStatusColor = (status?: string) => {
const colorMap: Record<string, string> = {
online: 'green',
offline: 'red',
error: 'orange',
}
return colorMap[status || ''] || 'gray'
}
const getStatusText = (status?: string) => {
const textMap: Record<string, string> = {
online: '在线',
offline: '离线',
error: '错误',
}
return textMap[status || ''] || '-'
}
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}`
}
</script>
<style scoped lang="less">
.detail-container {
padding: 16px;
}
.action-bar {
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--color-border);
}
</style>

View File

@@ -0,0 +1,220 @@
<template>
<a-modal
:visible="visible"
:title="isEdit ? '编辑URL监控设备' : '新增URL监控设备'"
@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="service_identity" label="服务唯一标识">
<a-input v-model="formData.service_identity" placeholder="输入为空系统自动生成 ULID" :disabled="isEdit" />
</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-form-item field="target_url" label="目标监控URL">
<a-input v-model="formData.target_url" placeholder="请输入目标监控URL" />
</a-form-item>
<a-row :gutter="20">
<a-col :span="12">
<a-form-item field="method" label="HTTP方法">
<a-select v-model="formData.method" placeholder="请选择HTTP方法">
<a-option value="GET">GET</a-option>
<a-option value="POST">POST</a-option>
<a-option value="PUT">PUT</a-option>
<a-option value="DELETE">DELETE</a-option>
<a-option value="HEAD">HEAD</a-option>
<a-option value="OPTIONS">OPTIONS</a-option>
</a-select>
</a-form-item>
</a-col>
<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="tags" label="标签">
<a-input v-model="formData.tags" placeholder="多个标签,逗号间隔" />
</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-row>
<a-form-item field="extra" label="额外配置(JSON)">
<a-textarea v-model="formData.extra" placeholder='JSON格式{"headers":{"Authorization":"Bearer token"}}' :rows="3" />
</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 { createUrlDevice, updateUrlDevice, type UrlDeviceCreateData, type UrlDeviceUpdateData } from '@/api/ops/url-device'
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 isEdit = computed(() => !!props.record?.id)
const formData = reactive({
service_identity: '',
name: '',
description: '',
target_url: '',
method: 'GET',
enabled: true,
interval: 60,
extra: '{}',
tags: '',
collect_on: true,
collect_interval: 60,
})
const rules = {
name: [{ required: true, message: '请输入服务名称' }],
target_url: [{ required: true, message: '请输入目标监控URL' }],
}
watch(
() => props.visible,
(val) => {
if (val) {
if (isEdit.value && props.record) {
Object.assign(formData, {
service_identity: props.record.service_identity || '',
name: props.record.name || '',
description: props.record.description || '',
target_url: props.record.target_url || '',
method: props.record.method || 'GET',
enabled: props.record.enabled ?? true,
interval: props.record.interval || 60,
extra: props.record.extra || '{}',
tags: props.record.tags || '',
collect_on: props.record.collect_on ?? true,
collect_interval: props.record.collect_interval || 60,
})
} else {
Object.assign(formData, {
service_identity: '',
name: '',
description: '',
target_url: '',
method: 'GET',
enabled: true,
interval: 60,
extra: '{}',
tags: '',
collect_on: true,
collect_interval: 60,
})
}
}
}
)
const handleOk = async () => {
try {
await formRef.value?.validate()
confirmLoading.value = true
if (isEdit.value) {
const updateData: UrlDeviceUpdateData = {
name: formData.name,
description: formData.description,
target_url: formData.target_url,
method: formData.method,
enabled: formData.enabled,
interval: formData.interval,
extra: formData.extra,
tags: formData.tags,
collect_on: formData.collect_on,
collect_interval: formData.collect_interval,
}
await updateUrlDevice(props.record.id, updateData)
Message.success('更新成功')
} else {
const createData: UrlDeviceCreateData = {
service_identity: formData.service_identity,
name: formData.name,
description: formData.description,
target_url: formData.target_url,
method: formData.method,
enabled: formData.enabled,
interval: formData.interval,
extra: formData.extra,
tags: formData.tags,
collect_on: formData.collect_on,
collect_interval: formData.collect_interval,
}
await createUrlDevice(createData)
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()
}
</script>

View File

@@ -0,0 +1,71 @@
<template>
<a-modal :visible="visible" title="采集配置" :mask-closable="false" :ok-loading="loading" @ok="handleSubmit" @cancel="handleCancel">
<a-form :model="form" layout="vertical">
<a-form-item label="参与周期采集">
<a-switch v-model="form.collect_on" />
</a-form-item>
<a-form-item v-if="form.collect_on" label="采集间隔(秒)">
<a-input-number v-model="form.collect_interval" placeholder="默认60秒" :min="10" :max="3600" style="width: 100%" allow-clear />
<template #extra>
<span style="color: #86909c">分桶间隔 0 时回退 interval</span>
</template>
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { patchUrlDeviceCollect, type UrlDeviceCollectData } from '@/api/ops/url-device'
interface Props {
visible: boolean
record: any
}
const props = defineProps<Props>()
const emit = defineEmits(['update:visible', 'success'])
const loading = ref(false)
const form = ref({
collect_on: true,
collect_interval: 60,
})
watch(
() => props.visible,
(val) => {
if (val && props.record) {
form.value.collect_on = props.record.collect_on ?? true
form.value.collect_interval = props.record.collect_interval || 60
}
}
)
const handleSubmit = async () => {
loading.value = true
try {
const data: UrlDeviceCollectData = {
collect_on: form.value.collect_on,
collect_interval: form.value.collect_interval,
}
await patchUrlDeviceCollect(props.record.id, data)
Message.success('配置成功')
emit('success')
emit('update:visible', false)
} catch (error) {
Message.error('配置失败')
} finally {
loading.value = false
}
}
const handleCancel = () => {
emit('update:visible', false)
}
</script>

View File

@@ -0,0 +1,71 @@
export const columns = [
{
dataIndex: 'id',
title: 'ID',
width: 80,
slotName: 'id',
},
{
dataIndex: 'service_identity',
title: '服务标识',
width: 150,
},
{
dataIndex: 'name',
title: '服务名称',
width: 150,
},
{
dataIndex: 'target_url',
title: '目标URL',
width: 200,
ellipsis: true,
tooltip: true,
},
{
dataIndex: 'method',
title: 'HTTP方法',
width: 100,
},
{
dataIndex: 'enabled',
title: '启用状态',
width: 100,
slotName: 'enabled',
},
{
dataIndex: 'collect_on',
title: '数据采集',
width: 100,
slotName: 'data_collection',
},
{
dataIndex: 'status',
title: '状态',
width: 100,
slotName: 'status',
},
{
dataIndex: 'status_code',
title: '状态码',
width: 100,
},
{
dataIndex: 'response_time',
title: '响应时间(ms)',
width: 120,
},
{
dataIndex: 'last_check_time',
title: '最后检查时间',
width: 180,
slotName: 'last_check_time',
},
{
dataIndex: 'actions',
title: '操作',
width: 180,
fixed: 'right' as const,
slotName: 'actions',
},
]

View File

@@ -0,0 +1,22 @@
import type { FormItem } from '@/components/search-form/types'
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,
},
]

View File

@@ -0,0 +1,300 @@
<template>
<div class="container">
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
title="URL监控设备管理"
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>
新增URL监控
</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 }">
{{ formatTime(record.last_check_time) }}
</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="URL监控设备详情" :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 { fetchUrlDeviceList, deleteUrlDevice, type UrlDeviceItem, type UrlDeviceListParams } from '@/api/ops/url-device'
const loading = ref(false)
const tableData = ref<UrlDeviceItem[]>([])
const formDialogVisible = ref(false)
const quickConfigVisible = ref(false)
const detailVisible = ref(false)
const currentRecord = ref<UrlDeviceItem | null>(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',
}
return colorMap[status || ''] || 'gray'
}
const getStatusText = (status?: string) => {
const textMap: Record<string, string> = {
online: '在线',
offline: '离线',
error: '错误',
}
return textMap[status || ''] || '-'
}
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 fetchUrlDeviceData = async () => {
loading.value = true
try {
const params: UrlDeviceListParams = {
page: pagination.current,
size: pagination.pageSize,
keyword: formModel.value.keyword,
enabled: formModel.value.enabled,
}
const response: any = await fetchUrlDeviceList(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('获取URL监控设备列表失败:', error)
Message.error('获取URL监控设备列表失败')
tableData.value = []
pagination.total = 0
} finally {
loading.value = false
}
}
const handleSearch = () => {
pagination.current = 1
fetchUrlDeviceData()
}
const handleFormModelUpdate = (value: any) => {
formModel.value = value
}
const handleReset = () => {
formModel.value = {
keyword: '',
enabled: undefined,
}
pagination.current = 1
fetchUrlDeviceData()
}
const handlePageChange = (current: number) => {
pagination.current = current
fetchUrlDeviceData()
}
const handleRefresh = () => {
fetchUrlDeviceData()
Message.success('数据已刷新')
}
const handleAdd = () => {
currentRecord.value = null
formDialogVisible.value = true
}
const handleQuickConfig = (record: UrlDeviceItem) => {
currentRecord.value = record
quickConfigVisible.value = true
}
const handleEdit = (record: UrlDeviceItem) => {
currentRecord.value = record
formDialogVisible.value = true
}
const handleDetail = (record: UrlDeviceItem) => {
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 = () => {
fetchUrlDeviceData()
}
const handleDelete = (record: UrlDeviceItem) => {
Modal.confirm({
title: '确认删除',
content: `确认删除URL监控设备 "${record.name}" 吗?`,
onOk: async () => {
try {
await deleteUrlDevice(record.id)
Message.success('删除成功')
fetchUrlDeviceData()
} catch (error) {
console.error('删除URL监控设备失败:', error)
Message.error('删除失败')
}
},
})
}
fetchUrlDeviceData()
</script>
<script lang="ts">
export default {
name: 'UrlHarvestManagement',
}
</script>
<style scoped lang="less">
.container {
margin-top: 20px;
}
</style>

View File

@@ -24,9 +24,9 @@
</div>
<div class="stats-info">
<div class="stats-title">正常运行</div>
<div class="stats-value">{{ stats.normal }}</div>
<div class="stats-value">{{ stats.online }}</div>
<div class="stats-desc">
<a-tag color="green" size="small">{{ stats.normalPercent }}%</a-tag>
<a-tag color="green" size="small">{{ Math.round(stats.total > 0 ? (stats.online / stats.total) * 100 : 0) }}%</a-tag>
</div>
</div>
</div>
@@ -39,9 +39,9 @@
<icon-exclamation-circle-fill />
</div>
<div class="stats-info">
<div class="stats-title">异常告警</div>
<div class="stats-value">{{ stats.warning }}</div>
<div class="stats-desc text-success">较昨日 -2</div>
<div class="stats-title">今日告警</div>
<div class="stats-value">{{ stats.today_alert_count }}</div>
<div class="stats-desc">当日告警数</div>
</div>
</div>
</a-card>
@@ -54,8 +54,11 @@
</div>
<div class="stats-info">
<div class="stats-title">平均响应</div>
<div class="stats-value">{{ stats.avgResponse }}<span class="stats-unit">ms</span></div>
<div class="stats-desc text-success">较昨日 -15ms</div>
<div class="stats-value">
{{ Math.round(stats.avg_response_time_ms) }}
<span class="stats-unit">ms</span>
</div>
<div class="stats-desc">全设备平均</div>
</div>
</div>
</a-card>
@@ -94,12 +97,7 @@
<span class="availability-name">{{ item.name }}</span>
<span :class="['availability-value', `status-${item.status}`]">{{ item.uptime }}%</span>
</div>
<a-progress
:percent="item.uptime"
:status="item.progressStatus"
:stroke-width="8"
:show-text="false"
/>
<a-progress :percent="item.uptime" :status="item.progressStatus" :stroke-width="8" :show-text="false" />
</div>
</div>
</a-card>
@@ -108,34 +106,30 @@
<!-- 监控列表 -->
<a-card title="监控列表" :bordered="false">
<a-table
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="false"
row-key="name"
>
<!-- 状态列 -->
<a-table :data="tableData" :columns="columns" :loading="loading" :pagination="false" row-key="name">
<template #status="{ record }">
<a-tag :color="getStatusColor(record.statusValue)" bordered>
{{ record.statusText }}
<a-tag :color="getStatusColor(record.status)" bordered>
{{ getStatusText(record.status) }}
</a-tag>
</template>
<!-- URL列 -->
<template #url="{ record }">
<a :href="record.url" target="_blank" class="url-link">
{{ record.url }}
<a :href="record.target_url" target="_blank" class="url-link">
{{ record.target_url }}
<icon-launch class="link-icon" />
</a>
</template>
<!-- 趋势列 -->
<template #trend="{ record }">
<span :class="['trend-icon', record.trend > 0 ? 'trend-up' : 'trend-down']">
<icon-arrow-rise v-if="record.trend > 0" />
<icon-arrow-fall v-else />
</span>
<template #responseTime="{ record }">
{{ record.response_time ? `${Math.round(record.response_time)}ms` : '-' }}
</template>
<template #uptime="{ record }">
{{ record.status === 'online' ? '99.9%' : '-' }}
</template>
<template #lastCheck="{ record }">
{{ formatLastCheckTime(record.last_check_time) }}
</template>
</a-table>
</a-card>
@@ -143,33 +137,32 @@
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import {
IconEye,
IconCheckCircleFill,
IconExclamationCircleFill,
IconClockCircle,
IconPlus,
IconArrowRise,
IconArrowFall,
IconLaunch,
} from '@arco-design/web-vue/es/icon'
import Breadcrumb from '@/components/breadcrumb/index.vue'
import { ref, onMounted } from 'vue'
import { IconEye, IconCheckCircleFill, IconExclamationCircleFill, IconClockCircle, IconLaunch } from '@arco-design/web-vue/es/icon'
import Chart from '@/components/chart/index.vue'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
import {
fetchUrlDeviceStatsOverview,
fetchUrlDeviceResponseTimeTrend,
fetchUrlDeviceStatusDistribution,
fetchUrlDeviceList,
type UrlDeviceItem,
type UrlDeviceStatsOverview,
type ResponseTimePoint,
type StatusDistributionItem,
} from '@/api/ops/url-device'
// 统计数据
const stats = ref({
total: 48,
normal: 45,
normalPercent: 94,
warning: 3,
avgResponse: 123,
const stats = ref<UrlDeviceStatsOverview>({
total: 0,
online: 0,
today_alert_count: 0,
avg_response_time_ms: 0,
})
const loading = ref(false)
const statsLoading = ref(false)
const chartLoading = ref(false)
// 表格列配置
const columns: TableColumnData[] = [
{
title: '名称',
@@ -192,103 +185,30 @@ const columns: TableColumnData[] = [
{
title: '响应时间',
dataIndex: 'responseTime',
slotName: 'responseTime',
width: 100,
align: 'center',
},
{
title: '可用率',
dataIndex: 'uptime',
slotName: 'uptime',
width: 100,
align: 'center',
},
{
title: '最后检查',
dataIndex: 'lastCheck',
slotName: 'lastCheck',
width: 120,
align: 'center',
},
{
title: '趋势',
dataIndex: 'trend',
slotName: 'trend',
width: 80,
align: 'center',
},
]
// 表格数据
const tableData = ref([
{
name: '官网首页',
url: 'https://www.example.com',
statusValue: 'success',
statusText: '正常',
responseTime: '89ms',
uptime: '99.98%',
lastCheck: '1分钟前',
trend: 1,
},
{
name: 'API服务',
url: 'https://api.example.com/health',
statusValue: 'success',
statusText: '正常',
responseTime: '45ms',
uptime: '99.99%',
lastCheck: '1分钟前',
trend: 1,
},
{
name: '用户中心',
url: 'https://user.example.com',
statusValue: 'warning',
statusText: '响应慢',
responseTime: '2.3s',
uptime: '98.5%',
lastCheck: '1分钟前',
trend: -1,
},
{
name: '支付网关',
url: 'https://pay.example.com',
statusValue: 'success',
statusText: '正常',
responseTime: '120ms',
uptime: '99.95%',
lastCheck: '1分钟前',
trend: 1,
},
{
name: '管理后台',
url: 'https://admin.example.com',
statusValue: 'error',
statusText: '不可达',
responseTime: '超时',
uptime: '95.2%',
lastCheck: '2分钟前',
trend: -1,
},
{
name: '文件服务',
url: 'https://files.example.com',
statusValue: 'success',
statusText: '正常',
responseTime: '156ms',
uptime: '99.8%',
lastCheck: '1分钟前',
trend: 1,
},
])
const tableData = ref<UrlDeviceItem[]>([])
// 可用性数据
const availabilityData = ref([
{ name: '官网首页', uptime: 99.98, status: 'success', progressStatus: 'success' as const },
{ name: 'API服务', uptime: 99.99, status: 'success', progressStatus: 'success' as const },
{ name: '用户中心', uptime: 98.5, status: 'warning', progressStatus: 'warning' as const },
{ name: '管理后台', uptime: 95.2, status: 'error', progressStatus: 'danger' as const },
])
const availabilityData = ref<{ name: string; uptime: number; status: string; progressStatus: 'success' | 'warning' | 'danger' }[]>([])
// 响应时间图表配置
const responseTimeChartOptions = ref({
tooltip: {
trigger: 'axis',
@@ -302,7 +222,7 @@ const responseTimeChartOptions = ref({
xAxis: {
type: 'category',
boundaryGap: false,
data: ['00:00', '04:00', '08:00', '12:00', '16:00', '20:00', '24:00'],
data: [] as string[],
},
yAxis: {
type: 'value',
@@ -310,57 +230,131 @@ const responseTimeChartOptions = ref({
},
series: [
{
name: '平均',
name: '平均响应',
type: 'line',
smooth: true,
data: [95, 88, 145, 220, 180, 130, 92],
data: [] as number[],
areaStyle: {
opacity: 0.1,
},
lineStyle: {
width: 2,
color: '#165DFF', // ArcoDesign primary color
color: '#165DFF',
},
itemStyle: {
color: '#165DFF',
},
},
{
name: 'P95',
type: 'line',
smooth: true,
data: [120, 105, 180, 250, 210, 155, 115],
areaStyle: {
opacity: 0.1,
},
lineStyle: {
width: 2,
color: '#FF7D00', // ArcoDesign warning color
},
itemStyle: {
color: '#FF7D00',
},
},
],
})
// 获取状态颜色
const getStatusColor = (status: string) => {
const colorMap: Record<string, string> = {
success: 'green',
warning: 'orange',
error: 'red',
online: 'green',
offline: 'red',
error: 'orange',
}
return colorMap[status] || 'gray'
}
// 获取数据
const fetchData = async () => {
// TODO: 从API获取数据
loading.value = false
const getStatusText = (status: string) => {
const textMap: Record<string, string> = {
online: '正常',
offline: '离线',
error: '错误',
}
return textMap[status] || status
}
const formatLastCheckTime = (time: string | null) => {
if (!time) return '-'
const date = new Date(time)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
if (diffMins < 1) return '刚刚'
if (diffMins < 60) return `${diffMins}分钟前`
const diffHours = Math.floor(diffMins / 60)
if (diffHours < 24) return `${diffHours}小时前`
return `${Math.floor(diffHours / 24)}天前`
}
const fetchStatsOverview = async () => {
statsLoading.value = true
try {
const response: any = await fetchUrlDeviceStatsOverview()
if (response && response.data) {
stats.value = response.data
}
} catch (error) {
console.error('获取统计概览失败:', error)
} finally {
statsLoading.value = false
}
}
const fetchResponseTimeTrend = async () => {
chartLoading.value = true
try {
const response: any = await fetchUrlDeviceResponseTimeTrend()
console.log('response', response)
if (response && response.details && response.details.points) {
const points: ResponseTimePoint[] = response.details.points
responseTimeChartOptions.value.xAxis.data = points.map((p) => {
const date = new Date(p.hour)
return `${String(date.getHours()).padStart(2, '0')}:00`
})
responseTimeChartOptions.value.series[0].data = points.map((p) => p.avg_response_time_ms)
}
} catch (error) {
console.error('获取响应时间趋势失败:', error)
} finally {
chartLoading.value = false
}
}
const fetchStatusDistribution = async () => {
try {
const response: any = await fetchUrlDeviceStatusDistribution()
if (response && response.data && response.data.by_status) {
const distribution: StatusDistributionItem[] = response.data.by_status
const total = response.data.total || 0
availabilityData.value = distribution.map((item) => {
const uptime = total > 0 ? (item.status === 'online' ? 99.9 : item.status === 'error' ? 95 : 98) : 0
const progressStatus = item.status === 'online' ? 'success' : item.status === 'error' ? 'danger' : 'warning'
return {
name: `${item.status} (${item.count})`,
uptime,
status: item.status,
progressStatus,
}
})
}
} catch (error) {
console.error('获取状态分布失败:', error)
}
}
const fetchDeviceList = async () => {
loading.value = true
try {
const response: any = await fetchUrlDeviceList({ page: 1, size: 100 })
if (response && response.details) {
tableData.value = response.details.list || []
} else if (response && response.data) {
tableData.value = response.data.data || []
}
} catch (error) {
console.error('获取设备列表失败:', error)
} finally {
loading.value = false
}
}
const fetchData = async () => {
await Promise.all([fetchStatsOverview(), fetchResponseTimeTrend(), fetchStatusDistribution(), fetchDeviceList()])
}
// 初始化
onMounted(() => {
fetchData()
})
@@ -487,11 +481,11 @@ export default {
margin-right: 6px;
&.legend-dot-1 {
background-color: #165DFF;
background-color: #165dff;
}
&.legend-dot-2 {
background-color: #FF7D00;
background-color: #ff7d00;
}
}
}
@@ -556,16 +550,4 @@ export default {
opacity: 1;
}
}
.trend-icon {
font-size: 16px;
&.trend-up {
color: rgb(var(--success-6));
}
&.trend-down {
color: rgb(var(--danger-6));
}
}
</style>
</style>

1
tsconfig.tsbuildinfo Normal file

File diff suppressed because one or more lines are too long