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

37
.gitignore vendored Normal file
View File

@@ -0,0 +1,37 @@
# Go
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
go.work
# Vendor
vendor/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Data
data/
*.db
# Node
node_modules/
dist/
# Logs
*.log
# Config with secrets
client/config.yaml

34
Dockerfile.server Normal file
View File

@@ -0,0 +1,34 @@
FROM golang:1.21-alpine AS builder
WORKDIR /app
# Install build dependencies
RUN apk add --no-cache gcc musl-dev
# Copy server code
COPY server/ .
# Download dependencies
RUN go mod download
# Build the server
RUN CGO_ENABLED=1 GOOS=linux go build -o server -ldflags="-s -w" .
# Final stage
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
# Copy binary from builder
COPY --from=builder /app/server .
# Create data directory
RUN mkdir -p /app/data
# Expose port
EXPOSE 8080
# Run the server
CMD ["./server"]

122
README.md Normal file
View File

@@ -0,0 +1,122 @@
# PC Monitor
A monitoring system for Windows PCs that tracks CPU, GPU, memory, network, disk, and power status.
## Architecture
- **Client**: Windows executable (.exe) that collects hardware metrics
- **Server**: Go backend with SQLite database
- **Frontend**: Vue 3 + Element Plus web dashboard
- **Deployment**: Docker support for server
## Quick Start
### Server (Docker)
1. Build and start the server:
```bash
docker-compose up -d
```
2. Access the web dashboard at http://localhost:8080
### Client (Windows)
1. Download the client executable
2. Edit `config.yaml` to set your server URL
3. Run the client:
```powershell
.\pc-monitor-client.exe
```
Or install as a Windows service:
```powershell
.\install\install.ps1 -ServerUrl "http://your-server:8080"
```
## Development
### Prerequisites
- Go 1.21+
- Node.js 18+
- Docker (optional)
### Build Server
```bash
cd server
go mod tidy
go build -o server .
```
### Build Client
```bash
cd client
go mod tidy
go build -o pc-monitor-client.exe .
```
### Build Frontend
```bash
cd web
npm install
npm run build
```
## API Endpoints
### Device Management
- `POST /api/v1/register` - Register a new device
- `GET /api/v1/devices` - List all devices
- `GET /api/v1/devices/:id` - Get device details
- `DELETE /api/v1/devices/:id` - Delete a device
- `POST /api/v1/devices/:id/heartbeat` - Send heartbeat
### Metrics
- `POST /api/v1/report` - Report metrics
- `GET /api/v1/devices/:id/metrics/latest` - Get latest metrics
- `GET /api/v1/devices/:id/metrics/history` - Get metrics history
### Alerts
- `GET /api/v1/alerts` - List active alerts
- `POST /api/v1/alerts/rules` - Create alert rule
- `GET /api/v1/devices/:id/alerts/rules` - Get device alert rules
- `DELETE /api/v1/alerts/rules/:id` - Delete alert rule
- `POST /api/v1/alerts/:id/resolve` - Resolve alert
## Configuration
### Server Configuration (config.yaml)
```yaml
server:
addr: ":8080"
database:
path: "./data/monitor.db"
retention_days: 30
auth:
admin_password: "admin123"
```
### Client Configuration (config.yaml)
```yaml
server:
url: "http://your-server:8080"
token: ""
collect:
interval: 30s
report:
interval: 60s
```
## License
MIT License

14
client/build.bat Normal file
View File

@@ -0,0 +1,14 @@
@echo off
echo Building PC Monitor Client for Windows...
set GOOS=windows
set GOARCH=amd64
go build -o pc-monitor-client.exe -ldflags="-s -w" .
if %ERRORLEVEL% EQU 0 (
echo Build successful: pc-monitor-client.exe
) else (
echo Build failed!
exit /b 1
)

View File

@@ -0,0 +1,61 @@
package collector
import (
"time"
)
type Metrics struct {
DeviceID string `json:"device_id"`
Timestamp time.Time `json:"timestamp"`
CPU *CPUInfo `json:"cpu"`
Memory *MemoryInfo `json:"memory"`
GPU *GPUInfo `json:"gpu"`
Disks []DiskInfo `json:"disks"`
Net []NetInterface `json:"network"`
Power *PowerInfo `json:"power"`
}
func CollectAll() (*Metrics, error) {
metrics := &Metrics{
Timestamp: time.Now(),
}
// Collect CPU
cpuInfo, err := CollectCPU()
if err == nil {
metrics.CPU = cpuInfo
}
// Collect Memory
memInfo, err := CollectMemory()
if err == nil {
metrics.Memory = memInfo
}
// Collect GPU
gpuInfo, err := CollectGPU()
if err == nil {
metrics.GPU = gpuInfo
}
// Collect Disks
disks, err := CollectDisks()
if err == nil {
metrics.Disks = disks
}
// Collect Network
nets, err := CollectNetwork()
if err == nil {
metrics.Net = nets
}
// Collect Power
powerInfo, err := CollectPower()
if err == nil {
metrics.Power = powerInfo
}
return metrics, nil
}

41
client/collector/cpu.go Normal file
View File

@@ -0,0 +1,41 @@
package collector
import (
"github.com/shirou/gopsutil/v3/cpu"
"github.com/shirou/gopsutil/v3/host"
)
type CPUInfo struct {
Usage float64 `json:"usage"`
Temperature float64 `json:"temperature"`
CoreUsage []float64 `json:"core_usage"`
}
func CollectCPU() (*CPUInfo, error) {
info := &CPUInfo{}
// Get overall CPU usage
percent, err := cpu.Percent(0, false)
if err == nil && len(percent) > 0 {
info.Usage = percent[0]
}
// Get per-core CPU usage
perCPU, err := cpu.Percent(0, true)
if err == nil {
info.CoreUsage = perCPU
}
// Get CPU temperature
temps, err := host.SensorsTemperatures()
if err == nil {
for _, temp := range temps {
if temp.Temperature > 0 {
info.Temperature = temp.Temperature
break
}
}
}
return info, nil
}

38
client/collector/disk.go Normal file
View File

@@ -0,0 +1,38 @@
package collector
import (
"github.com/shirou/gopsutil/v3/disk"
)
type DiskInfo struct {
MountPoint string `json:"mount_point"`
Total uint64 `json:"total"`
Used uint64 `json:"used"`
Usage float64 `json:"usage"`
FileSystem string `json:"file_system"`
}
func CollectDisks() ([]DiskInfo, error) {
partitions, err := disk.Partitions(false)
if err != nil {
return nil, err
}
var disks []DiskInfo
for _, p := range partitions {
usage, err := disk.Usage(p.Mountpoint)
if err != nil {
continue
}
disks = append(disks, DiskInfo{
MountPoint: p.Mountpoint,
Total: usage.Total,
Used: usage.Used,
Usage: usage.UsedPercent,
FileSystem: p.Fstype,
})
}
return disks, nil
}

80
client/collector/gpu.go Normal file
View File

@@ -0,0 +1,80 @@
package collector
import (
"encoding/json"
"os/exec"
"strings"
)
type GPUInfo struct {
Usage float64 `json:"usage"`
Temperature float64 `json:"temperature"`
MemoryTotal uint64 `json:"memory_total"`
MemoryUsed uint64 `json:"memory_used"`
Name string `json:"name"`
}
type nvidiaSMIOutput struct {
GPUs []struct {
Name string `json:"name"`
Utilization struct {
GPU string `json:"gpu"`
} `json:"utilization"`
Temperature struct {
GPU string `json:"gpu_temp"`
} `json:"temperature"`
Memory struct {
Total string `json:"total"`
Used string `json:"used"`
} `json:"fb_memory_usage"`
} `json:"gpus"`
}
func CollectGPU() (*GPUInfo, error) {
// Try NVIDIA GPU first
info, err := collectNvidiaGPU()
if err == nil && info != nil {
return info, nil
}
// Return empty GPU info if no GPU detected
return &GPUInfo{
Name: "No GPU detected",
}, nil
}
func collectNvidiaGPU() (*GPUInfo, error) {
cmd := exec.Command("nvidia-smi", "--query-gpu=name,utilization.gpu,temperature.gpu,memory.total,memory.used", "--format=csv,noheader,nounits")
output, err := cmd.Output()
if err != nil {
return nil, err
}
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(lines) == 0 {
return nil, nil
}
fields := strings.Split(lines[0], ",")
if len(fields) < 5 {
return nil, nil
}
info := &GPUInfo{
Name: strings.TrimSpace(fields[0]),
}
// Parse values (ignore errors, use defaults)
var usage, temp, memTotal, memUsed float64
json.Unmarshal([]byte(strings.TrimSpace(fields[1])), &usage)
json.Unmarshal([]byte(strings.TrimSpace(fields[2])), &temp)
json.Unmarshal([]byte(strings.TrimSpace(fields[3])), &memTotal)
json.Unmarshal([]byte(strings.TrimSpace(fields[4])), &memUsed)
info.Usage = usage
info.Temperature = temp
info.MemoryTotal = uint64(memTotal) * 1024 * 1024 // Convert MB to bytes
info.MemoryUsed = uint64(memUsed) * 1024 * 1024
return info, nil
}

View File

@@ -0,0 +1,24 @@
package collector
import (
"github.com/shirou/gopsutil/v3/mem"
)
type MemoryInfo struct {
Total uint64 `json:"total"`
Used uint64 `json:"used"`
Usage float64 `json:"usage"`
}
func CollectMemory() (*MemoryInfo, error) {
vmStat, err := mem.VirtualMemory()
if err != nil {
return nil, err
}
return &MemoryInfo{
Total: vmStat.Total,
Used: vmStat.Used,
Usage: vmStat.UsedPercent,
}, nil
}

View File

@@ -0,0 +1,68 @@
package collector
import (
"github.com/shirou/gopsutil/v3/net"
)
type NetInterface struct {
Name string `json:"name"`
MACAddress string `json:"mac_address"`
IPAddress string `json:"ip_address"`
BytesSent uint64 `json:"bytes_sent"`
BytesRecv uint64 `json:"bytes_recv"`
Speed uint64 `json:"speed"`
IsUp bool `json:"is_up"`
}
func CollectNetwork() ([]NetInterface, error) {
interfaces, err := net.Interfaces()
if err != nil {
return nil, err
}
counters, err := net.IOCounters(true)
if err != nil {
return nil, err
}
counterMap := make(map[string]net.IOCountersStat)
for _, c := range counters {
counterMap[c.Name] = c
}
var nets []NetInterface
for _, iface := range interfaces {
if iface.Name == "lo" || iface.Name == "Loopback Pseudo-Interface 1" {
continue
}
ni := NetInterface{
Name: iface.Name,
MACAddress: iface.HardwareAddr,
IsUp: false,
}
for _, addr := range iface.Addrs {
if addr.Addr != "" && addr.Addr != "0.0.0.0" {
ni.IPAddress = addr.Addr
break
}
}
for _, flag := range iface.Flags {
if flag == "up" {
ni.IsUp = true
break
}
}
if c, ok := counterMap[iface.Name]; ok {
ni.BytesSent = c.BytesSent
ni.BytesRecv = c.BytesRecv
}
nets = append(nets, ni)
}
return nets, nil
}

47
client/collector/power.go Normal file
View File

@@ -0,0 +1,47 @@
package collector
import (
"github.com/shirou/gopsutil/v3/host"
)
type PowerInfo struct {
Status string `json:"status"`
BatteryLevel int `json:"battery_level"`
PowerSource string `json:"power_source"`
}
func CollectPower() (*PowerInfo, error) {
info := &PowerInfo{
Status: "no_battery",
BatteryLevel: 0,
PowerSource: "ac",
}
// Try to get battery info
battery, err := host.SensorsBattery()
if err != nil {
return info, nil
}
if battery == nil {
return info, nil
}
info.BatteryLevel = int(battery.Percent)
if battery.PowerSupply {
info.PowerSource = "ac"
} else {
info.PowerSource = "battery"
}
if battery.Charging {
info.Status = "charging"
} else if battery.Percent >= 100 {
info.Status = "full"
} else {
info.Status = "discharging"
}
return info, nil
}

65
client/config.go Normal file
View File

@@ -0,0 +1,65 @@
package main
import (
"os"
"time"
"gopkg.in/yaml.v3"
)
type Config struct {
Server ServerConfig `yaml:"server"`
Collect CollectConfig `yaml:"collect"`
Report ReportConfig `yaml:"report"`
}
type ServerConfig struct {
URL string `yaml:"url"`
Token string `yaml:"token"`
}
type CollectConfig struct {
Interval time.Duration `yaml:"interval"`
}
type ReportConfig struct {
Interval time.Duration `yaml:"interval"`
}
func LoadConfig(path string) (*Config, error) {
cfg := &Config{
Server: ServerConfig{
URL: "http://localhost:8080",
},
Collect: CollectConfig{
Interval: 30 * time.Second,
},
Report: ReportConfig{
Interval: 60 * time.Second,
},
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
data, _ = yaml.Marshal(cfg)
os.WriteFile(path, data, 0644)
return cfg, nil
}
return nil, err
}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, err
}
return cfg, nil
}
func SaveConfig(path string, cfg *Config) error {
data, err := yaml.Marshal(cfg)
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}

8
client/go.mod Normal file
View File

@@ -0,0 +1,8 @@
module pc-monitor-client
go 1.21
require (
github.com/shirou/gopsutil/v3 v3.24.5
gopkg.in/yaml.v3 v3.0.1
)

View File

@@ -0,0 +1,87 @@
# PC Monitor Client Installation Script
# Run as Administrator
param(
[string]$ServerUrl = "http://your-server:8080",
[string]$InstallDir = "C:\Program Files\PCMonitor"
)
# Check if running as Administrator
$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
Write-Error "Please run this script as Administrator"
exit 1
}
Write-Host "Installing PC Monitor Client..." -ForegroundColor Green
# Create installation directory
if (-not (Test-Path $InstallDir)) {
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
}
# Copy executable
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$exePath = Join-Path $scriptDir "pc-monitor-client.exe"
if (-not (Test-Path $exePath)) {
Write-Error "pc-monitor-client.exe not found in script directory"
exit 1
}
Copy-Item $exePath -Destination $InstallDir -Force
# Create config file
$configPath = Join-Path $InstallDir "config.yaml"
@"
server:
url: "$ServerUrl"
token: ""
collect:
interval: 30s
report:
interval: 60s
"@ | Out-File -FilePath $configPath -Encoding UTF8
# Create Windows Service using NSSM
$nssmPath = Join-Path $InstallDir "nssm.exe"
if (-not (Test-Path $nssmPath)) {
Write-Host "Downloading NSSM..." -ForegroundColor Yellow
$nssmUrl = "https://nssm.cc/release/nssm-2.24.zip"
$zipPath = Join-Path $InstallDir "nssm.zip"
Invoke-WebRequest -Uri $nssmUrl -OutFile $zipPath
Expand-Archive -Path $zipPath -DestinationPath $InstallDir -Force
$nssmExe = Get-ChildItem -Path $InstallDir -Recurse -Filter "nssm.exe" | Where-Object { $_.FullName -match "win64" } | Select-Object -First 1
if ($nssmExe) {
Copy-Item $nssmExe.FullName -Destination $nssmPath -Force
}
Remove-Item $zipPath -Force
}
# Install service
$serviceName = "PCMonitor"
$clientExe = Join-Path $InstallDir "pc-monitor-client.exe"
& $nssmPath install $serviceName $clientExe
& $nssmPath set $serviceName AppDirectory $InstallDir
& $nssmPath set $serviceName DisplayName "PC Monitor Client"
& $nssmPath set $serviceName Description "Monitors PC hardware metrics and reports to server"
& $nssmPath set $serviceName Start SERVICE_AUTO_START
& $nssmPath set $serviceName AppStdout (Join-Path $InstallDir "stdout.log")
& $nssmPath set $serviceName AppStderr (Join-Path $InstallDir "stderr.log")
# Start service
Start-Service $serviceName
Write-Host ""
Write-Host "Installation complete!" -ForegroundColor Green
Write-Host "Service '$serviceName' installed and started." -ForegroundColor Green
Write-Host ""
Write-Host "Configuration file: $configPath" -ForegroundColor Cyan
Write-Host "Please edit the config file to set your server URL and token." -ForegroundColor Cyan
Write-Host ""
Write-Host "Useful commands:" -ForegroundColor Yellow
Write-Host " Start service: Start-Service $serviceName"
Write-Host " Stop service: Stop-Service $serviceName"
Write-Host " Service status: Get-Service $serviceName"
Write-Host " View logs: Get-Content (Join-Path $InstallDir 'stdout.log') -Tail 50"

123
client/main.go Normal file
View File

@@ -0,0 +1,123 @@
package main
import (
"fmt"
"log"
"net"
"os"
"os/signal"
"runtime"
"syscall"
"time"
"pc-monitor-client/collector"
"pc-monitor-client/reporter"
)
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Println("PC Monitor Client starting...")
// Load config
cfg, err := LoadConfig("config.yaml")
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
// Get hostname and IP
hostname, _ := os.Hostname()
ip := getLocalIP()
// Create reporter
r := reporter.NewReporter(cfg.Server.URL, cfg.Server.Token)
// Register device
log.Printf("Registering device: %s (%s)", hostname, ip)
deviceID, err := r.Register(hostname, runtime.GOOS, ip)
if err != nil {
log.Printf("Warning: Failed to register: %v", err)
log.Println("Will retry on next report...")
} else {
log.Printf("Device registered with ID: %s", deviceID)
}
// Create ticker for collection
collectTicker := time.NewTicker(cfg.Collect.Interval)
defer collectTicker.Stop()
reportTicker := time.NewTicker(cfg.Report.Interval)
defer reportTicker.Stop()
// Create channel for graceful shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
// Last collected metrics
var lastMetrics *collector.Metrics
log.Printf("Collecting metrics every %v", cfg.Collect.Interval)
log.Printf("Reporting metrics every %v", cfg.Report.Interval)
for {
select {
case <-collectTicker.C:
metrics, err := collector.CollectAll()
if err != nil {
log.Printf("Error collecting metrics: %v", err)
continue
}
metrics.DeviceID = deviceID
lastMetrics = metrics
log.Printf("Metrics collected: CPU=%.1f%%, Memory=%.1f%%",
metrics.CPU.Usage, metrics.Memory.Usage)
case <-reportTicker.C:
if lastMetrics == nil {
log.Println("No metrics to report yet")
continue
}
if deviceID == "" {
// Try to register again
deviceID, err = r.Register(hostname, runtime.GOOS, ip)
if err != nil {
log.Printf("Failed to register: %v", err)
continue
}
log.Printf("Device registered with ID: %s", deviceID)
}
if err := r.Report(lastMetrics); err != nil {
log.Printf("Error reporting metrics: %v", err)
} else {
log.Println("Metrics reported successfully")
}
// Send heartbeat
if err := r.Heartbeat(deviceID); err != nil {
log.Printf("Error sending heartbeat: %v", err)
}
case <-sigChan:
log.Println("Shutting down...")
return
}
}
}
func getLocalIP() string {
addrs, err := net.InterfaceAddrs()
if err != nil {
return "unknown"
}
for _, addr := range addrs {
if ipNet, ok := addr.(*net.IPNet); ok && !ipNet.IP.IsLoopback() {
if ipNet.IP.To4() != nil {
return ipNet.IP.String()
}
}
}
return "unknown"
}

119
client/reporter/http.go Normal file
View File

@@ -0,0 +1,119 @@
package reporter
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
type Reporter struct {
serverURL string
token string
client *http.Client
}
func NewReporter(serverURL, token string) *Reporter {
return &Reporter{
serverURL: serverURL,
token: token,
client: &http.Client{
Timeout: 10 * time.Second,
},
}
}
type RegisterRequest struct {
Hostname string `json:"hostname"`
OS string `json:"os"`
IP string `json:"ip"`
}
type RegisterResponse struct {
Device struct {
ID string `json:"id"`
Token string `json:"token"`
} `json:"device"`
}
func (r *Reporter) Register(hostname, osName, ip string) (string, error) {
req := RegisterRequest{
Hostname: hostname,
OS: osName,
IP: ip,
}
body, err := json.Marshal(req)
if err != nil {
return "", err
}
resp, err := r.client.Post(r.serverURL+"/api/v1/register", "application/json", bytes.NewBuffer(body))
if err != nil {
return "", fmt.Errorf("failed to register: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("registration failed: %s", string(respBody))
}
var regResp RegisterResponse
if err := json.NewDecoder(resp.Body).Decode(&regResp); err != nil {
return "", err
}
return regResp.Device.ID, nil
}
func (r *Reporter) Report(metrics interface{}) error {
body, err := json.Marshal(metrics)
if err != nil {
return err
}
req, err := http.NewRequest("POST", r.serverURL+"/api/v1/report", bytes.NewBuffer(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if r.token != "" {
req.Header.Set("Authorization", "Bearer "+r.token)
}
resp, err := r.client.Do(req)
if err != nil {
return fmt.Errorf("failed to report: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("report failed: %s", string(respBody))
}
return nil
}
func (r *Reporter) Heartbeat(deviceID string) error {
req, err := http.NewRequest("POST", r.serverURL+"/api/v1/devices/"+deviceID+"/heartbeat", nil)
if err != nil {
return err
}
if r.token != "" {
req.Header.Set("Authorization", "Bearer "+r.token)
}
resp, err := r.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}

15
docker-compose.yml Normal file
View File

@@ -0,0 +1,15 @@
version: '3.8'
services:
server:
build:
context: .
dockerfile: Dockerfile.server
ports:
- "8080:8080"
volumes:
- ./data:/app/data
- ./config.yaml:/app/config.yaml
environment:
- TZ=Asia/Shanghai
restart: unless-stopped

View File

@@ -0,0 +1,89 @@
package handler
import (
"net/http"
"pc-monitor-server/model"
"pc-monitor-server/service"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type AlertHandler struct {
service *service.AlertService
}
func NewAlertHandler(service *service.AlertService) *AlertHandler {
return &AlertHandler{service: service}
}
type CreateRuleRequest struct {
DeviceID string `json:"device_id" binding:"required"`
Metric string `json:"metric" binding:"required"`
Operator string `json:"operator" binding:"required"`
Threshold float64 `json:"threshold" binding:"required"`
Duration int `json:"duration"`
}
func (h *AlertHandler) CreateRule(c *gin.Context) {
var req CreateRuleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
rule := &model.AlertRule{
ID: uuid.New().String(),
DeviceID: req.DeviceID,
Metric: req.Metric,
Operator: req.Operator,
Threshold: req.Threshold,
Duration: req.Duration,
CreatedAt: time.Now(),
}
if err := h.service.CreateRule(rule); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"rule": rule})
}
func (h *AlertHandler) GetRulesByDevice(c *gin.Context) {
deviceID := c.Param("id")
rules, err := h.service.GetRulesByDevice(deviceID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"rules": rules})
}
func (h *AlertHandler) DeleteRule(c *gin.Context) {
id := c.Param("id")
if err := h.service.DeleteRule(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Rule deleted"})
}
func (h *AlertHandler) GetActiveAlerts(c *gin.Context) {
alerts, err := h.service.GetActiveAlerts()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"alerts": alerts})
}
func (h *AlertHandler) ResolveAlert(c *gin.Context) {
id := c.Param("id")
if err := h.service.ResolveAlert(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Alert resolved"})
}

View File

@@ -0,0 +1,75 @@
package handler
import (
"net/http"
"pc-monitor-server/service"
"github.com/gin-gonic/gin"
)
type DeviceHandler struct {
service *service.DeviceService
}
func NewDeviceHandler(service *service.DeviceService) *DeviceHandler {
return &DeviceHandler{service: service}
}
type RegisterRequest struct {
Hostname string `json:"hostname" binding:"required"`
OS string `json:"os" binding:"required"`
IP string `json:"ip" binding:"required"`
}
func (h *DeviceHandler) Register(c *gin.Context) {
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
device, err := h.service.Register(req.Hostname, req.OS, req.IP)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"device": device})
}
func (h *DeviceHandler) GetAll(c *gin.Context) {
devices, err := h.service.GetAll()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"devices": devices})
}
func (h *DeviceHandler) GetByID(c *gin.Context) {
id := c.Param("id")
device, err := h.service.GetByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Device not found"})
return
}
c.JSON(http.StatusOK, gin.H{"device": device})
}
func (h *DeviceHandler) Delete(c *gin.Context) {
id := c.Param("id")
if err := h.service.Delete(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Device deleted"})
}
func (h *DeviceHandler) Heartbeat(c *gin.Context) {
id := c.Param("id")
if err := h.service.Heartbeat(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "OK"})
}

View File

@@ -0,0 +1,80 @@
package handler
import (
"net/http"
"pc-monitor-server/model"
"pc-monitor-server/service"
"time"
"github.com/gin-gonic/gin"
)
type MetricsHandler struct {
service *service.MetricsService
}
func NewMetricsHandler(service *service.MetricsService) *MetricsHandler {
return &MetricsHandler{service: service}
}
type ReportRequest struct {
DeviceID string `json:"device_id" binding:"required"`
model.Metrics
}
func (h *MetricsHandler) Report(c *gin.Context) {
var req ReportRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
metrics := req.Metrics
metrics.DeviceID = req.DeviceID
metrics.Timestamp = time.Now()
if err := h.service.Save(&metrics); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Metrics saved"})
}
func (h *MetricsHandler) GetLatest(c *gin.Context) {
deviceID := c.Param("id")
metrics, err := h.service.GetLatest(deviceID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "No metrics found"})
return
}
c.JSON(http.StatusOK, gin.H{"metrics": metrics})
}
func (h *MetricsHandler) GetHistory(c *gin.Context) {
deviceID := c.Param("id")
startStr := c.Query("start")
endStr := c.Query("end")
start := time.Now().Add(-1 * time.Hour)
end := time.Now()
if startStr != "" {
if t, err := time.Parse(time.RFC3339, startStr); err == nil {
start = t
}
}
if endStr != "" {
if t, err := time.Parse(time.RFC3339, endStr); err == nil {
end = t
}
}
metrics, err := h.service.GetHistory(deviceID, start, end, 1000)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"metrics": metrics})
}

57
server/api/router.go Normal file
View File

@@ -0,0 +1,57 @@
package api
import (
"pc-monitor-server/api/handler"
"pc-monitor-server/service"
"github.com/gin-gonic/gin"
)
func SetupRouter(deviceService *service.DeviceService, metricsService *service.MetricsService, alertService *service.AlertService) *gin.Engine {
r := gin.Default()
r.Use(corsMiddleware())
api := r.Group("/api/v1")
{
deviceHandler := handler.NewDeviceHandler(deviceService)
metricsHandler := handler.NewMetricsHandler(metricsService)
alertHandler := handler.NewAlertHandler(alertService)
// Device endpoints
api.POST("/register", deviceHandler.Register)
api.GET("/devices", deviceHandler.GetAll)
api.GET("/devices/:id", deviceHandler.GetByID)
api.DELETE("/devices/:id", deviceHandler.Delete)
api.POST("/devices/:id/heartbeat", deviceHandler.Heartbeat)
// Metrics endpoints
api.POST("/report", metricsHandler.Report)
api.GET("/devices/:id/metrics/latest", metricsHandler.GetLatest)
api.GET("/devices/:id/metrics/history", metricsHandler.GetHistory)
// Alert endpoints
api.GET("/alerts", alertHandler.GetActiveAlerts)
api.POST("/alerts/rules", alertHandler.CreateRule)
api.GET("/devices/:id/alerts/rules", alertHandler.GetRulesByDevice)
api.DELETE("/alerts/rules/:id", alertHandler.DeleteRule)
api.POST("/alerts/:id/resolve", alertHandler.ResolveAlert)
}
return r
}
func corsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}

9
server/config.yaml Normal file
View File

@@ -0,0 +1,9 @@
server:
addr: ":8080"
database:
path: "./data/monitor.db"
retention_days: 30
auth:
admin_password: "admin123"

51
server/config/config.go Normal file
View File

@@ -0,0 +1,51 @@
package config
import (
"os"
"time"
"gopkg.in/yaml.v3"
)
type Config struct {
Server ServerConfig `yaml:"server"`
Database DatabaseConfig `yaml:"database"`
Auth AuthConfig `yaml:"auth"`
}
type ServerConfig struct {
Addr string `yaml:"addr"`
}
type DatabaseConfig struct {
Path string `yaml:"path"`
RetentionDays int `yaml:"retention_days"`
CleanupInterval time.Duration `yaml:"cleanup_interval"`
}
type AuthConfig struct {
AdminPassword string `yaml:"admin_password"`
}
func Load() *Config {
cfg := &Config{
Server: ServerConfig{
Addr: ":8080",
},
Database: DatabaseConfig{
Path: "./data/monitor.db",
RetentionDays: 30,
CleanupInterval: 24 * time.Hour,
},
Auth: AuthConfig{
AdminPassword: "admin123",
},
}
data, err := os.ReadFile("config.yaml")
if err == nil {
yaml.Unmarshal(data, cfg)
}
return cfg
}

9
server/go.mod Normal file
View File

@@ -0,0 +1,9 @@
module pc-monitor-server
go 1.21
require (
github.com/gin-gonic/gin v1.9.1
github.com/mattn/go-sqlite3 v1.14.22
gopkg.in/yaml.v3 v3.0.1
)

52
server/main.go Normal file
View File

@@ -0,0 +1,52 @@
package main
import (
"embed"
"io/fs"
"log"
"net/http"
"pc-monitor-server/api"
"pc-monitor-server/config"
"pc-monitor-server/repository"
"pc-monitor-server/service"
"github.com/gin-gonic/gin"
)
//go:embed web/dist
var webFS embed.FS
func main() {
cfg := config.Load()
db, err := repository.NewDB(cfg.Database.Path)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer db.Close()
deviceRepo := repository.NewDeviceRepository(db)
metricsRepo := repository.NewMetricsRepository(db)
alertRepo := repository.NewAlertRepository(db)
deviceService := service.NewDeviceService(deviceRepo)
metricsService := service.NewMetricsService(metricsRepo)
alertService := service.NewAlertService(alertRepo, metricsRepo)
router := api.SetupRouter(deviceService, metricsService, alertService)
// Serve embedded frontend
distFS, err := fs.Sub(webFS, "web/dist")
if err != nil {
log.Fatalf("Failed to load embedded frontend: %v", err)
}
router.StaticFS("/assets", http.FS(distFS))
router.NoRoute(func(c *gin.Context) {
c.FileFromFS("/", http.FS(distFS))
})
log.Printf("Server starting on %s", cfg.Server.Addr)
if err := router.Run(cfg.Server.Addr); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}

25
server/model/alert.go Normal file
View File

@@ -0,0 +1,25 @@
package model
import "time"
type AlertRule struct {
ID string `json:"id"`
DeviceID string `json:"device_id"`
Metric string `json:"metric"`
Operator string `json:"operator"`
Threshold float64 `json:"threshold"`
Duration int `json:"duration"`
CreatedAt time.Time `json:"created_at"`
}
type Alert struct {
ID string `json:"id"`
DeviceID string `json:"device_id"`
RuleID string `json:"rule_id"`
Metric string `json:"metric"`
Value float64 `json:"value"`
Message string `json:"message"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
}

13
server/model/device.go Normal file
View File

@@ -0,0 +1,13 @@
package model
import "time"
type Device struct {
ID string `json:"id"`
Hostname string `json:"hostname"`
OS string `json:"os"`
IP string `json:"ip"`
RegisteredAt time.Time `json:"registered_at"`
LastReportAt time.Time `json:"last_report_at"`
Status string `json:"status"`
}

54
server/model/metrics.go Normal file
View File

@@ -0,0 +1,54 @@
package model
import "time"
type Metrics struct {
DeviceID string `json:"device_id"`
Timestamp time.Time `json:"timestamp"`
// CPU
CPUUsage float64 `json:"cpu_usage"`
CPUTemperature float64 `json:"cpu_temperature"`
CPUCoreUsage []float64 `json:"cpu_core_usage"`
// Memory
MemoryTotal uint64 `json:"memory_total"`
MemoryUsed uint64 `json:"memory_used"`
MemoryUsage float64 `json:"memory_usage"`
// GPU
GPUUsage float64 `json:"gpu_usage"`
GPUTemperature float64 `json:"gpu_temperature"`
GPUMemoryTotal uint64 `json:"gpu_memory_total"`
GPUMemoryUsed uint64 `json:"gpu_memory_used"`
GPUName string `json:"gpu_name"`
// Network
NetworkInterfaces []NetInterface `json:"network_interfaces"`
// Disk
Disks []DiskInfo `json:"disks"`
// Power
PowerStatus string `json:"power_status"`
BatteryLevel int `json:"battery_level"`
PowerSource string `json:"power_source"`
}
type NetInterface struct {
Name string `json:"name"`
MACAddress string `json:"mac_address"`
IPAddress string `json:"ip_address"`
BytesSent uint64 `json:"bytes_sent"`
BytesRecv uint64 `json:"bytes_recv"`
Speed uint64 `json:"speed"`
IsUp bool `json:"is_up"`
}
type DiskInfo struct {
MountPoint string `json:"mount_point"`
Total uint64 `json:"total"`
Used uint64 `json:"used"`
Usage float64 `json:"usage"`
FileSystem string `json:"file_system"`
}

View File

@@ -0,0 +1,88 @@
package repository
import (
"database/sql"
"pc-monitor-server/model"
"time"
)
type AlertRepository struct {
db *sql.DB
}
func NewAlertRepository(db *sql.DB) *AlertRepository {
return &AlertRepository{db: db}
}
func (r *AlertRepository) CreateRule(rule *model.AlertRule) error {
_, err := r.db.Exec(
`INSERT INTO alert_rules (id, device_id, metric, operator, threshold, duration, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
rule.ID, rule.DeviceID, rule.Metric, rule.Operator, rule.Threshold, rule.Duration, rule.CreatedAt,
)
return err
}
func (r *AlertRepository) GetRulesByDevice(deviceID string) ([]model.AlertRule, error) {
rows, err := r.db.Query(
`SELECT id, device_id, metric, operator, threshold, duration, created_at
FROM alert_rules WHERE device_id=?`, deviceID)
if err != nil {
return nil, err
}
defer rows.Close()
var rules []model.AlertRule
for rows.Next() {
var rule model.AlertRule
if err := rows.Scan(&rule.ID, &rule.DeviceID, &rule.Metric, &rule.Operator,
&rule.Threshold, &rule.Duration, &rule.CreatedAt); err != nil {
return nil, err
}
rules = append(rules, rule)
}
return rules, nil
}
func (r *AlertRepository) DeleteRule(id string) error {
_, err := r.db.Exec("DELETE FROM alert_rules WHERE id=?", id)
return err
}
func (r *AlertRepository) CreateAlert(alert *model.Alert) error {
_, err := r.db.Exec(
`INSERT INTO alerts (id, device_id, rule_id, metric, value, message, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
alert.ID, alert.DeviceID, alert.RuleID, alert.Metric,
alert.Value, alert.Message, alert.Status, alert.CreatedAt,
)
return err
}
func (r *AlertRepository) GetActiveAlerts() ([]model.Alert, error) {
rows, err := r.db.Query(
`SELECT id, device_id, rule_id, metric, value, message, status, created_at, resolved_at
FROM alerts WHERE status='active' ORDER BY created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var alerts []model.Alert
for rows.Next() {
var a model.Alert
if err := rows.Scan(&a.ID, &a.DeviceID, &a.RuleID, &a.Metric,
&a.Value, &a.Message, &a.Status, &a.CreatedAt, &a.ResolvedAt); err != nil {
return nil, err
}
alerts = append(alerts, a)
}
return alerts, nil
}
func (r *AlertRepository) ResolveAlert(id string) error {
now := time.Now()
_, err := r.db.Exec(
`UPDATE alerts SET status='resolved', resolved_at=? WHERE id=?`, now, id)
return err
}

94
server/repository/db.go Normal file
View File

@@ -0,0 +1,94 @@
package repository
import (
"database/sql"
"os"
"path/filepath"
_ "github.com/mattn/go-sqlite3"
)
func NewDB(dbPath string) (*sql.DB, error) {
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, err
}
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL")
if err != nil {
return nil, err
}
if err := initSchema(db); err != nil {
db.Close()
return nil, err
}
return db, nil
}
func initSchema(db *sql.DB) error {
schema := `
CREATE TABLE IF NOT EXISTS devices (
id TEXT PRIMARY KEY,
hostname TEXT NOT NULL,
os TEXT NOT NULL,
ip TEXT NOT NULL,
registered_at DATETIME NOT NULL,
last_report_at DATETIME NOT NULL,
status TEXT NOT NULL DEFAULT 'offline'
);
CREATE TABLE IF NOT EXISTS metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
timestamp DATETIME NOT NULL,
cpu_usage REAL,
cpu_temperature REAL,
cpu_core_usage TEXT,
memory_total INTEGER,
memory_used INTEGER,
memory_usage REAL,
gpu_usage REAL,
gpu_temperature REAL,
gpu_memory_total INTEGER,
gpu_memory_used INTEGER,
gpu_name TEXT,
network_interfaces TEXT,
disks TEXT,
power_status TEXT,
battery_level INTEGER,
power_source TEXT,
FOREIGN KEY (device_id) REFERENCES devices(id)
);
CREATE INDEX IF NOT EXISTS idx_metrics_device_time ON metrics(device_id, timestamp);
CREATE TABLE IF NOT EXISTS alert_rules (
id TEXT PRIMARY KEY,
device_id TEXT NOT NULL,
metric TEXT NOT NULL,
operator TEXT NOT NULL,
threshold REAL NOT NULL,
duration INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
FOREIGN KEY (device_id) REFERENCES devices(id)
);
CREATE TABLE IF NOT EXISTS alerts (
id TEXT PRIMARY KEY,
device_id TEXT NOT NULL,
rule_id TEXT NOT NULL,
metric TEXT NOT NULL,
value REAL NOT NULL,
message TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL,
resolved_at DATETIME,
FOREIGN KEY (device_id) REFERENCES devices(id),
FOREIGN KEY (rule_id) REFERENCES alert_rules(id)
);
`
_, err := db.Exec(schema)
return err
}

View File

@@ -0,0 +1,87 @@
package repository
import (
"database/sql"
"pc-monitor-server/model"
"time"
)
type DeviceRepository struct {
db *sql.DB
}
func NewDeviceRepository(db *sql.DB) *DeviceRepository {
return &DeviceRepository{db: db}
}
func (r *DeviceRepository) Create(device *model.Device) error {
_, err := r.db.Exec(
`INSERT INTO devices (id, hostname, os, ip, registered_at, last_report_at, status)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
device.ID, device.Hostname, device.OS, device.IP,
device.RegisteredAt, device.LastReportAt, device.Status,
)
return err
}
func (r *DeviceRepository) Update(device *model.Device) error {
_, err := r.db.Exec(
`UPDATE devices SET hostname=?, os=?, ip=?, last_report_at=?, status=?
WHERE id=?`,
device.Hostname, device.OS, device.IP,
device.LastReportAt, device.Status, device.ID,
)
return err
}
func (r *DeviceRepository) GetByID(id string) (*model.Device, error) {
device := &model.Device{}
err := r.db.QueryRow(
`SELECT id, hostname, os, ip, registered_at, last_report_at, status
FROM devices WHERE id=?`, id,
).Scan(&device.ID, &device.Hostname, &device.OS, &device.IP,
&device.RegisteredAt, &device.LastReportAt, &device.Status)
if err != nil {
return nil, err
}
return device, nil
}
func (r *DeviceRepository) GetAll() ([]model.Device, error) {
rows, err := r.db.Query(
`SELECT id, hostname, os, ip, registered_at, last_report_at, status
FROM devices ORDER BY last_report_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var devices []model.Device
for rows.Next() {
var d model.Device
if err := rows.Scan(&d.ID, &d.Hostname, &d.OS, &d.IP,
&d.RegisteredAt, &d.LastReportAt, &d.Status); err != nil {
return nil, err
}
devices = append(devices, d)
}
return devices, nil
}
func (r *DeviceRepository) Delete(id string) error {
_, err := r.db.Exec("DELETE FROM devices WHERE id=?", id)
return err
}
func (r *DeviceRepository) UpdateStatus(id string, status string) error {
_, err := r.db.Exec("UPDATE devices SET status=? WHERE id=?", status, id)
return err
}
func (r *DeviceRepository) MarkOffline(threshold time.Duration) error {
cutoff := time.Now().Add(-threshold)
_, err := r.db.Exec(
`UPDATE devices SET status='offline'
WHERE last_report_at < ? AND status='online'`, cutoff)
return err
}

View File

@@ -0,0 +1,106 @@
package repository
import (
"database/sql"
"encoding/json"
"pc-monitor-server/model"
"time"
)
type MetricsRepository struct {
db *sql.DB
}
func NewMetricsRepository(db *sql.DB) *MetricsRepository {
return &MetricsRepository{db: db}
}
func (r *MetricsRepository) Save(m *model.Metrics) error {
cpuCoreJSON, _ := json.Marshal(m.CPUCoreUsage)
netJSON, _ := json.Marshal(m.NetworkInterfaces)
diskJSON, _ := json.Marshal(m.Disks)
_, err := r.db.Exec(
`INSERT INTO metrics (
device_id, timestamp, cpu_usage, cpu_temperature, cpu_core_usage,
memory_total, memory_used, memory_usage,
gpu_usage, gpu_temperature, gpu_memory_total, gpu_memory_used, gpu_name,
network_interfaces, disks,
power_status, battery_level, power_source
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
m.DeviceID, m.Timestamp,
m.CPUUsage, m.CPUTemperature, string(cpuCoreJSON),
m.MemoryTotal, m.MemoryUsed, m.MemoryUsage,
m.GPUUsage, m.GPUTemperature, m.GPUMemoryTotal, m.GPUMemoryUsed, m.GPUName,
string(netJSON), string(diskJSON),
m.PowerStatus, m.BatteryLevel, m.PowerSource,
)
return err
}
func (r *MetricsRepository) GetLatest(deviceID string) (*model.Metrics, error) {
m := &model.Metrics{}
var cpuCoreJSON, netJSON, diskJSON string
err := r.db.QueryRow(
`SELECT device_id, timestamp, cpu_usage, cpu_temperature, cpu_core_usage,
memory_total, memory_used, memory_usage,
gpu_usage, gpu_temperature, gpu_memory_total, gpu_memory_used, gpu_name,
network_interfaces, disks,
power_status, battery_level, power_source
FROM metrics WHERE device_id=? ORDER BY timestamp DESC LIMIT 1`, deviceID,
).Scan(&m.DeviceID, &m.Timestamp,
&m.CPUUsage, &m.CPUTemperature, &cpuCoreJSON,
&m.MemoryTotal, &m.MemoryUsed, &m.MemoryUsage,
&m.GPUUsage, &m.GPUTemperature, &m.GPUMemoryTotal, &m.GPUMemoryUsed, &m.GPUName,
&netJSON, &diskJSON,
&m.PowerStatus, &m.BatteryLevel, &m.PowerSource)
if err != nil {
return nil, err
}
json.Unmarshal([]byte(cpuCoreJSON), &m.CPUCoreUsage)
json.Unmarshal([]byte(netJSON), &m.NetworkInterfaces)
json.Unmarshal([]byte(diskJSON), &m.Disks)
return m, nil
}
func (r *MetricsRepository) GetHistory(deviceID string, start, end time.Time, limit int) ([]model.Metrics, error) {
rows, err := r.db.Query(
`SELECT device_id, timestamp, cpu_usage, cpu_temperature, cpu_core_usage,
memory_total, memory_used, memory_usage,
gpu_usage, gpu_temperature, gpu_memory_total, gpu_memory_used, gpu_name,
network_interfaces, disks,
power_status, battery_level, power_source
FROM metrics WHERE device_id=? AND timestamp BETWEEN ? AND ?
ORDER BY timestamp DESC LIMIT ?`, deviceID, start, end, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var metrics []model.Metrics
for rows.Next() {
var m model.Metrics
var cpuCoreJSON, netJSON, diskJSON string
if err := rows.Scan(&m.DeviceID, &m.Timestamp,
&m.CPUUsage, &m.CPUTemperature, &cpuCoreJSON,
&m.MemoryTotal, &m.MemoryUsed, &m.MemoryUsage,
&m.GPUUsage, &m.GPUTemperature, &m.GPUMemoryTotal, &m.GPUMemoryUsed, &m.GPUName,
&netJSON, &diskJSON,
&m.PowerStatus, &m.BatteryLevel, &m.PowerSource); err != nil {
return nil, err
}
json.Unmarshal([]byte(cpuCoreJSON), &m.CPUCoreUsage)
json.Unmarshal([]byte(netJSON), &m.NetworkInterfaces)
json.Unmarshal([]byte(diskJSON), &m.Disks)
metrics = append(metrics, m)
}
return metrics, nil
}
func (r *MetricsRepository) Cleanup(before time.Time) error {
_, err := r.db.Exec("DELETE FROM metrics WHERE timestamp < ?", before)
return err
}

99
server/service/alert.go Normal file
View File

@@ -0,0 +1,99 @@
package service
import (
"fmt"
"pc-monitor-server/model"
"pc-monitor-server/repository"
"time"
)
type AlertService struct {
alertRepo *repository.AlertRepository
metricsRepo *repository.MetricsRepository
}
func NewAlertService(alertRepo *repository.AlertRepository, metricsRepo *repository.MetricsRepository) *AlertService {
return &AlertService{alertRepo: alertRepo, metricsRepo: metricsRepo}
}
func (s *AlertService) CreateRule(rule *model.AlertRule) error {
rule.CreatedAt = time.Now()
return s.alertRepo.CreateRule(rule)
}
func (s *AlertService) GetRulesByDevice(deviceID string) ([]model.AlertRule, error) {
return s.alertRepo.GetRulesByDevice(deviceID)
}
func (s *AlertService) DeleteRule(id string) error {
return s.alertRepo.DeleteRule(id)
}
func (s *AlertService) GetActiveAlerts() ([]model.Alert, error) {
return s.alertRepo.GetActiveAlerts()
}
func (s *AlertService) ResolveAlert(id string) error {
return s.alertRepo.ResolveAlert(id)
}
func (s *AlertService) CheckAlerts(deviceID string, metrics *model.Metrics) error {
rules, err := s.alertRepo.GetRulesByDevice(deviceID)
if err != nil {
return err
}
for _, rule := range rules {
value := getMetricValue(metrics, rule.Metric)
if checkThreshold(value, rule.Operator, rule.Threshold) {
alert := &model.Alert{
ID: fmt.Sprintf("%s-%s-%d", deviceID, rule.ID, time.Now().Unix()),
DeviceID: deviceID,
RuleID: rule.ID,
Metric: rule.Metric,
Value: value,
Message: fmt.Sprintf("%s %s %.2f (threshold: %.2f)", rule.Metric, rule.Operator, value, rule.Threshold),
Status: "active",
CreatedAt: time.Now(),
}
s.alertRepo.CreateAlert(alert)
}
}
return nil
}
func getMetricValue(m *model.Metrics, metric string) float64 {
switch metric {
case "cpu_usage":
return m.CPUUsage
case "cpu_temperature":
return m.CPUTemperature
case "memory_usage":
return m.MemoryUsage
case "gpu_usage":
return m.GPUUsage
case "gpu_temperature":
return m.GPUTemperature
case "battery_level":
return float64(m.BatteryLevel)
default:
return 0
}
}
func checkThreshold(value float64, operator string, threshold float64) bool {
switch operator {
case ">":
return value > threshold
case ">=":
return value >= threshold
case "<":
return value < threshold
case "<=":
return value <= threshold
case "==":
return value == threshold
default:
return false
}
}

73
server/service/device.go Normal file
View File

@@ -0,0 +1,73 @@
package service
import (
"crypto/sha256"
"fmt"
"pc-monitor-server/model"
"pc-monitor-server/repository"
"time"
)
type DeviceService struct {
repo *repository.DeviceRepository
}
func NewDeviceService(repo *repository.DeviceRepository) *DeviceService {
return &DeviceService{repo: repo}
}
func (s *DeviceService) Register(hostname, osName, ip string) (*model.Device, error) {
id := generateDeviceID(hostname, ip)
device, err := s.repo.GetByID(id)
if err == nil {
device.LastReportAt = time.Now()
device.Status = "online"
s.repo.Update(device)
return device, nil
}
device = &model.Device{
ID: id,
Hostname: hostname,
OS: osName,
IP: ip,
RegisteredAt: time.Now(),
LastReportAt: time.Now(),
Status: "online",
}
if err := s.repo.Create(device); err != nil {
return nil, err
}
return device, nil
}
func (s *DeviceService) GetAll() ([]model.Device, error) {
return s.repo.GetAll()
}
func (s *DeviceService) GetByID(id string) (*model.Device, error) {
return s.repo.GetByID(id)
}
func (s *DeviceService) Delete(id string) error {
return s.repo.Delete(id)
}
func (s *DeviceService) Heartbeat(id string) error {
device, err := s.repo.GetByID(id)
if err != nil {
return err
}
device.LastReportAt = time.Now()
device.Status = "online"
return s.repo.Update(device)
}
func (s *DeviceService) CheckOffline() {
s.repo.MarkOffline(2 * time.Minute)
}
func generateDeviceID(hostname, ip string) string {
hash := sha256.Sum256([]byte(fmt.Sprintf("%s-%s", hostname, ip)))
return fmt.Sprintf("%x", hash[:8])
}

35
server/service/metrics.go Normal file
View File

@@ -0,0 +1,35 @@
package service
import (
"pc-monitor-server/model"
"pc-monitor-server/repository"
"time"
)
type MetricsService struct {
repo *repository.MetricsRepository
}
func NewMetricsService(repo *repository.MetricsRepository) *MetricsService {
return &MetricsService{repo: repo}
}
func (s *MetricsService) Save(m *model.Metrics) error {
return s.repo.Save(m)
}
func (s *MetricsService) GetLatest(deviceID string) (*model.Metrics, error) {
return s.repo.GetLatest(deviceID)
}
func (s *MetricsService) GetHistory(deviceID string, start, end time.Time, limit int) ([]model.Metrics, error) {
if limit <= 0 {
limit = 1000
}
return s.repo.GetHistory(deviceID, start, end, limit)
}
func (s *MetricsService) Cleanup(retentionDays int) error {
before := time.Now().AddDate(0, 0, -retentionDays)
return s.repo.Cleanup(before)
}

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