PingWatch 网络设备离线监控系统

- FastAPI 后端 + Vue 3 前端
- Docker Compose 一键部署
- Casdoor OAuth 认证集成
- LogHive 集中式日志
- 设备批量 CSV 导入/导出
- WebSocket 实时状态推送
- 企业微信告警通知
- fping 高性能并发 Ping 检测

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-09 15:02:04 +08:00
commit 848f804169
55 changed files with 5941 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
# 构建阶段
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
# 运行阶段
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PingWatch - 网络设备监控</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Vue 路由历史模式支持
location / {
try_files $uri $uri/ /index.html;
}
# API 反向代理
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# WebSocket 反向代理
location /ws {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
}
+1865
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "pingwatch-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"pinia": "^2.1.0",
"axios": "^1.7.0",
"echarts": "^5.5.0",
"vue-echarts": "^7.0.0",
"element-plus": "^2.7.0",
"@element-plus/icons-vue": "^2.3.0",
"dayjs": "^1.11.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.4.0"
}
}
+14
View File
@@ -0,0 +1,14 @@
<template>
<router-view />
</template>
<script setup>
</script>
<style>
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #f5f7fa;
}
</style>
+110
View File
@@ -0,0 +1,110 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
const api = axios.create({
baseURL: '/api',
timeout: 15000,
})
// 请求拦截器:注入 token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// 响应拦截器:统一错误处理
api.interceptors.response.use(
(response) => response.data,
(error) => {
if (error.response) {
const { status, data } = error.response
if (status === 401) {
localStorage.removeItem('token')
localStorage.removeItem('user')
router.push('/login')
ElMessage.error('登录已过期,请重新登录')
} else if (status === 403) {
ElMessage.error('权限不足')
} else {
ElMessage.error(data?.detail || `请求失败 (${status})`)
}
} else {
ElMessage.error('网络错误')
}
return Promise.reject(error)
}
)
// ========== 认证 ==========
export const authApi = {
login(code) {
return api.post('/auth/login', { code })
},
getMe() {
return api.get('/auth/me')
},
}
// ========== 设备 ==========
export const deviceApi = {
list(params) {
return api.get('/devices', { params })
},
get(id) {
return api.get(`/devices/${id}`)
},
create(data) {
return api.post('/devices', data)
},
update(id, data) {
return api.put(`/devices/${id}`, data)
},
delete(id) {
return api.delete(`/devices/${id}`)
},
downloadTemplate() {
return api.get('/devices/template/download', { responseType: 'blob' })
},
importDevices(file) {
const formData = new FormData()
formData.append('file', file)
return api.post('/devices/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
}
// ========== 告警 ==========
export const alertApi = {
list(params) {
return api.get('/alerts', { params })
},
getLatest(limit = 10) {
return api.get('/alerts/latest', { params: { limit } })
},
}
// ========== 统计 ==========
export const statsApi = {
getSummary() {
return api.get('/stats/summary')
},
getDashboard() {
return api.get('/stats/dashboard')
},
getOfflineTrend(days = 7) {
return api.get('/stats/offline-trend', { params: { days } })
},
getOnlineRateTrend(days = 7) {
return api.get('/stats/online-rate-trend', { params: { days } })
},
getPacketLossTop(limit = 10, days = 7) {
return api.get('/stats/packet-loss-top', { params: { limit, days } })
},
}
export default api
+19
View File
@@ -0,0 +1,19 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
// 注册所有 Element Plus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
app.mount('#app')
+63
View File
@@ -0,0 +1,63 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
},
{
path: '/',
component: () => import('@/views/Layout.vue'),
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { title: '仪表盘' },
},
{
path: 'devices',
name: 'Devices',
component: () => import('@/views/Devices.vue'),
meta: { title: '设备列表' },
},
{
path: 'alerts',
name: 'Alerts',
component: () => import('@/views/Alerts.vue'),
meta: { title: '告警记录' },
},
{
path: 'stats',
name: 'Stats',
component: () => import('@/views/Stats.vue'),
meta: { title: '统计分析' },
},
{
path: 'settings',
name: 'Settings',
component: () => import('@/views/Settings.vue'),
meta: { title: '系统设置', adminOnly: true },
},
],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
// 路由守卫:检查登录
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (to.name !== 'Login' && !token) {
next({ name: 'Login' })
} else {
next()
}
})
export default router
+85
View File
@@ -0,0 +1,85 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { authApi, statsApi } from '@/api'
export const useAppStore = defineStore('app', () => {
// 用户状态
const user = ref(JSON.parse(localStorage.getItem('user') || 'null'))
const token = ref(localStorage.getItem('token') || '')
const isLoggedIn = computed(() => !!token.value)
const isAdmin = computed(() => user.value?.role === 'admin')
function setUser(userData, tokenStr) {
user.value = userData
token.value = tokenStr
localStorage.setItem('user', JSON.stringify(userData))
localStorage.setItem('token', tokenStr)
}
function logout() {
user.value = null
token.value = ''
localStorage.removeItem('user')
localStorage.removeItem('token')
}
// 仪表盘统计缓存
const dashboardData = ref(null)
const loading = ref(false)
async function fetchDashboard() {
loading.value = true
try {
dashboardData.value = await statsApi.getDashboard()
} finally {
loading.value = false
}
}
// WebSocket 连接
let ws = null
function connectWebSocket() {
if (ws) return
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${protocol}//${location.host}/ws`
ws = new WebSocket(wsUrl)
ws.onopen = () => {
console.log('[WS] 已连接')
}
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
if (data.type === 'device_status_change') {
// 触发 dashboard 刷新
fetchDashboard()
}
} catch (e) {
// ignore
}
}
ws.onclose = () => {
console.log('[WS] 已断开,3秒后重连')
ws = null
setTimeout(() => connectWebSocket(), 3000)
}
// 心跳
setInterval(() => {
if (ws?.readyState === WebSocket.OPEN) {
ws.send('ping')
}
}, 30000)
}
return {
user, token, isLoggedIn, isAdmin,
setUser, logout,
dashboardData, loading, fetchDashboard,
connectWebSocket,
}
})
+130
View File
@@ -0,0 +1,130 @@
<template>
<div class="alerts-page">
<!-- 过滤栏 -->
<el-card shadow="hover">
<el-row :gutter="16">
<el-col :span="5">
<el-select v-model="filterType" placeholder="告警类型" clearable @change="fetchAlerts" style="width:100%">
<el-option label="全部" value="" />
<el-option label="离线" value="offline" />
<el-option label="恢复" value="recovered" />
<el-option label="系统" value="system" />
</el-select>
</el-col>
<el-col :span="5">
<el-select v-model="filterResolved" placeholder="状态" clearable @change="fetchAlerts" style="width:100%">
<el-option label="全部" value="" />
<el-option label="未恢复" :value="false" />
<el-option label="已恢复" :value="true" />
</el-select>
</el-col>
<el-col :span="5">
<el-date-picker v-model="dateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" @change="fetchAlerts" style="width:100%" />
</el-col>
<el-col :span="5" :offset="4" style="text-align: right">
<el-button @click="fetchAlerts"><el-icon><Refresh /></el-icon> 刷新</el-button>
</el-col>
</el-row>
</el-card>
<!-- 告警列表 -->
<el-card shadow="hover" style="margin-top: 16px">
<el-table :data="alerts" stripe v-loading="loading" max-height="calc(100vh - 280px)">
<el-table-column type="index" label="#" width="50" />
<el-table-column prop="device_name" label="设备" width="130" />
<el-table-column prop="device_ip" label="IP" width="130" />
<el-table-column prop="device_type" label="类型" width="80">
<template #default="{ row }">
{{ typeMap[row.device_type] || row.device_type }}
</template>
</el-table-column>
<el-table-column prop="location" label="位置" width="130" show-overflow-tooltip />
<el-table-column prop="alert_type" label="告警类型" width="80">
<template #default="{ row }">
<el-tag :type="row.alert_type === 'offline' ? 'danger' : row.alert_type === 'recovered' ? 'success' : 'info'" size="small">
{{ row.alert_type === 'offline' ? '离线' : row.alert_type === 'recovered' ? '恢复' : '系统' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="duration_minutes" label="离线时长(分)" width="100" align="center">
<template #default="{ row }">
{{ row.duration_minutes ?? '-' }}
</template>
</el-table-column>
<el-table-column prop="is_resolved" label="是否恢复" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.is_resolved ? 'success' : 'danger'" size="small">
{{ row.is_resolved ? '已恢复' : '未恢复' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="时间" width="160">
<template #default="{ row }">
{{ formatTime(row.created_at) }}
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
@change="fetchAlerts"
/>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { alertApi } from '@/api'
import dayjs from 'dayjs'
const alerts = ref([])
const loading = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const filterType = ref('')
const filterResolved = ref('')
const dateRange = ref(null)
const typeMap = { server: '服务器', olt: 'OLT', switch: '交换机', firewall: '防火墙', other: '其他' }
async function fetchAlerts() {
loading.value = true
try {
const params = { page: page.value, page_size: pageSize.value }
if (filterType.value) params.alert_type = filterType.value
if (filterResolved.value !== '') params.is_resolved = filterResolved.value
if (dateRange.value) {
params.start_time = dayjs(dateRange.value[0]).startOf('day').toISOString()
params.end_time = dayjs(dateRange.value[1]).endOf('day').toISOString()
}
const data = await alertApi.list(params)
alerts.value = data.items
total.value = data.total
} finally {
loading.value = false
}
}
onMounted(fetchAlerts)
function formatTime(t) {
return t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
}
</script>
<style scoped>
.pagination-wrap {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
</style>
+183
View File
@@ -0,0 +1,183 @@
<template>
<div class="dashboard">
<!-- 状态卡片 -->
<el-row :gutter="20" class="stat-cards">
<el-col :span="6">
<el-card shadow="hover" class="stat-card total">
<div class="stat-value">{{ summary?.total || 0 }}</div>
<div class="stat-label">总设备数</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card online">
<div class="stat-value">{{ summary?.online || 0 }}</div>
<div class="stat-label">在线</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card offline">
<div class="stat-value">{{ summary?.offline || 0 }}</div>
<div class="stat-label">离线</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card rate">
<div class="stat-value">{{ summary?.online_rate || 0 }}%</div>
<div class="stat-label">在线率</div>
</el-card>
</el-col>
</el-row>
<!-- 离线趋势 & 丢包率排名 -->
<el-row :gutter="20" style="margin-top: 20px">
<el-col :span="14">
<el-card shadow="hover">
<template #header>
<span>离线趋势近7天</span>
</template>
<v-chart :option="trendOption" style="height: 320px" autoresize />
</el-card>
</el-col>
<el-col :span="10">
<el-card shadow="hover">
<template #header>
<span>丢包率排名 TOP 10</span>
</template>
<el-table :data="topLossDevices" stripe size="small" max-height="320">
<el-table-column prop="device_name" label="设备" width="100" show-overflow-tooltip />
<el-table-column prop="packet_loss_rate" label="丢包率" width="80">
<template #default="{ row }">
<el-tag :type="row.packet_loss_rate > 10 ? 'danger' : row.packet_loss_rate > 3 ? 'warning' : 'success'" size="small">
{{ row.packet_loss_rate }}%
</el-tag>
</template>
</el-table-column>
<el-table-column prop="offline_count" label="离线次数" width="80" />
</el-table>
<div v-if="!topLossDevices.length" class="empty-hint">暂无数据</div>
</el-card>
</el-col>
</el-row>
<!-- 最近告警 -->
<el-card shadow="hover" style="margin-top: 20px">
<template #header>
<div class="card-header">
<span>最近告警</span>
<el-button text type="primary" @click="$router.push('/alerts')">查看全部</el-button>
</div>
</template>
<el-table :data="recentAlerts" stripe max-height="300">
<el-table-column prop="device_name" label="设备" width="140" />
<el-table-column prop="device_ip" label="IP" width="130" />
<el-table-column prop="alert_type" label="类型" width="80">
<template #default="{ row }">
<el-tag :type="row.alert_type === 'offline' ? 'danger' : 'success'" size="small">
{{ row.alert_type === 'offline' ? '离线' : '恢复' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="duration_minutes" label="时长(分)" width="80" />
<el-table-column prop="location" label="位置" width="120" show-overflow-tooltip />
<el-table-column prop="created_at" label="时间" width="160">
<template #default="{ row }">
{{ formatTime(row.created_at) }}
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useAppStore } from '@/stores/app'
import { statsApi } from '@/api'
import dayjs from 'dayjs'
const store = useAppStore()
const summary = ref(null)
const recentAlerts = ref([])
const topLossDevices = ref([])
const offlineTrend = ref([])
onMounted(() => {
fetchData()
})
watch(() => store.dashboardData, (data) => {
if (data) {
summary.value = data.summary
recentAlerts.value = data.recent_offline || []
topLossDevices.value = data.packet_loss_top || []
offlineTrend.value = data.offline_trend || []
}
})
async function fetchData() {
await store.fetchDashboard()
if (store.dashboardData) {
summary.value = store.dashboardData.summary
recentAlerts.value = store.dashboardData.recent_offline || []
topLossDevices.value = store.dashboardData.packet_loss_top || []
offlineTrend.value = store.dashboardData.offline_trend || []
}
}
const trendOption = computed(() => ({
tooltip: { trigger: 'axis' },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: {
type: 'category',
data: offlineTrend.value.map(i => dayjs(i.time).format('MM-DD')),
boundaryGap: false,
},
yAxis: { type: 'value', minInterval: 1 },
series: [{
name: '离线次数',
type: 'line',
smooth: true,
data: offlineTrend.value.map(i => i.value),
areaStyle: {
color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
colorStops: [{ offset: 0, color: 'rgba(64,158,255,0.3)' }, { offset: 1, color: 'rgba(64,158,255,0.05)' }] }
},
lineStyle: { color: '#409eff' },
itemStyle: { color: '#409eff' },
}],
}))
function formatTime(t) {
return t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
}
</script>
<style scoped>
.stat-cards .stat-card {
text-align: center;
}
.stat-cards .stat-value {
font-size: 32px;
font-weight: bold;
}
.stat-cards .stat-label {
font-size: 14px;
color: #909399;
margin-top: 8px;
}
.total .stat-value { color: #409eff; }
.online .stat-value { color: #67c23a; }
.offline .stat-value { color: #f56c6c; }
.rate .stat-value { color: #e6a23c; }
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.empty-hint {
text-align: center;
color: #c0c4cc;
padding: 20px;
}
</style>
+320
View File
@@ -0,0 +1,320 @@
<template>
<div class="devices-page">
<!-- 工具栏 -->
<el-card shadow="hover">
<el-row :gutter="16">
<el-col :span="6">
<el-input v-model="search" placeholder="搜索设备名/IP/位置" clearable @input="fetchDevices" />
</el-col>
<el-col :span="4">
<el-select v-model="filterType" placeholder="设备类型" clearable @change="fetchDevices" style="width:100%">
<el-option label="全部" value="" />
<el-option label="服务器" value="server" />
<el-option label="OLT" value="olt" />
<el-option label="交换机" value="switch" />
<el-option label="防火墙" value="firewall" />
<el-option label="其他" value="other" />
</el-select>
</el-col>
<el-col :span="4">
<el-select v-model="filterStatus" placeholder="状态" clearable @change="fetchDevices" style="width:100%">
<el-option label="全部" value="" />
<el-option label="在线" value="online" />
<el-option label="离线" value="offline" />
<el-option label="检测中" value="checking" />
</el-select>
</el-col>
<el-col :span="6" :offset="4" style="text-align: right">
<el-button v-if="store.isAdmin" type="primary" @click="showAddDialog">
<el-icon><Plus /></el-icon> 添加设备
</el-button>
<el-button v-if="store.isAdmin" @click="downloadTemplate">
<el-icon><Download /></el-icon> 下载模板
</el-button>
<el-button v-if="store.isAdmin" @click="showImportDialog">
<el-icon><Upload /></el-icon> 批量导入
</el-button>
<el-button @click="fetchDevices">
<el-icon><Refresh /></el-icon> 刷新
</el-button>
</el-col>
</el-row>
</el-card>
<!-- 设备列表 -->
<el-card shadow="hover" style="margin-top: 16px">
<el-table :data="devices" stripe v-loading="loading" max-height="calc(100vh - 280px)">
<el-table-column prop="name" label="设备名称" width="140" fixed />
<el-table-column prop="ip" label="IP 地址" width="140" />
<el-table-column prop="device_type" label="类型" width="90">
<template #default="{ row }">
{{ typeMap[row.device_type] || row.device_type }}
</template>
</el-table-column>
<el-table-column prop="current_status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="statusTagType(row.current_status)" size="small">
{{ statusMap[row.current_status] || row.current_status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="location" label="位置" width="140" show-overflow-tooltip />
<el-table-column prop="project_name" label="项目" width="140" show-overflow-tooltip />
<el-table-column prop="consecutive_failures" label="连续失败" width="90" align="center">
<template #default="{ row }">
<span :style="{ color: row.consecutive_failures > 0 ? '#f56c6c' : '#67c23a' }">
{{ row.consecutive_failures }}
</span>
</template>
</el-table-column>
<el-table-column prop="last_ping_time" label="最后 Ping" width="160">
<template #default="{ row }">
{{ row.last_ping_time ? dayjs(row.last_ping_time).format('MM-DD HH:mm:ss') : '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right" v-if="store.isAdmin">
<template #default="{ row }">
<el-button text type="primary" size="small" @click="showEditDialog(row)">编辑</el-button>
<el-button text type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 批量导入对话框 -->
<el-dialog v-model="importDialogVisible" title="批量导入设备" width="560px" @closed="importResult = null">
<div style="margin-bottom: 16px; color: #606266; font-size: 14px">
1. <a href="#" @click.prevent="downloadTemplate">下载模板</a>按模板格式填写设备信息<br />
2. 选择填写好的 CSV 文件上传
</div>
<el-upload
ref="uploadRef"
drag
:auto-upload="false"
:limit="1"
accept=".csv"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
>
<el-icon :size="40"><UploadFilled /></el-icon>
<div style="margin-top: 8px"> CSV 文件拖到此处或点击选择</div>
</el-upload>
<template #footer>
<el-button @click="importDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="importing" :disabled="!importFile" @click="handleImport">
开始导入
</el-button>
</template>
<!-- 导入结果 -->
<div v-if="importResult" style="margin-top: 16px">
<el-alert
:title="`成功导入 ${importResult.devices_added} 台设备`"
type="success"
:closable="false"
show-icon
/>
<div v-if="importResult.errors?.length" style="margin-top: 8px">
<p style="color: #f56c6c; font-size: 13px">以下行导入失败</p>
<ul style="color: #909399; font-size: 13px; padding-left: 20px">
<li v-for="(err, i) in importResult.errors" :key="i">{{ err }}</li>
</ul>
</div>
</div>
</el-dialog>
<!-- 添加/编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="isEditing ? '编辑设备' : '添加设备'" width="520px">
<el-form :model="form" label-width="100px" :rules="rules" ref="formRef">
<el-form-item label="设备名称" prop="name">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="IP 地址" prop="ip">
<el-input v-model="form.ip" />
</el-form-item>
<el-form-item label="设备类型" prop="device_type">
<el-select v-model="form.device_type" style="width:100%">
<el-option label="服务器" value="server" />
<el-option label="OLT" value="olt" />
<el-option label="交换机" value="switch" />
<el-option label="防火墙" value="firewall" />
<el-option label="其他" value="other" />
</el-select>
</el-form-item>
<el-form-item label="位置" prop="location">
<el-input v-model="form.location" placeholder="如:县公安局机房" />
</el-form-item>
<el-form-item label="所属项目" prop="project_name">
<el-input v-model="form.project_name" placeholder="如:校园安防4+N项目" />
</el-form-item>
<el-form-item label="离线阈值" prop="alert_threshold">
<el-input-number v-model="form.alert_threshold" :min="1" :max="20" />
<span style="margin-left: 8px; color: #909399; font-size: 13px">连续失败次数</span>
</el-form-item>
<el-form-item label="是否启用">
<el-switch v-model="form.is_enabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useAppStore } from '@/stores/app'
import { deviceApi } from '@/api'
import { ElMessage, ElMessageBox } from 'element-plus'
import dayjs from 'dayjs'
const store = useAppStore()
const devices = ref([])
const loading = ref(false)
const search = ref('')
const filterType = ref('')
const filterStatus = ref('')
// 批量导入
const importDialogVisible = ref(false)
const importFile = ref(null)
const importing = ref(false)
const uploadRef = ref(null)
const importResult = ref(null)
function showImportDialog() {
importDialogVisible.value = true
}
function handleFileChange(file) {
importFile.value = file.raw
importResult.value = null
}
function handleFileRemove() {
importFile.value = null
importResult.value = null
}
async function downloadTemplate() {
try {
const blob = await deviceApi.downloadTemplate()
const url = window.URL.createObjectURL(new Blob([blob]))
const link = document.createElement('a')
link.href = url
link.download = 'device_template.csv'
link.click()
window.URL.revokeObjectURL(url)
} catch {
// error handled by interceptor
}
}
async function handleImport() {
if (!importFile.value) return
importing.value = true
try {
const result = await deviceApi.importDevices(importFile.value)
importResult.value = result
if (result.devices_added > 0) {
ElMessage.success(`成功导入 ${result.devices_added} 台设备`)
fetchDevices()
}
if (result.errors?.length) {
ElMessage.warning(`${result.errors.length} 条数据导入失败`)
}
uploadRef.value?.clearFiles()
importFile.value = null
} finally {
importing.value = false
}
}
const typeMap = { server: '服务器', olt: 'OLT', switch: '交换机', firewall: '防火墙', other: '其他' }
const statusMap = { online: '在线', offline: '离线', checking: '检测中', unknown: '未知' }
function statusTagType(status) {
return { online: 'success', offline: 'danger', checking: 'warning', unknown: 'info' }[status] || 'info'
}
async function fetchDevices() {
loading.value = true
try {
const params = {}
if (search.value) params.search = search.value
if (filterType.value) params.device_type = filterType.value
const list = await deviceApi.list(params)
if (filterStatus.value) {
devices.value = list.filter(d => d.current_status === filterStatus.value)
} else {
devices.value = list
}
} finally {
loading.value = false
}
}
onMounted(fetchDevices)
// 添加/编辑对话框
const dialogVisible = ref(false)
const isEditing = ref(false)
const editingId = ref(null)
const saving = ref(false)
const formRef = ref(null)
const form = ref({
name: '', ip: '', device_type: 'server', location: '', project_name: '',
alert_threshold: 5, is_enabled: true,
})
const rules = {
name: [{ required: true, message: '请输入设备名称' }],
ip: [{ required: true, message: '请输入 IP 地址' }],
device_type: [{ required: true, message: '请选择设备类型' }],
}
function showAddDialog() {
isEditing.value = false
editingId.value = null
form.value = { name: '', ip: '', device_type: 'server', location: '', project_name: '', alert_threshold: 5, is_enabled: true }
dialogVisible.value = true
}
function showEditDialog(device) {
isEditing.value = true
editingId.value = device.id
form.value = { ...device }
dialogVisible.value = true
}
async function handleSave() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
if (isEditing.value) {
await deviceApi.update(editingId.value, form.value)
ElMessage.success('更新成功')
} else {
await deviceApi.create(form.value)
ElMessage.success('添加成功')
}
dialogVisible.value = false
fetchDevices()
} finally {
saving.value = false
}
}
async function handleDelete(device) {
await ElMessageBox.confirm(`确定删除设备「${device.name}」吗?`, '警告', { type: 'warning' })
await deviceApi.delete(device.id)
ElMessage.success('删除成功')
fetchDevices()
}
</script>
+177
View File
@@ -0,0 +1,177 @@
<template>
<el-container class="layout-container">
<!-- 侧边栏 -->
<el-aside :width="isCollapse ? '64px' : '220px'" class="sidebar">
<div class="sidebar-header">
<span v-if="!isCollapse" class="sidebar-title">PingWatch</span>
<el-icon v-else :size="24"><Monitor /></el-icon>
</div>
<el-menu
:default-active="route.path"
:collapse="isCollapse"
:router="true"
background-color="#1d1e1f"
text-color="#bfcbd9"
active-text-color="#409eff"
>
<el-menu-item index="/dashboard">
<el-icon><DataBoard /></el-icon>
<span>仪表盘</span>
</el-menu-item>
<el-menu-item index="/devices">
<el-icon><Monitor /></el-icon>
<span>设备列表</span>
</el-menu-item>
<el-menu-item index="/alerts">
<el-icon><WarningFilled /></el-icon>
<span>告警记录</span>
</el-menu-item>
<el-menu-item index="/stats">
<el-icon><TrendCharts /></el-icon>
<span>统计分析</span>
</el-menu-item>
<el-menu-item v-if="store.isAdmin" index="/settings">
<el-icon><Setting /></el-icon>
<span>系统设置</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<!-- 顶部栏 -->
<el-header class="header">
<div class="header-left">
<el-icon
:size="20"
class="collapse-btn"
@click="isCollapse = !isCollapse"
>
<Fold v-if="!isCollapse" />
<Expand v-else />
</el-icon>
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/dashboard' }">首页</el-breadcrumb-item>
<el-breadcrumb-item v-if="route.meta.title">
{{ route.meta.title }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<el-dropdown @command="handleCommand">
<span class="user-info">
{{ store.user?.display_name || store.user?.username }}
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">
<el-icon><User /></el-icon>个人信息
</el-dropdown-item>
<el-dropdown-item command="logout" divided>
<el-icon><SwitchButton /></el-icon>退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<!-- 主体内容 -->
<el-main class="main-content">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { ElMessage, ElMessageBox } from 'element-plus'
const route = useRoute()
const router = useRouter()
const store = useAppStore()
const isCollapse = ref(false)
onMounted(() => {
store.connectWebSocket()
})
function handleCommand(command) {
if (command === 'logout') {
ElMessageBox.confirm('确定要退出登录吗?', '提示').then(() => {
store.logout()
router.push('/login')
ElMessage.success('已退出')
}).catch(() => {})
}
}
</script>
<style scoped>
.layout-container {
height: 100vh;
}
.sidebar {
background-color: #1d1e1f;
transition: width 0.3s;
overflow: hidden;
}
.sidebar-header {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 20px;
font-weight: bold;
border-bottom: 1px solid #333;
}
.sidebar-title {
letter-spacing: 2px;
}
.header {
background: white;
border-bottom: 1px solid #e4e7ed;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
height: 60px;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.collapse-btn {
cursor: pointer;
color: #606266;
}
.collapse-btn:hover {
color: #409eff;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.user-info {
cursor: pointer;
color: #606266;
display: flex;
align-items: center;
gap: 4px;
}
.user-info:hover {
color: #409eff;
}
.main-content {
background: #f5f7fa;
padding: 20px;
overflow-y: auto;
}
</style>
+123
View File
@@ -0,0 +1,123 @@
<template>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<h1>PingWatch</h1>
<p>网络设备离线监控系统</p>
</div>
<el-button
type="primary"
size="large"
:loading="loading"
@click="handleLogin"
class="login-btn"
>
<el-icon style="margin-right: 8px"><User /></el-icon>
Casdoor 统一登录
</el-button>
<div class="login-footer">
首次登录将自动创建账号
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { authApi } from '@/api'
import { ElMessage } from 'element-plus'
const router = useRouter()
const route = useRoute()
const store = useAppStore()
const loading = ref(false)
// Casdoor 配置
const CASDOOR_ENDPOINT = 'https://casdoor.dhdx.fun'
const CLIENT_ID = 'e46b9e1eb893027bdf2a'
const ORGANIZATION = 'dahua'
const APPLICATION = 'PingWatch'
const REDIRECT_URI = `${window.location.origin}/login`
const SCOPE = 'openid profile email'
onMounted(() => {
// 检查是否从 Casdoor 回调回来(带 code 参数)
const code = route.query.code
if (code) {
handleCallback(code)
}
})
async function handleLogin() {
// 构造 Casdoor OAuth 授权 URL 并跳转
const params = new URLSearchParams({
client_id: CLIENT_ID,
response_type: 'code',
redirect_uri: REDIRECT_URI,
scope: SCOPE,
state: generateState(),
organization: ORGANIZATION,
application: APPLICATION,
})
const authorizeUrl = `${CASDOOR_ENDPOINT}/login/oauth/authorize?${params.toString()}`
window.location.href = authorizeUrl
}
async function handleCallback(code) {
loading.value = true
try {
const res = await authApi.login(code)
store.setUser(res.user, res.token)
ElMessage.success('登录成功')
router.push('/dashboard')
} catch (e) {
// 错误已在 axios 拦截器中处理
loading.value = false
}
}
/** 生成随机 state,防止 CSRF */
function generateState() {
const array = new Uint8Array(16)
crypto.getRandomValues(array)
return Array.from(array, b => b.toString(16).padStart(2, '0')).join('')
}
</script>
<style scoped>
.login-container {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
background: white;
border-radius: 16px;
padding: 48px;
text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
min-width: 380px;
}
.login-header h1 {
margin: 0;
font-size: 28px;
color: #303133;
}
.login-header p {
color: #909399;
margin: 8px 0 32px;
}
.login-btn {
width: 100%;
font-size: 16px;
}
.login-footer {
margin-top: 24px;
font-size: 13px;
color: #c0c4cc;
}
</style>
+111
View File
@@ -0,0 +1,111 @@
<template>
<div class="settings-page">
<!-- 用户管理 -->
<el-card shadow="hover">
<template #header>
<span>用户管理</span>
</template>
<el-table :data="users" stripe v-loading="loading">
<el-table-column prop="username" label="用户名" width="140" />
<el-table-column prop="display_name" label="显示名称" width="140" />
<el-table-column prop="role" label="角色" width="100">
<template #default="{ row }">
<el-tag :type="row.role === 'admin' ? 'danger' : 'info'" size="small">
{{ row.role === 'admin' ? '管理员' : '查看者' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="last_login_at" label="最后登录" width="160">
<template #default="{ row }">
{{ row.last_login_at ? dayjs(row.last_login_at).format('YYYY-MM-DD HH:mm') : '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="160">
<template #default="{ row }">
<el-button
text
type="primary"
size="small"
@click="toggleRole(row)"
:disabled="row.id === currentUserId"
>
{{ row.role === 'admin' ? '设为查看者' : '设为管理员' }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 系统配置 -->
<el-card shadow="hover" style="margin-top: 20px">
<template #header>
<div class="card-header">
<span>数据保留设置</span>
</div>
</template>
<el-form label-width="150px">
<el-form-item label="Ping 记录保留">
<el-input-number v-model="pingRetention" :min="7" :max="365" />
</el-form-item>
<el-form-item label="告警记录保留">
<el-input-number v-model="alertRetention" :min="30" :max="730" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveConfig">保存设置</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useAppStore } from '@/stores/app'
import { ElMessage, ElMessageBox } from 'element-plus'
import dayjs from 'dayjs'
import api from '@/api'
const store = useAppStore()
const currentUserId = computed(() => store.user?.id)
const users = ref([])
const loading = ref(false)
const pingRetention = ref(90)
const alertRetention = ref(365)
onMounted(() => {
fetchUsers()
})
async function fetchUsers() {
loading.value = true
try {
users.value = await api.get('/users')
} finally {
loading.value = false
}
}
async function toggleRole(user) {
const newRole = user.role === 'admin' ? 'viewer' : 'admin'
await ElMessageBox.confirm(
`确定将「${user.display_name || user.username}${newRole === 'admin' ? '设为管理员' : '降为查看者'}`,
'提示'
)
await api.put(`/users/${user.id}/role`, { role: newRole })
ElMessage.success('已更新')
fetchUsers()
}
function saveConfig() {
ElMessage.success('设置已保存(需要在后端更新配置)')
}
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
+153
View File
@@ -0,0 +1,153 @@
<template>
<div class="stats-page">
<!-- 在线率趋势 -->
<el-card shadow="hover">
<template #header>
<div class="card-header">
<span>在线率趋势</span>
<el-radio-group v-model="rateDays" size="small" @change="fetchRateTrend">
<el-radio-button :value="7">7</el-radio-button>
<el-radio-button :value="30">30</el-radio-button>
<el-radio-button :value="90">90</el-radio-button>
</el-radio-group>
</div>
</template>
<v-chart :option="rateTrendOption" style="height: 360px" autoresize />
</el-card>
<el-row :gutter="20" style="margin-top: 20px">
<!-- 离线趋势 -->
<el-col :span="12">
<el-card shadow="hover">
<template #header>
<div class="card-header">
<span>离线次数趋势</span>
<el-radio-group v-model="offlineDays" size="small" @change="fetchOfflineTrend">
<el-radio-button :value="7">7</el-radio-button>
<el-radio-button :value="30">30</el-radio-button>
<el-radio-button :value="90">90</el-radio-button>
</el-radio-group>
</div>
</template>
<v-chart :option="offlineTrendOption" style="height: 300px" autoresize />
</el-card>
</el-col>
<!-- 丢包率排名 -->
<el-col :span="12">
<el-card shadow="hover">
<template #header>
<div class="card-header">
<span>丢包率排名</span>
<el-radio-group v-model="lossDays" size="small" @change="fetchLossTop">
<el-radio-button :value="7">7</el-radio-button>
<el-radio-button :value="30">30</el-radio-button>
<el-radio-button :value="90">90</el-radio-button>
</el-radio-group>
</div>
</template>
<el-table :data="lossTop" stripe size="small" max-height="300">
<el-table-column type="index" label="#" width="40" />
<el-table-column prop="device_name" label="设备" width="100" show-overflow-tooltip />
<el-table-column prop="packet_loss_rate" label="丢包率" width="80">
<template #default="{ row }">
<el-tag :type="row.packet_loss_rate > 10 ? 'danger' : 'warning'" size="small">
{{ row.packet_loss_rate }}%
</el-tag>
</template>
</el-table-column>
<el-table-column prop="offline_count" label="离线次数" width="80" />
<el-table-column prop="total_offline_duration" label="总离线(分)" width="90" />
<el-table-column prop="avg_response_time" label="平均响应(ms)" width="100">
<template #default="{ row }">
{{ row.avg_response_time ?? '-' }}
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { statsApi } from '@/api'
import dayjs from 'dayjs'
const rateDays = ref(7)
const offlineDays = ref(7)
const lossDays = ref(7)
const rateTrend = ref([])
const offlineTrend = ref([])
const lossTop = ref([])
onMounted(() => {
fetchRateTrend()
fetchOfflineTrend()
fetchLossTop()
})
async function fetchRateTrend() {
const data = await statsApi.getOnlineRateTrend(rateDays.value)
rateTrend.value = data
}
async function fetchOfflineTrend() {
const data = await statsApi.getOfflineTrend(offlineDays.value)
offlineTrend.value = data
}
async function fetchLossTop() {
const data = await statsApi.getPacketLossTop(10, lossDays.value)
lossTop.value = data
}
const rateTrendOption = computed(() => ({
tooltip: { trigger: 'axis', valueFormatter: (v) => `${v}%` },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: {
type: 'category',
data: rateTrend.value.map(i => dayjs(i.time).format('MM-DD')),
boundaryGap: false,
},
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { formatter: '{value}%' } },
series: [{
name: '在线率',
type: 'line',
smooth: true,
data: rateTrend.value.map(i => i.value),
areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
colorStops: [{ offset: 0, color: 'rgba(103,194,58,0.3)' }, { offset: 1, color: 'rgba(103,194,58,0.05)' }] } },
lineStyle: { color: '#67c23a' },
itemStyle: { color: '#67c23a' },
}],
}))
const offlineTrendOption = computed(() => ({
tooltip: { trigger: 'axis' },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: {
type: 'category',
data: offlineTrend.value.map(i => dayjs(i.time).format('MM-DD')),
boundaryGap: false,
},
yAxis: { type: 'value', minInterval: 1 },
series: [{
name: '离线次数',
type: 'bar',
data: offlineTrend.value.map(i => i.value),
itemStyle: { color: '#f56c6c', borderRadius: [4, 4, 0, 0] },
}],
}))
// computed for lossTop chart (optional - table suffices)
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
+28
View File
@@ -0,0 +1,28 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
root: __dirname,
plugins: [vue()],
resolve: {
preserveSymlinks: true,
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
ws: true,
},
'/ws': {
target: 'ws://localhost:8000',
ws: true,
},
},
},
})