diff --git a/src/api/admin/noticeChannel.js b/src/api/admin/noticeChannel.js new file mode 100644 index 0000000..c5b62b5 --- /dev/null +++ b/src/api/admin/noticeChannel.js @@ -0,0 +1,41 @@ +import { http2 } from '@/utils/request.js' + +// ========== 通知渠道配置 ========== + +/** 获取全部通知渠道配置列表(无分页) */ +export const getNoticeChannelList = () => { + return http2.get('/api/v1/admin/notice_message/channel/list') +} + +/** 修改通知渠道配置 */ +export const updateNoticeChannel = (data) => { + return http2.post('/api/v1/admin/notice_message/channel/update', data, { + headers: { 'Content-Type': 'multipart/form-data' } + }) +} + +// ========== 通知模板管理 ========== + +/** 获取全部通知模板列表(无分页) */ +export const getNoticeTemplateList = () => { + return http2.get('/api/v1/admin/notice_message/template/list') +} + +/** 添加通知模板 */ +export const addNoticeTemplate = (data) => { + return http2.post('/api/v1/admin/notice_message/template/add', data, { + headers: { 'Content-Type': 'multipart/form-data' } + }) +} + +/** 修改通知模板 */ +export const updateNoticeTemplate = (data) => { + return http2.post('/api/v1/admin/notice_message/template/update', data, { + headers: { 'Content-Type': 'multipart/form-data' } + }) +} + +/** 删除通知模板 */ +export const deleteNoticeTemplate = (params) => { + return http2.delete('/api/v1/admin/notice_message/template/delete', { params }) +} diff --git a/src/components/admin/AvatarSelector.vue b/src/components/admin/AvatarSelector.vue index 38e7592..71d1be7 100644 --- a/src/components/admin/AvatarSelector.vue +++ b/src/components/admin/AvatarSelector.vue @@ -13,9 +13,15 @@

文件列表

- - 上传新文件 - +
+ + + + + + 上传新文件 + +
props.modelValue, (newVal) => { @@ -144,6 +151,7 @@ import { closeAllMessage } from '../../utils/message' if (newVal) { selectedId.value = props.currentCoverId currentPage.value = 1 + sourceFilter.value = undefined fetchFileList() } }) @@ -161,10 +169,11 @@ import { closeAllMessage } from '../../utils/message' fileList.value = [] // 清空列表 try { - const res = await getFileList({ - page: currentPage.value, - count: pageSize.value - }) + const params = { page: currentPage.value, count: pageSize.value } + if (sourceFilter.value !== undefined && sourceFilter.value !== null && sourceFilter.value !== '') { + params.is_admin = sourceFilter.value + } + const res = await getFileList(params) console.log("获取文件列表:", res) @@ -219,6 +228,12 @@ import { closeAllMessage } from '../../utils/message' fetchFileList() } + // 来源筛选变化 + const handleSourceChange = () => { + currentPage.value = 1 + fetchFileList() + } + // 切换到上传标签页 const switchToUpload = () => { activeTab.value = 'upload' @@ -312,6 +327,7 @@ import { closeAllMessage } from '../../utils/message' fileList.value = [] currentPage.value = 1 total.value = 0 + sourceFilter.value = undefined } // 确认选择 @@ -347,6 +363,12 @@ import { closeAllMessage } from '../../utils/message' margin: 0; color: #303133; } + + .header-actions { + display: flex; + align-items: center; + gap: 10px; + } .file-grid { display: grid; diff --git a/src/components/admin/ImageSelector.vue b/src/components/admin/ImageSelector.vue index 193c5e2..10585f6 100644 --- a/src/components/admin/ImageSelector.vue +++ b/src/components/admin/ImageSelector.vue @@ -33,6 +33,10 @@ @input="handleSearch" style="width: 300px;" /> + + + +
@@ -178,6 +182,7 @@ const currentPage = ref(1) const pageSize = ref(12) const total = ref(0) const searchKeyword = ref('') +const sourceFilter = ref(undefined) const pendingFiles = ref([]) // 待上传文件列表 const uploading = ref(false) // 批量上传中 let fetchVersion = 0 // 防止 fetchFileList 竞态条件 @@ -190,6 +195,7 @@ watch(() => props.modelValue, (newVal) => { selectedIds.value = new Set() currentPage.value = 1 searchKeyword.value = '' + sourceFilter.value = undefined fetchFileList() } }) @@ -224,10 +230,11 @@ const fetchFileList = async () => { loading.value = true try { - const res = await getFileList({ - page: currentPage.value, - count: pageSize.value - }) + const params = { page: currentPage.value, count: pageSize.value } + if (sourceFilter.value !== undefined && sourceFilter.value !== null && sourceFilter.value !== '') { + params.is_admin = sourceFilter.value + } + const res = await getFileList(params) // 如果有更新的请求发起,丢弃当前结果 if (currentFetchVersion !== fetchVersion) return @@ -285,10 +292,15 @@ const handleTabClick = (tab) => { // 处理搜索 const handleSearch = () => { - // 搜索时重置到第一页 currentPage.value = 1 } +// 来源筛选变化 +const handleSourceChange = () => { + currentPage.value = 1 + fetchFileList() +} + // 分页处理 const handleSizeChange = (size) => { pageSize.value = size @@ -436,6 +448,7 @@ const handleClose = () => { currentPage.value = 1 total.value = 0 searchKeyword.value = '' + sourceFilter.value = undefined // 清理待上传文件的预览URL pendingFiles.value.forEach(f => { if (f.previewUrl) URL.revokeObjectURL(f.previewUrl) @@ -495,6 +508,8 @@ const handleConfirm = () => { .filter-section { margin-bottom: 20px; + display: flex; + align-items: center; } .file-grid { diff --git a/src/config/menus.js b/src/config/menus.js index 1c50552..01f110e 100644 --- a/src/config/menus.js +++ b/src/config/menus.js @@ -194,6 +194,10 @@ export const menus = [ path: '/system/setting-manage', title: '配置管理' }, + { + path: '/system/notice-channel', + title: '通知管理' + }, { path: '/system/menu', title: '菜单管理', diff --git a/src/router/index.js b/src/router/index.js index 7dd6a94..09bebdb 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -424,6 +424,12 @@ const routes = [ component: () => import('../views/system/SettingManage.vue'), meta: { title: '配置管理' } }, + { + path: 'notice-channel', + name: 'NoticeChannel', + component: () => import('../views/system/NoticeChannel.vue'), + meta: { title: '通知管理' } + }, { path: 'menu-manage', name: 'MenuManage', diff --git a/src/views/system/NoticeChannel.vue b/src/views/system/NoticeChannel.vue new file mode 100644 index 0000000..e2e56a3 --- /dev/null +++ b/src/views/system/NoticeChannel.vue @@ -0,0 +1,500 @@ + + + + + diff --git a/src/views/user-vm/UserVmDetail.vue b/src/views/user-vm/UserVmDetail.vue index bd50bc2..4376499 100644 --- a/src/views/user-vm/UserVmDetail.vue +++ b/src/views/user-vm/UserVmDetail.vue @@ -402,15 +402,28 @@

监控指标

+ + 最近 + 自定义 + + + + + + + + + + 粒度: {{ currentIntervalLabel }} @@ -464,13 +477,13 @@ - +
- +
@@ -478,7 +491,7 @@ - +
@@ -516,16 +529,27 @@

每小时流量

-
+
+ + 最近 + 自定义 + + + + + + + + 刷新 @@ -2259,24 +2283,35 @@ const submitAddTraffic = async () => { } // ---- 每小时流量统计 ---- +const trafficTimeMode = ref('relative') +const trafficRelativeMinutes = ref(1440) const trafficHourlyRange = ref(null) const trafficHourlyData = ref([]) const trafficHourlyLoading = ref(false) const trafficHourlyChartRef = ref(null) let trafficHourlyChart = null +const getTrafficTimeRange = () => { + if (trafficTimeMode.value === 'relative') { + const endTime = new Date() + const startTime = new Date(endTime - trafficRelativeMinutes.value * 60 * 1000) + return { startTime, endTime } + } else { + if (!trafficHourlyRange.value || trafficHourlyRange.value.length < 2) return null + return { startTime: new Date(trafficHourlyRange.value[0]), endTime: new Date(trafficHourlyRange.value[1]) } + } +} + const loadTrafficHourly = async () => { if (!userGoodsId.value) return - if (!trafficHourlyRange.value) { - const now = new Date() - trafficHourlyRange.value = [new Date(now.getTime() - 24 * 3600 * 1000), now] - } + const range = getTrafficTimeRange() + if (!range) return trafficHourlyLoading.value = true try { const res = await getUserVmTrafficHourly({ user_goods_id: userGoodsId.value, - start: new Date(trafficHourlyRange.value[0]).toISOString(), - end_time: new Date(trafficHourlyRange.value[1]).toISOString() + start: range.startTime.toISOString(), + end_time: range.endTime.toISOString() }) const raw = res?.data?.data?.data trafficHourlyData.value = typeof raw === 'string' ? JSON.parse(raw) : (Array.isArray(raw) ? raw : []) @@ -2372,21 +2407,20 @@ let trafficUsedChart = null const metricsData = ref(null) const metricsLoading = ref(false) -const makeDefaultRange = () => { - const now = new Date() - return [new Date(now.getTime() - 10 * 60 * 1000), now] -} -const monitorDateRange = ref(makeDefaultRange()) +const monitorTimeMode = ref('relative') +const monitorRelativeMinutes = ref(10) +const monitorDateRange = ref(null) -const monitorShortcuts = [ - { text: '最近10分钟', value: () => { const n = new Date(); return [new Date(n.getTime() - 10 * 60000), n] } }, - { text: '最近30分钟', value: () => { const n = new Date(); return [new Date(n.getTime() - 30 * 60000), n] } }, - { text: '最近1小时', value: () => { const n = new Date(); return [new Date(n.getTime() - 3600000), n] } }, - { text: '最近6小时', value: () => { const n = new Date(); return [new Date(n.getTime() - 6 * 3600000), n] } }, - { text: '最近12小时', value: () => { const n = new Date(); return [new Date(n.getTime() - 12 * 3600000), n] } }, - { text: '最近1天', value: () => { const n = new Date(); return [new Date(n.getTime() - 86400000), n] } }, - { text: '最近7天', value: () => { const n = new Date(); return [new Date(n.getTime() - 7 * 86400000), n] } }, -] +const getMonitorTimeRange = () => { + if (monitorTimeMode.value === 'relative') { + const endTime = new Date() + const startTime = new Date(endTime - monitorRelativeMinutes.value * 60 * 1000) + return { startTime, endTime } + } else { + if (!monitorDateRange.value || monitorDateRange.value.length < 2) return null + return { startTime: new Date(monitorDateRange.value[0]), endTime: new Date(monitorDateRange.value[1]) } + } +} function calcInterval(startTime, endTime) { const spanMin = (endTime.getTime() - startTime.getTime()) / 60000 @@ -2404,8 +2438,9 @@ function calcInterval(startTime, endTime) { const intervalLabelMap = { '1m': '1分钟', '3m': '3分钟', '5m': '5分钟', '15m': '15分钟', '30m': '30分钟', '1h': '1小时', '2h': '2小时', '6h': '6小时', '12h': '12小时', '1d': '1天' } const currentIntervalLabel = computed(() => { - if (!monitorDateRange.value || monitorDateRange.value.length < 2) return '-' - const iv = calcInterval(new Date(monitorDateRange.value[0]), new Date(monitorDateRange.value[1])) + const range = getMonitorTimeRange() + if (!range) return '-' + const iv = calcInterval(range.startTime, range.endTime) return intervalLabelMap[iv] || iv }) @@ -2450,11 +2485,11 @@ const vmMemPercent = (m) => { const loadMetricsHistory = async () => { if (!userGoodsId.value) return - if (!monitorDateRange.value || monitorDateRange.value.length < 2) return + const range = getMonitorTimeRange() + if (!range) return metricsLoading.value = true try { - const startTime = new Date(monitorDateRange.value[0]) - const endTime = new Date(monitorDateRange.value[1]) + const { startTime, endTime } = range const interval = calcInterval(startTime, endTime) const params = { user_goods_id: userGoodsId.value, @@ -2482,7 +2517,8 @@ const renderMetricsCharts = () => { const metrics = metricsData.value if (!Array.isArray(metrics) || !metrics.length) return - const spanMs = monitorDateRange.value ? (new Date(monitorDateRange.value[1]).getTime() - new Date(monitorDateRange.value[0]).getTime()) : 600000 + const range = getMonitorTimeRange() + const spanMs = range ? (range.endTime.getTime() - range.startTime.getTime()) : 600000 const showDate = spanMs >= 86400000 const symbolType = metrics.length < 30 ? 'circle' : 'none' const labelRotate = showDate ? 45 : 0 diff --git a/src/views/virtualization/HostDetail.vue b/src/views/virtualization/HostDetail.vue index 7273a14..67fe599 100644 --- a/src/views/virtualization/HostDetail.vue +++ b/src/views/virtualization/HostDetail.vue @@ -54,6 +54,18 @@ 带宽 ↓{{ detail.rx_bandwidth || 0 }} / ↑{{ detail.tx_bandwidth || 0 }} Mbps
+
+ 负载 + + {{ loadAvg['1min']?.toFixed(2) }} / + {{ loadAvg['5min']?.toFixed(2) }} / + {{ loadAvg['15min']?.toFixed(2) }} + +
+
+ 实时网速 + ↓{{ formatSpeedAuto(hostMetrics.internet_speed.rx_bytes) }} / ↑{{ formatSpeedAuto(hostMetrics.internet_speed.tx_bytes) }} +
创建时间 {{ formatTimestamp(detail.created_at) }} @@ -131,6 +143,187 @@
+ +
+
+

实时监控

+ 刷新 +
+
+ +
+
+ +
+
+ CPU + {{ hostMetrics.cpu.cpu_usage_percent?.toFixed(1) }}% + + {{ hostMetrics.cpu.cpu_count }} 逻辑核心 +
+
+ +
+
+ +
+
+ 内存 + {{ hostMetrics.memory.percent?.toFixed(1) }}% + + {{ formatBytesAuto(hostMetrics.memory.used) }} / {{ formatBytesAuto(hostMetrics.memory.total) }} +
+
+ +
+
+ +
+
+ 系统负载 +
+ + 1m{{ loadAvg['1min']?.toFixed(2) }} + + + 5m{{ loadAvg['5min']?.toFixed(2) }} + + + 15m{{ loadAvg['15min']?.toFixed(2) }} + +
+ 核心数 {{ hostMetrics?.cpu?.cpu_count ?? '-' }},满载阈值 {{ hostMetrics?.cpu?.cpu_count ?? '-' }}.00 +
+
+ +
+
+ +
+
+ 网络带宽 +
+ {{ formatSpeedAuto(hostMetrics.internet_speed.rx_bytes) }} + {{ formatSpeedAuto(hostMetrics.internet_speed.tx_bytes) }} +
+ 累计 ↓{{ formatBytesAuto(hostMetrics.network.rx_bytes) }} ↑{{ formatBytesAuto(hostMetrics.network.tx_bytes) }} +
+
+
+ + + +
+ + +
+

+ + 硬件与系统 + +

+
+ +
+
+ +
运行{{ formatUptime(hardwareInfo.system_info.uptime_seconds) }}
+
+
+ +
主机{{ hardwareInfo.system_info.hostname }}
+
+
+ +
系统{{ hardwareInfo.system_info.distro || hardwareInfo.system_info.os }}
+
+
+ +
内核{{ hardwareInfo.system_info.os }}
+
+
+ +
+
+
+
+ 处理器 + {{ hardwareInfo.cpu_model }} + {{ hardwareInfo.cpu_physical_cores }}C / {{ hardwareInfo.cpu_logical_cores }}T · {{ formatCpuFreq(hardwareInfo.cpu_freq) }} +
+
+
+
+
+ 内存 + {{ formatBytesAuto(hardwareInfo.memory_total) }} + Swap {{ hardwareInfo.swap_total ? formatBytesAuto(hardwareInfo.swap_total) : '无' }} · {{ hardwareInfo.system_info?.arch || '-' }} +
+
+
+ + + + +
+
+

硬盘 IO 限制 @@ -259,15 +452,28 @@

监控指标

+ + 最近 + 自定义 + + + + + + + + + + 粒度: {{ currentIntervalLabel }} @@ -350,6 +556,9 @@ + + +
@@ -730,7 +939,7 @@ import { useRoute, useRouter } from 'vue-router' import { ElMessage, ElMessageBox } from 'element-plus' import { ArrowLeft, ArrowRight, Refresh, Edit, Delete, Monitor, Coin, Connection, Search, Plus, Key, CopyDocument } from '@element-plus/icons-vue' import { - getRemoteHostDetail, updateRemoteHost, deleteRemoteHost, + getRemoteHostDetail, getRemoteHostMetrics, updateRemoteHost, deleteRemoteHost, getUserNetworkingList, getUserNetworkingDetail, createUserNetworking, deleteUserNetworking, assignUserNetworking, removeUserNetworkingNetwork, createHostToken, getMetricsHistory, getHostQuotaStats, @@ -745,6 +954,7 @@ import VolumeManage from '@/views/virtualization/VolumeManage.vue' import VmManage from '@/views/virtualization/VmManage.vue' import SnapshotManage from '@/views/virtualization/SnapshotManage.vue' import BackupManage from '@/views/virtualization/BackupManage.vue' +import VmMonitor from '@/views/virtualization/VmMonitor.vue' import { useTagsViewStore } from '@/store/tagsViewStore' import UserListSelector from '@/components/admin/UserListSelector.vue' import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue' @@ -759,7 +969,7 @@ const serviceName = computed(() => route.query.service_name || '') const hostId = computed(() => parseInt(route.query.id) || 0) const activeTab = ref('info') -const hostTabLoaded = reactive({ image: false, network: false, volume: false, vm: false, snapshot: false, backup: false, networking: false }) +const hostTabLoaded = reactive({ image: false, network: false, volume: false, vm: false, snapshot: false, backup: false, vmMonitor: false, networking: false }) const imageManageRef = ref(null) const networkManageRef = ref(null) @@ -767,7 +977,8 @@ const volumeManageRef = ref(null) const vmManageRef = ref(null) const snapshotManageRef = ref(null) const backupManageRef = ref(null) -const tabRefMap = { image: imageManageRef, network: networkManageRef, volume: volumeManageRef, vm: vmManageRef, snapshot: snapshotManageRef, backup: backupManageRef } +const vmMonitorRef = ref(null) +const tabRefMap = { image: imageManageRef, network: networkManageRef, volume: volumeManageRef, vm: vmManageRef, snapshot: snapshotManageRef, backup: backupManageRef, vmMonitor: vmMonitorRef } watch(activeTab, (tab) => { if (!['info', 'monitor', 'networking'].includes(tab)) { @@ -869,6 +1080,7 @@ const getTokenIoBwFactor = () => ioBwUnitOptions.find(u => u.label === tokenIoBw const showDiskIoSection = ref(false) const showTokenDiskIo = ref(false) const showDetailDiskIo = ref(false) +const showHardwareInfo = ref(false) const formData = reactive({ name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', private_key: '', @@ -944,6 +1156,111 @@ const loadDetail = async () => { } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '加载失败')) } finally { loading.value = false } } +// ---- 实时监控指标 ---- +const hostMetrics = ref(null) +const hostMetricsLoading = ref(false) +const hardwareInfo = ref(null) +const loadAvg = ref(null) + +const loadHostMetrics = async () => { + if (!hostId.value) return + hostMetricsLoading.value = true + try { + const res = await getRemoteHostMetrics({ service_id: serviceId.value, host_id: hostId.value }) + const body = res?.data + if (body?.code === 200 && body?.data) { + const raw = body.data.data ?? body.data + hostMetrics.value = raw + if (raw.hardware_json) { + try { hardwareInfo.value = JSON.parse(raw.hardware_json) } catch { hardwareInfo.value = null } + } + if (raw.load_avg_json) { + try { loadAvg.value = JSON.parse(raw.load_avg_json) } catch { loadAvg.value = null } + } + if (raw.ksm && !ksmStats.value) { + ksmStats.value = raw.ksm + } + } + } catch (e) { + console.warn('加载实时指标失败:', e) + } finally { + hostMetricsLoading.value = false + } +} + +const metricsDisks = computed(() => { + if (!hostMetrics.value?.disk) return [] + return Object.entries(hostMetrics.value.disk).map(([path, info]) => { + const pct = info.percent ?? (info.total ? ((info.used / info.total) * 100) : 0) + return { path, ...info, percent: pct } + }) +}) + +const formatBytesAuto = (val) => { + if (!val && val !== 0) return '-' + val = Number(val) + if (val >= 1099511627776) return (val / 1099511627776).toFixed(2) + ' TB' + if (val >= 1073741824) return (val / 1073741824).toFixed(2) + ' GB' + if (val >= 1048576) return (val / 1048576).toFixed(2) + ' MB' + if (val >= 1024) return (val / 1024).toFixed(1) + ' KB' + return val + ' B' +} + +const formatSpeedAuto = (val) => { + if (!val && val !== 0) return '0 B/s' + val = Number(val) + if (val >= 1073741824) return (val / 1073741824).toFixed(1) + ' GB/s' + if (val >= 1048576) return (val / 1048576).toFixed(1) + ' MB/s' + if (val >= 1024) return (val / 1024).toFixed(1) + ' KB/s' + return val + ' B/s' +} + +const formatCpuFreq = (freq) => { + if (!freq) return '-' + if (typeof freq === 'object') { + const cur = freq.current ? (freq.current >= 1000 ? (freq.current / 1000).toFixed(2) + ' GHz' : freq.current.toFixed(0) + ' MHz') : '' + const max = freq.max ? (freq.max >= 1000 ? (freq.max / 1000).toFixed(1) + ' GHz' : freq.max + ' MHz') : '' + if (cur && max) return `${cur}(最高 ${max})` + return cur || max || '-' + } + const v = Number(freq) + return v >= 1000 ? (v / 1000).toFixed(2) + ' GHz' : v + ' MHz' +} + +const formatUptime = (seconds) => { + if (!seconds) return '-' + const d = Math.floor(seconds / 86400) + const h = Math.floor((seconds % 86400) / 3600) + const m = Math.floor((seconds % 3600) / 60) + const parts = [] + if (d > 0) parts.push(`${d} 天`) + if (h > 0) parts.push(`${h} 小时`) + if (m > 0 && d === 0) parts.push(`${m} 分钟`) + return parts.join(' ') || '< 1 分钟' +} + +const loadColor = (val, cores) => { + if (!cores) return '#909399' + const ratio = val / cores + if (ratio >= 1) return '#f56c6c' + if (ratio >= 0.7) return '#e6a23c' + return '#67c23a' +} + +const filteredNics = computed(() => { + if (!hardwareInfo.value?.nic_info) return [] + const skipPrefixes = ['vnet', 'veth', 'ovs-', 'br-int', 'tap'] + return hardwareInfo.value.nic_info + .filter(nic => !skipPrefixes.some(p => nic.name.startsWith(p))) + .map(nic => { + const addrs = nic.addresses || [] + const ipv4List = addrs.filter(a => a.family?.includes('AF_INET') && !a.family?.includes('AF_INET6')).map(a => a.address) + const ipv6List = addrs.filter(a => a.family?.includes('AF_INET6')).map(a => a.address) + const macEntry = addrs.find(a => a.family?.includes('AF_PACKET')) + return { ...nic, _ipv4List: ipv4List, _ipv6List: ipv6List, _mac: macEntry?.address || '' } + }) +}) + // ---- 额度统计 ---- const quotaStats = ref(null) const quotaStatsLoading = ref(false) @@ -1104,21 +1421,20 @@ const latestMetrics = computed(() => { return arr[arr.length - 1] }) -const makeDefaultRange = () => { - const now = new Date() - return [new Date(now.getTime() - 10 * 60 * 1000), now] -} -const monitorDateRange = ref(makeDefaultRange()) +const monitorTimeMode = ref('relative') +const monitorRelativeMinutes = ref(10) +const monitorDateRange = ref(null) -const monitorShortcuts = [ - { text: '最近10分钟', value: () => { const n = new Date(); return [new Date(n.getTime() - 10 * 60000), n] } }, - { text: '最近30分钟', value: () => { const n = new Date(); return [new Date(n.getTime() - 30 * 60000), n] } }, - { text: '最近1小时', value: () => { const n = new Date(); return [new Date(n.getTime() - 3600000), n] } }, - { text: '最近6小时', value: () => { const n = new Date(); return [new Date(n.getTime() - 6 * 3600000), n] } }, - { text: '最近12小时', value: () => { const n = new Date(); return [new Date(n.getTime() - 12 * 3600000), n] } }, - { text: '最近1天', value: () => { const n = new Date(); return [new Date(n.getTime() - 86400000), n] } }, - { text: '最近7天', value: () => { const n = new Date(); return [new Date(n.getTime() - 7 * 86400000), n] } }, -] +const getMonitorTimeRange = () => { + if (monitorTimeMode.value === 'relative') { + const endTime = new Date() + const startTime = new Date(endTime - monitorRelativeMinutes.value * 60 * 1000) + return { startTime, endTime } + } else { + if (!monitorDateRange.value || monitorDateRange.value.length < 2) return null + return { startTime: new Date(monitorDateRange.value[0]), endTime: new Date(monitorDateRange.value[1]) } + } +} function calcInterval(startTime, endTime) { const spanMin = (endTime.getTime() - startTime.getTime()) / 60000 @@ -1136,18 +1452,19 @@ function calcInterval(startTime, endTime) { const intervalLabelMap = { '1m': '1分钟', '3m': '3分钟', '5m': '5分钟', '15m': '15分钟', '30m': '30分钟', '1h': '1小时', '2h': '2小时', '6h': '6小时', '12h': '12小时', '1d': '1天' } const currentIntervalLabel = computed(() => { - if (!monitorDateRange.value || monitorDateRange.value.length < 2) return '-' - const iv = calcInterval(new Date(monitorDateRange.value[0]), new Date(monitorDateRange.value[1])) + const range = getMonitorTimeRange() + if (!range) return '-' + const iv = calcInterval(range.startTime, range.endTime) return intervalLabelMap[iv] || iv }) const loadHistoricalMetrics = async () => { if (!serviceId.value || !hostId.value) return - if (!monitorDateRange.value || monitorDateRange.value.length < 2) return + const range = getMonitorTimeRange() + if (!range) return historicalMetricsLoading.value = true try { - const startTime = new Date(monitorDateRange.value[0]) - const endTime = new Date(monitorDateRange.value[1]) + const { startTime, endTime } = range const interval = calcInterval(startTime, endTime) const params = { service_id: serviceId.value, @@ -1187,7 +1504,8 @@ const renderHistoricalCharts = () => { const metrics = historicalMetricsData.value if (!Array.isArray(metrics) || !metrics.length) return - const spanMs = monitorDateRange.value ? (new Date(monitorDateRange.value[1]).getTime() - new Date(monitorDateRange.value[0]).getTime()) : 0 + const range = getMonitorTimeRange() + const spanMs = range ? (range.endTime.getTime() - range.startTime.getTime()) : 0 const showDate = spanMs >= 12 * 3600 * 1000 const symbolType = spanMs >= 7 * 86400 * 1000 ? 'circle' : 'none' const labelRotate = showDate ? 45 : 0 @@ -1618,6 +1936,7 @@ const initPage = () => { historicalMetricsData.value = null disposeCharts() loadDetail() + loadHostMetrics() if (activeTab.value === 'monitor') loadHistoricalMetrics() } @@ -1726,4 +2045,80 @@ onBeforeUnmount(() => { isPageActive = false; disposeCharts() }) .quota-disk-detail { font-size: 12px; color: #86909c; flex: 1; text-align: right; } .quota-disk-pct { font-size: 13px; font-weight: 600; min-width: 48px; text-align: right; } +/* 实时监控 */ +.sec-icon { width: 18px; height: 18px; vertical-align: -3px; margin-right: 4px; } +.rt-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px; } +.rt-card { display: flex; gap: 14px; background: #fff; border: 1px solid #ebeef5; border-radius: 10px; padding: 18px 20px; transition: box-shadow .2s, border-color .2s; } +.rt-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,.06); border-color: #d9ecff; } +.rt-card-icon { flex-shrink: 0; width: 44px; height: 44px; border-radius: 10px; display: flex; align-items: center; justify-content: center; color: #fff; } +.cpu-icon { background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%); } +.mem-icon { background: linear-gradient(135deg, #67c23a 0%, #85ce61 100%); } +.load-icon { background: linear-gradient(135deg, #e6a23c 0%, #f0c78a 100%); } +.net-icon { background: linear-gradient(135deg, #909399 0%, #b1b3b8 100%); } +.rt-card-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; } +.rt-label { font-size: 12px; color: #86909c; font-weight: 500; } +.rt-value { font-size: 26px; font-weight: 700; line-height: 1.1; } +.rt-value small { font-size: 14px; font-weight: 500; margin-left: 1px; } +.rt-sub { font-size: 11px; color: #a8abb2; margin-top: 2px; } + +.load-pills { display: flex; gap: 8px; margin: 4px 0 2px; } +.load-pill { display: inline-flex; align-items: center; gap: 4px; background: #f7f8fa; border: 1px solid #ebeef5; border-radius: 6px; padding: 4px 10px; font-size: 15px; font-weight: 700; color: var(--lc, #1d2129); } +.load-pill em { font-style: normal; font-size: 10px; font-weight: 500; color: #a8abb2; text-transform: uppercase; } + +.net-bw-row { display: flex; gap: 16px; margin: 6px 0 2px; } +.net-bw-item { display: inline-flex; align-items: center; gap: 5px; font-size: 16px; font-weight: 700; color: #1d2129; } + +.rt-subtitle { font-size: 13px; font-weight: 600; color: #606266; margin: 20px 0 10px; display: flex; align-items: center; gap: 6px; } +.disk-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 10px; } +.disk-item { background: #fff; border: 1px solid #ebeef5; border-radius: 8px; padding: 12px 16px; transition: box-shadow .2s; } +.disk-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,.04); } +.disk-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } +.disk-path { font-size: 13px; font-family: 'Consolas','Monaco',monospace; color: #1d2129; background: #f0f2f5; padding: 2px 8px; border-radius: 4px; } +.disk-detail { font-size: 12px; color: #86909c; flex: 1; text-align: right; } +.disk-pct { font-size: 13px; font-weight: 700; min-width: 44px; text-align: right; } + +/* 硬件信息 */ +.hw-overview { display: flex; flex-wrap: wrap; gap: 0; background: #f7f8fa; border-radius: 8px; border: 1px solid #ebeef5; margin-bottom: 16px; overflow: hidden; } +.hw-ov-item { flex: 1; min-width: 180px; display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-right: 1px solid #ebeef5; } +.hw-ov-item:last-child { border-right: none; } +.hw-ov-item div { display: flex; flex-direction: column; min-width: 0; } +.hw-ov-item em { font-style: normal; font-size: 11px; color: #a8abb2; line-height: 1; margin-bottom: 2px; } +.hw-ov-item div { font-size: 13px; color: #1d2129; font-weight: 500; word-break: break-all; } + +.hw-spec-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 12px; margin-bottom: 16px; } +.hw-spec-card { display: flex; gap: 14px; align-items: flex-start; background: #fff; border: 1px solid #ebeef5; border-radius: 10px; padding: 16px 20px; } +.hw-spec-icon { flex-shrink: 0; width: 40px; height: 40px; background: #f0f7ff; border-radius: 8px; display: flex; align-items: center; justify-content: center; } +.hw-spec-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.hw-spec-label { font-size: 11px; color: #a8abb2; } +.hw-spec-main { font-size: 14px; font-weight: 600; color: #1d2129; word-break: break-word; } +.hw-spec-detail { font-size: 12px; color: #86909c; } + +.hw-subtitle { font-size: 13px; font-weight: 600; color: #606266; margin: 16px 0 10px; display: flex; align-items: center; gap: 6px; } + +.hw-disk-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 10px; } +.hw-disk-card { display: flex; align-items: center; gap: 14px; background: #fff; border: 1px solid #ebeef5; border-radius: 10px; padding: 14px 18px; transition: box-shadow .2s; } +.hw-disk-card:hover { box-shadow: 0 2px 12px rgba(0,0,0,.05); } +.hw-disk-icon { flex-shrink: 0; width: 44px; height: 44px; background: #f0f7ff; border-radius: 10px; display: flex; align-items: center; justify-content: center; } +.hw-disk-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.hw-disk-name { font-size: 14px; font-weight: 600; color: #1d2129; font-family: 'Consolas','Monaco',monospace; } +.hw-disk-model { font-size: 12px; color: #86909c; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.hw-disk-meta { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; flex-shrink: 0; } +.hw-disk-size { font-size: 15px; font-weight: 700; color: #1d2129; } + +.nic-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 10px; } +.nic-card { background: #fff; border: 1px solid #ebeef5; border-radius: 10px; padding: 14px 18px; transition: box-shadow .2s; } +.nic-card:hover { box-shadow: 0 2px 12px rgba(0,0,0,.05); } +.nic-card.nic-down { opacity: .55; } +.nic-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; } +.nic-name { font-size: 14px; font-weight: 600; color: #1d2129; } +.nic-tag { margin-left: auto; } +.nic-speed { font-size: 12px; color: #86909c; font-weight: 500; } +.nic-body { display: flex; flex-direction: column; gap: 4px; } +.nic-row { display: flex; align-items: center; gap: 8px; } +.nic-k { font-size: 11px; color: #a8abb2; min-width: 32px; font-weight: 500; } +.nic-addr-list { display: flex; flex-wrap: wrap; gap: 4px; } +.nic-addr { display: inline-block; background: #f0f7ff; border: 1px solid #d9ecff; border-radius: 4px; padding: 1px 8px; font-size: 12px; font-family: 'Consolas','Monaco',monospace; color: #409eff; } +.nic-addr.v6 { background: #fdf6ec; border-color: #faecd8; color: #e6a23c; font-size: 11px; } +.nic-mac { font-size: 12px; color: #606266; } + diff --git a/src/views/virtualization/HostTreeManage.vue b/src/views/virtualization/HostTreeManage.vue index 39dc89c..d64f9eb 100644 --- a/src/views/virtualization/HostTreeManage.vue +++ b/src/views/virtualization/HostTreeManage.vue @@ -22,8 +22,10 @@ :header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }">