feat: init pc-monitor project
- Client: Go-based Windows hardware monitoring (CPU, GPU, memory, disk, network, power) - Server: Go + Gin + SQLite backend with REST API - Frontend: Vue 3 + Element Plus dashboard - Docker deployment support - Windows service installation script
This commit is contained in:
13
web/index.html
Normal file
13
web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PC Monitor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
25
web/package.json
Normal file
25
web/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "pc-monitor-web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.3.0",
|
||||
"element-plus": "^2.7.0",
|
||||
"axios": "^1.6.8",
|
||||
"echarts": "^5.5.0",
|
||||
"vue-echarts": "^6.6.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"typescript": "^5.4.0",
|
||||
"vite": "^5.2.0",
|
||||
"vue-tsc": "^2.0.6"
|
||||
}
|
||||
}
|
||||
91
web/src/App.vue
Normal file
91
web/src/App.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<el-container class="app-container">
|
||||
<el-aside width="200px">
|
||||
<div class="logo">
|
||||
<h2>PC Monitor</h2>
|
||||
</div>
|
||||
<el-menu
|
||||
:default-active="route.path"
|
||||
router
|
||||
class="el-menu-vertical"
|
||||
>
|
||||
<el-menu-item index="/">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
<span>Dashboard</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/devices">
|
||||
<el-icon><Cpu /></el-icon>
|
||||
<span>Devices</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/alerts">
|
||||
<el-icon><Bell /></el-icon>
|
||||
<span>Alerts</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-main>
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Monitor, Cpu, Bell } from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
</script>
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.el-aside {
|
||||
background-color: #304156;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #263445;
|
||||
}
|
||||
|
||||
.logo h2 {
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.el-menu {
|
||||
border-right: none;
|
||||
background-color: #304156;
|
||||
}
|
||||
|
||||
.el-menu-item {
|
||||
color: #bfcbd9;
|
||||
}
|
||||
|
||||
.el-menu-item:hover,
|
||||
.el-menu-item.is-active {
|
||||
background-color: #263445;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.el-main {
|
||||
background-color: #f0f2f5;
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
29
web/src/api/index.ts
Normal file
29
web/src/api/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 10000
|
||||
})
|
||||
|
||||
// Device APIs
|
||||
export const getDevices = () => api.get('/devices')
|
||||
export const getDevice = (id: string) => api.get(`/devices/${id}`)
|
||||
export const deleteDevice = (id: string) => api.delete(`/devices/${id}`)
|
||||
|
||||
// Metrics APIs
|
||||
export const getLatestMetrics = (deviceId: string) => api.get(`/devices/${deviceId}/metrics/latest`)
|
||||
export const getMetricsHistory = (deviceId: string, start?: string, end?: string) => {
|
||||
const params: any = {}
|
||||
if (start) params.start = start
|
||||
if (end) params.end = end
|
||||
return api.get(`/devices/${deviceId}/metrics/history`, { params })
|
||||
}
|
||||
|
||||
// Alert APIs
|
||||
export const getActiveAlerts = () => api.get('/alerts')
|
||||
export const getAlertRules = (deviceId: string) => api.get(`/devices/${deviceId}/alerts/rules`)
|
||||
export const createAlertRule = (rule: any) => api.post('/alerts/rules', rule)
|
||||
export const deleteAlertRule = (id: string) => api.delete(`/alerts/rules/${id}`)
|
||||
export const resolveAlert = (id: string) => api.post(`/alerts/${id}/resolve`)
|
||||
|
||||
export default api
|
||||
7
web/src/env.d.ts
vendored
Normal file
7
web/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
10
web/src/main.ts
Normal file
10
web/src/main.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(ElementPlus)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
29
web/src/router/index.ts
Normal file
29
web/src/router/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Dashboard',
|
||||
component: () => import('../views/Dashboard.vue')
|
||||
},
|
||||
{
|
||||
path: '/devices',
|
||||
name: 'DeviceList',
|
||||
component: () => import('../views/DeviceList.vue')
|
||||
},
|
||||
{
|
||||
path: '/devices/:id',
|
||||
name: 'DeviceDetail',
|
||||
component: () => import('../views/DeviceDetail.vue')
|
||||
},
|
||||
{
|
||||
path: '/alerts',
|
||||
name: 'Alerts',
|
||||
component: () => import('../views/Alerts.vue')
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
export default router
|
||||
249
web/src/views/Alerts.vue
Normal file
249
web/src/views/Alerts.vue
Normal file
@@ -0,0 +1,249 @@
|
||||
<template>
|
||||
<div class="alerts">
|
||||
<el-row :gutter="20">
|
||||
<!-- Alert Rules -->
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>Alert Rules</span>
|
||||
<el-button type="primary" size="small" @click="showAddRule = true">
|
||||
Add Rule
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="rules" style="width: 100%">
|
||||
<el-table-column prop="device_id" label="Device" />
|
||||
<el-table-column prop="metric" label="Metric" />
|
||||
<el-table-column prop="operator" label="Operator" />
|
||||
<el-table-column prop="threshold" label="Threshold" />
|
||||
<el-table-column label="Actions">
|
||||
<template #default="{ row }">
|
||||
<el-popconfirm
|
||||
title="Are you sure to delete this rule?"
|
||||
@confirm="handleDeleteRule(row.id)"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button size="small" type="danger">Delete</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- Active Alerts -->
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>Active Alerts</span>
|
||||
</template>
|
||||
|
||||
<div v-if="alerts.length === 0" class="no-alerts">
|
||||
No active alerts
|
||||
</div>
|
||||
<div v-else class="alert-list">
|
||||
<div v-for="alert in alerts" :key="alert.id" class="alert-item">
|
||||
<div class="alert-header">
|
||||
<el-tag :type="getAlertType(alert.metric)" size="small">
|
||||
{{ alert.metric }}
|
||||
</el-tag>
|
||||
<span class="alert-time">{{ formatTime(alert.created_at) }}</span>
|
||||
</div>
|
||||
<div class="alert-message">{{ alert.message }}</div>
|
||||
<div class="alert-actions">
|
||||
<el-button size="small" type="success" @click="handleResolve(alert.id)">
|
||||
Resolve
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- Add Rule Dialog -->
|
||||
<el-dialog v-model="showAddRule" title="Add Alert Rule" width="500px">
|
||||
<el-form :model="newRule" label-width="120px">
|
||||
<el-form-item label="Device">
|
||||
<el-select v-model="newRule.device_id" placeholder="Select device">
|
||||
<el-option
|
||||
v-for="device in devices"
|
||||
:key="device.id"
|
||||
:label="device.hostname"
|
||||
:value="device.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Metric">
|
||||
<el-select v-model="newRule.metric" placeholder="Select metric">
|
||||
<el-option label="CPU Usage" value="cpu_usage" />
|
||||
<el-option label="CPU Temperature" value="cpu_temperature" />
|
||||
<el-option label="Memory Usage" value="memory_usage" />
|
||||
<el-option label="GPU Usage" value="gpu_usage" />
|
||||
<el-option label="GPU Temperature" value="gpu_temperature" />
|
||||
<el-option label="Battery Level" value="battery_level" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Operator">
|
||||
<el-select v-model="newRule.operator" placeholder="Select operator">
|
||||
<el-option label="Greater than (>)" value=">" />
|
||||
<el-option label="Greater or equal (>=)" value=">=" />
|
||||
<el-option label="Less than (<)" value="<" />
|
||||
<el-option label="Less or equal (<=)" value="<=" />
|
||||
<el-option label="Equal (==)" value="==" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Threshold">
|
||||
<el-input-number v-model="newRule.threshold" :min="0" :max="1000" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Duration (s)">
|
||||
<el-input-number v-model="newRule.duration" :min="0" :max="3600" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showAddRule = false">Cancel</el-button>
|
||||
<el-button type="primary" @click="handleAddRule">Add</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getDevices, getActiveAlerts, getAlertRules, createAlertRule, deleteAlertRule, resolveAlert } from '../api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const devices = ref<any[]>([])
|
||||
const rules = ref<any[]>([])
|
||||
const alerts = ref<any[]>([])
|
||||
const showAddRule = ref(false)
|
||||
|
||||
const newRule = ref({
|
||||
device_id: '',
|
||||
metric: '',
|
||||
operator: '>',
|
||||
threshold: 0,
|
||||
duration: 0
|
||||
})
|
||||
|
||||
const formatTime = (time: string) => {
|
||||
if (!time) return 'N/A'
|
||||
return new Date(time).toLocaleString()
|
||||
}
|
||||
|
||||
const getAlertType = (metric: string) => {
|
||||
if (metric.includes('cpu') || metric.includes('gpu')) return 'warning'
|
||||
if (metric.includes('memory')) return 'danger'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
const handleAddRule = async () => {
|
||||
try {
|
||||
await createAlertRule(newRule.value)
|
||||
showAddRule.value = false
|
||||
ElMessage.success('Rule added successfully')
|
||||
await loadRules()
|
||||
} catch (error) {
|
||||
ElMessage.error('Failed to add rule')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteRule = async (id: string) => {
|
||||
try {
|
||||
await deleteAlertRule(id)
|
||||
rules.value = rules.value.filter(r => r.id !== id)
|
||||
ElMessage.success('Rule deleted successfully')
|
||||
} catch (error) {
|
||||
ElMessage.error('Failed to delete rule')
|
||||
}
|
||||
}
|
||||
|
||||
const handleResolve = async (id: string) => {
|
||||
try {
|
||||
await resolveAlert(id)
|
||||
alerts.value = alerts.value.filter(a => a.id !== id)
|
||||
ElMessage.success('Alert resolved')
|
||||
} catch (error) {
|
||||
ElMessage.error('Failed to resolve alert')
|
||||
}
|
||||
}
|
||||
|
||||
const loadRules = async () => {
|
||||
try {
|
||||
// Load rules for all devices
|
||||
const allRules: any[] = []
|
||||
for (const device of devices.value) {
|
||||
const res = await getAlertRules(device.id)
|
||||
allRules.push(...(res.data.rules || []))
|
||||
}
|
||||
rules.value = allRules
|
||||
} catch (error) {
|
||||
console.error('Failed to load rules:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [devicesRes, alertsRes] = await Promise.all([
|
||||
getDevices(),
|
||||
getActiveAlerts()
|
||||
])
|
||||
devices.value = devicesRes.data.devices || []
|
||||
alerts.value = alertsRes.data.alerts || []
|
||||
await loadRules()
|
||||
} catch (error) {
|
||||
console.error('Failed to load alerts data:', error)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.no-alerts {
|
||||
text-align: center;
|
||||
color: #909399;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.alert-list {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.alert-item {
|
||||
padding: 12px;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.alert-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.alert-time {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.alert-message {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.alert-actions {
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
181
web/src/views/Dashboard.vue
Normal file
181
web/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<el-row :gutter="20" class="stat-cards">
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>Total Devices</span>
|
||||
<el-icon><Monitor /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
<div class="stat-value">{{ devices.length }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>Online</span>
|
||||
<el-icon style="color: #67c23a"><CircleCheck /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
<div class="stat-value" style="color: #67c23a">{{ onlineCount }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>Offline</span>
|
||||
<el-icon style="color: #f56c6c"><CircleClose /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
<div class="stat-value" style="color: #f56c6c">{{ offlineCount }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>Active Alerts</span>
|
||||
<el-icon style="color: #e6a23c"><Bell /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
<div class="stat-value" style="color: #e6a23c">{{ alerts.length }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20" style="margin-top: 20px">
|
||||
<el-col :span="16">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>Device Overview</span>
|
||||
</template>
|
||||
<el-table :data="devices" style="width: 100%">
|
||||
<el-table-column prop="hostname" label="Hostname" />
|
||||
<el-table-column prop="ip" label="IP Address" />
|
||||
<el-table-column prop="os" label="OS" />
|
||||
<el-table-column label="Status">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'online' ? 'success' : 'danger'">
|
||||
{{ row.status }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Last Report">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.last_report_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Actions">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="viewDevice(row.id)">View</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>Recent Alerts</span>
|
||||
</template>
|
||||
<div v-if="alerts.length === 0" class="no-alerts">No active alerts</div>
|
||||
<div v-else class="alert-list">
|
||||
<div v-for="alert in alerts.slice(0, 5)" :key="alert.id" class="alert-item">
|
||||
<el-tag :type="getAlertType(alert.metric)" size="small">{{ alert.metric }}</el-tag>
|
||||
<span class="alert-message">{{ alert.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Monitor, CircleCheck, CircleClose, Bell } from '@element-plus/icons-vue'
|
||||
import { getDevices, getActiveAlerts } from '../api'
|
||||
|
||||
const router = useRouter()
|
||||
const devices = ref<any[]>([])
|
||||
const alerts = ref<any[]>([])
|
||||
|
||||
const onlineCount = computed(() => devices.value.filter(d => d.status === 'online').length)
|
||||
const offlineCount = computed(() => devices.value.filter(d => d.status === 'offline').length)
|
||||
|
||||
const formatTime = (time: string) => {
|
||||
if (!time) return 'N/A'
|
||||
return new Date(time).toLocaleString()
|
||||
}
|
||||
|
||||
const getAlertType = (metric: string) => {
|
||||
if (metric.includes('cpu') || metric.includes('gpu')) return 'warning'
|
||||
if (metric.includes('memory')) return 'danger'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
const viewDevice = (id: string) => {
|
||||
router.push(`/devices/${id}`)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [devicesRes, alertsRes] = await Promise.all([
|
||||
getDevices(),
|
||||
getActiveAlerts()
|
||||
])
|
||||
devices.value = devicesRes.data.devices || []
|
||||
alerts.value = alertsRes.data.alerts || []
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard data:', error)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-cards {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.no-alerts {
|
||||
text-align: center;
|
||||
color: #909399;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.alert-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.alert-item {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.alert-message {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
</style>
|
||||
409
web/src/views/DeviceDetail.vue
Normal file
409
web/src/views/DeviceDetail.vue
Normal file
@@ -0,0 +1,409 @@
|
||||
<template>
|
||||
<div class="device-detail" v-loading="loading">
|
||||
<el-page-header @back="goBack" :content="device?.hostname || 'Device Detail'" />
|
||||
|
||||
<el-row :gutter="20" style="margin-top: 20px">
|
||||
<!-- Device Info Card -->
|
||||
<el-col :span="8">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>Device Information</span>
|
||||
</template>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="Hostname">{{ device?.hostname }}</el-descriptions-item>
|
||||
<el-descriptions-item label="IP Address">{{ device?.ip }}</el-descriptions-item>
|
||||
<el-descriptions-item label="OS">{{ device?.os }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Status">
|
||||
<el-tag :type="device?.status === 'online' ? 'success' : 'danger'">
|
||||
{{ device?.status }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="Last Report">
|
||||
{{ formatTime(device?.last_report_at) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- Real-time Stats -->
|
||||
<el-col :span="16">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>Real-time Metrics</span>
|
||||
</template>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">CPU Usage</div>
|
||||
<el-progress
|
||||
type="dashboard"
|
||||
:percentage="metrics?.cpu?.usage || 0"
|
||||
:color="getProgressColor(metrics?.cpu?.usage || 0)"
|
||||
/>
|
||||
<div class="metric-detail">Temp: {{ metrics?.cpu?.temperature || 0 }}°C</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Memory Usage</div>
|
||||
<el-progress
|
||||
type="dashboard"
|
||||
:percentage="metrics?.memory?.usage || 0"
|
||||
:color="getProgressColor(metrics?.memory?.usage || 0)"
|
||||
/>
|
||||
<div class="metric-detail">
|
||||
{{ formatBytes(metrics?.memory?.used || 0) }} / {{ formatBytes(metrics?.memory?.total || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">GPU Usage</div>
|
||||
<el-progress
|
||||
type="dashboard"
|
||||
:percentage="metrics?.gpu?.usage || 0"
|
||||
:color="getProgressColor(metrics?.gpu?.usage || 0)"
|
||||
/>
|
||||
<div class="metric-detail">{{ metrics?.gpu?.name || 'N/A' }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- Charts -->
|
||||
<el-row :gutter="20" style="margin-top: 20px">
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>CPU Usage History</span>
|
||||
<el-radio-group v-model="cpuTimeRange" size="small">
|
||||
<el-radio-button label="1h">1H</el-radio-button>
|
||||
<el-radio-button label="24h">24H</el-radio-button>
|
||||
<el-radio-button label="7d">7D</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="cpuChart" style="height: 300px"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>Memory Usage History</span>
|
||||
<el-radio-group v-model="memTimeRange" size="small">
|
||||
<el-radio-button label="1h">1H</el-radio-button>
|
||||
<el-radio-button label="24h">24H</el-radio-button>
|
||||
<el-radio-button label="7d">7D</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="memChart" style="height: 300px"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- Disk and Network -->
|
||||
<el-row :gutter="20" style="margin-top: 20px">
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>Disk Usage</span>
|
||||
</template>
|
||||
<div ref="diskChart" style="height: 300px"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>Network Interfaces</span>
|
||||
</template>
|
||||
<el-table :data="metrics?.network || []" style="width: 100%">
|
||||
<el-table-column prop="name" label="Name" />
|
||||
<el-table-column prop="ip_address" label="IP Address" />
|
||||
<el-table-column label="Status">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_up ? 'success' : 'danger'" size="small">
|
||||
{{ row.is_up ? 'Up' : 'Down' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Sent">
|
||||
<template #default="{ row }">{{ formatBytes(row.bytes_sent) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Received">
|
||||
<template #default="{ row }">{{ formatBytes(row.bytes_recv) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- Power Info -->
|
||||
<el-row :gutter="20" style="margin-top: 20px">
|
||||
<el-col :span="24">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>Power Status</span>
|
||||
</template>
|
||||
<el-descriptions :column="3" border>
|
||||
<el-descriptions-item label="Power Status">
|
||||
<el-tag :type="getPowerStatusType(metrics?.power?.status)">
|
||||
{{ metrics?.power?.status || 'N/A' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="Battery Level">
|
||||
{{ metrics?.power?.battery_level || 0 }}%
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="Power Source">
|
||||
{{ metrics?.power?.power_source || 'N/A' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import * as echarts from 'echarts'
|
||||
import { getDevice, getLatestMetrics, getMetricsHistory } from '../api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const deviceId = route.params.id as string
|
||||
|
||||
const device = ref<any>(null)
|
||||
const metrics = ref<any>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const cpuTimeRange = ref('1h')
|
||||
const memTimeRange = ref('1h')
|
||||
|
||||
const cpuChart = ref<HTMLElement>()
|
||||
const memChart = ref<HTMLElement>()
|
||||
const diskChart = ref<HTMLElement>()
|
||||
|
||||
let cpuChartInstance: echarts.ECharts | null = null
|
||||
let memChartInstance: echarts.ECharts | null = null
|
||||
let diskChartInstance: echarts.ECharts | null = null
|
||||
|
||||
const formatTime = (time: string) => {
|
||||
if (!time) return 'N/A'
|
||||
return new Date(time).toLocaleString()
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const getProgressColor = (percentage: number) => {
|
||||
if (percentage < 50) return '#67c23a'
|
||||
if (percentage < 80) return '#e6a23c'
|
||||
return '#f56c6c'
|
||||
}
|
||||
|
||||
const getPowerStatusType = (status: string) => {
|
||||
switch (status) {
|
||||
case 'charging': return 'success'
|
||||
case 'full': return 'success'
|
||||
case 'discharging': return 'warning'
|
||||
default: return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/devices')
|
||||
}
|
||||
|
||||
const initCharts = () => {
|
||||
if (cpuChart.value) {
|
||||
cpuChartInstance = echarts.init(cpuChart.value)
|
||||
}
|
||||
if (memChart.value) {
|
||||
memChartInstance = echarts.init(memChart.value)
|
||||
}
|
||||
if (diskChart.value) {
|
||||
diskChartInstance = echarts.init(diskChart.value)
|
||||
}
|
||||
}
|
||||
|
||||
const updateCpuChart = (data: any[]) => {
|
||||
if (!cpuChartInstance) return
|
||||
|
||||
const option = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map(d => new Date(d.timestamp).toLocaleTimeString())
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
max: 100,
|
||||
axisLabel: { formatter: '{value}%' }
|
||||
},
|
||||
series: [{
|
||||
name: 'CPU Usage',
|
||||
type: 'line',
|
||||
data: data.map(d => d.cpu_usage),
|
||||
smooth: true,
|
||||
areaStyle: { opacity: 0.3 }
|
||||
}]
|
||||
}
|
||||
|
||||
cpuChartInstance.setOption(option)
|
||||
}
|
||||
|
||||
const updateMemChart = (data: any[]) => {
|
||||
if (!memChartInstance) return
|
||||
|
||||
const option = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map(d => new Date(d.timestamp).toLocaleTimeString())
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
max: 100,
|
||||
axisLabel: { formatter: '{value}%' }
|
||||
},
|
||||
series: [{
|
||||
name: 'Memory Usage',
|
||||
type: 'line',
|
||||
data: data.map(d => d.memory_usage),
|
||||
smooth: true,
|
||||
areaStyle: { opacity: 0.3 }
|
||||
}]
|
||||
}
|
||||
|
||||
memChartInstance.setOption(option)
|
||||
}
|
||||
|
||||
const updateDiskChart = (disks: any[]) => {
|
||||
if (!diskChartInstance || !disks || disks.length === 0) return
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{b}: {c} GB ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
left: 'left'
|
||||
},
|
||||
series: [{
|
||||
name: 'Disk Usage',
|
||||
type: 'pie',
|
||||
radius: '50%',
|
||||
data: disks.map(d => ({
|
||||
name: d.mount_point,
|
||||
value: Math.round(d.total / (1024 * 1024 * 1024))
|
||||
})),
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)'
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
diskChartInstance.setOption(option)
|
||||
}
|
||||
|
||||
const loadHistoryData = async (timeRange: string, type: 'cpu' | 'mem') => {
|
||||
const now = new Date()
|
||||
let start = new Date()
|
||||
|
||||
switch (timeRange) {
|
||||
case '1h':
|
||||
start.setHours(now.getHours() - 1)
|
||||
break
|
||||
case '24h':
|
||||
start.setDate(now.getDate() - 1)
|
||||
break
|
||||
case '7d':
|
||||
start.setDate(now.getDate() - 7)
|
||||
break
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await getMetricsHistory(deviceId, start.toISOString(), now.toISOString())
|
||||
const data = res.data.metrics || []
|
||||
if (type === 'cpu') {
|
||||
updateCpuChart(data)
|
||||
} else {
|
||||
updateMemChart(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load history:', error)
|
||||
}
|
||||
}
|
||||
|
||||
watch(cpuTimeRange, (val) => loadHistoryData(val, 'cpu'))
|
||||
watch(memTimeRange, (val) => loadHistoryData(val, 'mem'))
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const [deviceRes, metricsRes] = await Promise.all([
|
||||
getDevice(deviceId),
|
||||
getLatestMetrics(deviceId)
|
||||
])
|
||||
device.value = deviceRes.data.device
|
||||
metrics.value = metricsRes.data.metrics
|
||||
|
||||
await nextTick()
|
||||
initCharts()
|
||||
|
||||
if (metrics.value?.disks) {
|
||||
updateDiskChart(metrics.value.disks)
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
loadHistoryData('1h', 'cpu'),
|
||||
loadHistoryData('1h', 'mem')
|
||||
])
|
||||
} catch (error) {
|
||||
console.error('Failed to load device data:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 16px;
|
||||
color: #606266;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.metric-detail {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
120
web/src/views/DeviceList.vue
Normal file
120
web/src/views/DeviceList.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<div class="device-list">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>Devices</span>
|
||||
<el-input
|
||||
v-model="search"
|
||||
placeholder="Search devices..."
|
||||
style="width: 300px"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="filteredDevices" style="width: 100%" v-loading="loading">
|
||||
<el-table-column prop="hostname" label="Hostname" sortable />
|
||||
<el-table-column prop="ip" label="IP Address" />
|
||||
<el-table-column prop="os" label="Operating System" />
|
||||
<el-table-column label="Status" sortable>
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'online' ? 'success' : 'danger'">
|
||||
{{ row.status }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Registered At">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.registered_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Last Report" sortable>
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.last_report_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Actions" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" @click="viewDevice(row.id)">
|
||||
View Details
|
||||
</el-button>
|
||||
<el-popconfirm
|
||||
title="Are you sure to delete this device?"
|
||||
@confirm="handleDelete(row.id)"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button size="small" type="danger">Delete</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { getDevices, deleteDevice } from '../api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const devices = ref<any[]>([])
|
||||
const search = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const filteredDevices = computed(() => {
|
||||
if (!search.value) return devices.value
|
||||
const keyword = search.value.toLowerCase()
|
||||
return devices.value.filter(d =>
|
||||
d.hostname.toLowerCase().includes(keyword) ||
|
||||
d.ip.includes(keyword)
|
||||
)
|
||||
})
|
||||
|
||||
const formatTime = (time: string) => {
|
||||
if (!time) return 'N/A'
|
||||
return new Date(time).toLocaleString()
|
||||
}
|
||||
|
||||
const viewDevice = (id: string) => {
|
||||
router.push(`/devices/${id}`)
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteDevice(id)
|
||||
devices.value = devices.value.filter(d => d.id !== id)
|
||||
ElMessage.success('Device deleted successfully')
|
||||
} catch (error) {
|
||||
ElMessage.error('Failed to delete device')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getDevices()
|
||||
devices.value = res.data.devices || []
|
||||
} catch (error) {
|
||||
console.error('Failed to load devices:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
21
web/tsconfig.json
Normal file
21
web/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
web/tsconfig.node.json
Normal file
10
web/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
18
web/vite.config.ts
Normal file
18
web/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: '../server/web/dist',
|
||||
emptyOutDir: true
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user