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:
672
2026-05-17 01:29:44 +08:00
commit 0e8c9f7bff
49 changed files with 3291 additions and 0 deletions

13
web/index.html Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>

View 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>

View 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
View 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
View 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
View 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
}
})