From 0e8c9f7bffb42893de2234f91cbca33ae8ff27e2 Mon Sep 17 00:00:00 2001 From: 672 Date: Sun, 17 May 2026 01:29:44 +0800 Subject: [PATCH] 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 --- .gitignore | 37 +++ Dockerfile.server | 34 +++ README.md | 122 ++++++++++ client/build.bat | 14 ++ client/collector/collector.go | 61 +++++ client/collector/cpu.go | 41 ++++ client/collector/disk.go | 38 +++ client/collector/gpu.go | 80 +++++++ client/collector/memory.go | 24 ++ client/collector/network.go | 68 ++++++ client/collector/power.go | 47 ++++ client/config.go | 65 ++++++ client/go.mod | 8 + client/install/install.ps1 | 87 +++++++ client/main.go | 123 ++++++++++ client/reporter/http.go | 119 ++++++++++ docker-compose.yml | 15 ++ server/api/handler/alert.go | 89 +++++++ server/api/handler/device.go | 75 ++++++ server/api/handler/metrics.go | 80 +++++++ server/api/router.go | 57 +++++ server/config.yaml | 9 + server/config/config.go | 51 ++++ server/go.mod | 9 + server/main.go | 52 +++++ server/model/alert.go | 25 ++ server/model/device.go | 13 ++ server/model/metrics.go | 54 +++++ server/repository/alert.go | 88 +++++++ server/repository/db.go | 94 ++++++++ server/repository/device.go | 87 +++++++ server/repository/metrics.go | 106 +++++++++ server/service/alert.go | 99 ++++++++ server/service/device.go | 73 ++++++ server/service/metrics.go | 35 +++ web/index.html | 13 ++ web/package.json | 25 ++ web/src/App.vue | 91 ++++++++ web/src/api/index.ts | 29 +++ web/src/env.d.ts | 7 + web/src/main.ts | 10 + web/src/router/index.ts | 29 +++ web/src/views/Alerts.vue | 249 ++++++++++++++++++++ web/src/views/Dashboard.vue | 181 +++++++++++++++ web/src/views/DeviceDetail.vue | 409 +++++++++++++++++++++++++++++++++ web/src/views/DeviceList.vue | 120 ++++++++++ web/tsconfig.json | 21 ++ web/tsconfig.node.json | 10 + web/vite.config.ts | 18 ++ 49 files changed, 3291 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile.server create mode 100644 README.md create mode 100644 client/build.bat create mode 100644 client/collector/collector.go create mode 100644 client/collector/cpu.go create mode 100644 client/collector/disk.go create mode 100644 client/collector/gpu.go create mode 100644 client/collector/memory.go create mode 100644 client/collector/network.go create mode 100644 client/collector/power.go create mode 100644 client/config.go create mode 100644 client/go.mod create mode 100644 client/install/install.ps1 create mode 100644 client/main.go create mode 100644 client/reporter/http.go create mode 100644 docker-compose.yml create mode 100644 server/api/handler/alert.go create mode 100644 server/api/handler/device.go create mode 100644 server/api/handler/metrics.go create mode 100644 server/api/router.go create mode 100644 server/config.yaml create mode 100644 server/config/config.go create mode 100644 server/go.mod create mode 100644 server/main.go create mode 100644 server/model/alert.go create mode 100644 server/model/device.go create mode 100644 server/model/metrics.go create mode 100644 server/repository/alert.go create mode 100644 server/repository/db.go create mode 100644 server/repository/device.go create mode 100644 server/repository/metrics.go create mode 100644 server/service/alert.go create mode 100644 server/service/device.go create mode 100644 server/service/metrics.go create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/src/App.vue create mode 100644 web/src/api/index.ts create mode 100644 web/src/env.d.ts create mode 100644 web/src/main.ts create mode 100644 web/src/router/index.ts create mode 100644 web/src/views/Alerts.vue create mode 100644 web/src/views/Dashboard.vue create mode 100644 web/src/views/DeviceDetail.vue create mode 100644 web/src/views/DeviceList.vue create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..541f78c --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile.server b/Dockerfile.server new file mode 100644 index 0000000..f2e4b71 --- /dev/null +++ b/Dockerfile.server @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..3e2ac12 --- /dev/null +++ b/README.md @@ -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 diff --git a/client/build.bat b/client/build.bat new file mode 100644 index 0000000..979d66f --- /dev/null +++ b/client/build.bat @@ -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 +) diff --git a/client/collector/collector.go b/client/collector/collector.go new file mode 100644 index 0000000..6ef0e10 --- /dev/null +++ b/client/collector/collector.go @@ -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 +} diff --git a/client/collector/cpu.go b/client/collector/cpu.go new file mode 100644 index 0000000..52de163 --- /dev/null +++ b/client/collector/cpu.go @@ -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 +} diff --git a/client/collector/disk.go b/client/collector/disk.go new file mode 100644 index 0000000..51f9e62 --- /dev/null +++ b/client/collector/disk.go @@ -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 +} diff --git a/client/collector/gpu.go b/client/collector/gpu.go new file mode 100644 index 0000000..a19c2f1 --- /dev/null +++ b/client/collector/gpu.go @@ -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 +} diff --git a/client/collector/memory.go b/client/collector/memory.go new file mode 100644 index 0000000..6e02a63 --- /dev/null +++ b/client/collector/memory.go @@ -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 +} diff --git a/client/collector/network.go b/client/collector/network.go new file mode 100644 index 0000000..b9d8cf0 --- /dev/null +++ b/client/collector/network.go @@ -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 +} diff --git a/client/collector/power.go b/client/collector/power.go new file mode 100644 index 0000000..bf0a623 --- /dev/null +++ b/client/collector/power.go @@ -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 +} diff --git a/client/config.go b/client/config.go new file mode 100644 index 0000000..a8d4cfa --- /dev/null +++ b/client/config.go @@ -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) +} diff --git a/client/go.mod b/client/go.mod new file mode 100644 index 0000000..1ea3794 --- /dev/null +++ b/client/go.mod @@ -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 +) diff --git a/client/install/install.ps1 b/client/install/install.ps1 new file mode 100644 index 0000000..7756c27 --- /dev/null +++ b/client/install/install.ps1 @@ -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" diff --git a/client/main.go b/client/main.go new file mode 100644 index 0000000..c196951 --- /dev/null +++ b/client/main.go @@ -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" +} diff --git a/client/reporter/http.go b/client/reporter/http.go new file mode 100644 index 0000000..3757efb --- /dev/null +++ b/client/reporter/http.go @@ -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(®Resp); 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 +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9ae3b76 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/server/api/handler/alert.go b/server/api/handler/alert.go new file mode 100644 index 0000000..c0f7382 --- /dev/null +++ b/server/api/handler/alert.go @@ -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"}) +} diff --git a/server/api/handler/device.go b/server/api/handler/device.go new file mode 100644 index 0000000..9d0c0a1 --- /dev/null +++ b/server/api/handler/device.go @@ -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"}) +} diff --git a/server/api/handler/metrics.go b/server/api/handler/metrics.go new file mode 100644 index 0000000..4a07a8b --- /dev/null +++ b/server/api/handler/metrics.go @@ -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}) +} diff --git a/server/api/router.go b/server/api/router.go new file mode 100644 index 0000000..3db2a4d --- /dev/null +++ b/server/api/router.go @@ -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() + } +} diff --git a/server/config.yaml b/server/config.yaml new file mode 100644 index 0000000..bbc9144 --- /dev/null +++ b/server/config.yaml @@ -0,0 +1,9 @@ +server: + addr: ":8080" + +database: + path: "./data/monitor.db" + retention_days: 30 + +auth: + admin_password: "admin123" diff --git a/server/config/config.go b/server/config/config.go new file mode 100644 index 0000000..8de142b --- /dev/null +++ b/server/config/config.go @@ -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 +} diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..859cd11 --- /dev/null +++ b/server/go.mod @@ -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 +) diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..7c6023c --- /dev/null +++ b/server/main.go @@ -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) + } +} diff --git a/server/model/alert.go b/server/model/alert.go new file mode 100644 index 0000000..6ba9404 --- /dev/null +++ b/server/model/alert.go @@ -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"` +} diff --git a/server/model/device.go b/server/model/device.go new file mode 100644 index 0000000..ed5fe25 --- /dev/null +++ b/server/model/device.go @@ -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"` +} diff --git a/server/model/metrics.go b/server/model/metrics.go new file mode 100644 index 0000000..3c95109 --- /dev/null +++ b/server/model/metrics.go @@ -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"` +} diff --git a/server/repository/alert.go b/server/repository/alert.go new file mode 100644 index 0000000..cef88d7 --- /dev/null +++ b/server/repository/alert.go @@ -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 +} diff --git a/server/repository/db.go b/server/repository/db.go new file mode 100644 index 0000000..9838794 --- /dev/null +++ b/server/repository/db.go @@ -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 +} diff --git a/server/repository/device.go b/server/repository/device.go new file mode 100644 index 0000000..96d44d4 --- /dev/null +++ b/server/repository/device.go @@ -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 +} diff --git a/server/repository/metrics.go b/server/repository/metrics.go new file mode 100644 index 0000000..31e7da2 --- /dev/null +++ b/server/repository/metrics.go @@ -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 +} diff --git a/server/service/alert.go b/server/service/alert.go new file mode 100644 index 0000000..95430d0 --- /dev/null +++ b/server/service/alert.go @@ -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 + } +} diff --git a/server/service/device.go b/server/service/device.go new file mode 100644 index 0000000..55d3a78 --- /dev/null +++ b/server/service/device.go @@ -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]) +} diff --git a/server/service/metrics.go b/server/service/metrics.go new file mode 100644 index 0000000..64724ee --- /dev/null +++ b/server/service/metrics.go @@ -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) +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..cf626d2 --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + PC Monitor + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..927a374 --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/src/App.vue b/web/src/App.vue new file mode 100644 index 0000000..2ef7a14 --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/web/src/api/index.ts b/web/src/api/index.ts new file mode 100644 index 0000000..719260b --- /dev/null +++ b/web/src/api/index.ts @@ -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 diff --git a/web/src/env.d.ts b/web/src/env.d.ts new file mode 100644 index 0000000..323c78a --- /dev/null +++ b/web/src/env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..571164a --- /dev/null +++ b/web/src/main.ts @@ -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') diff --git a/web/src/router/index.ts b/web/src/router/index.ts new file mode 100644 index 0000000..b7beb67 --- /dev/null +++ b/web/src/router/index.ts @@ -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 diff --git a/web/src/views/Alerts.vue b/web/src/views/Alerts.vue new file mode 100644 index 0000000..edc4c38 --- /dev/null +++ b/web/src/views/Alerts.vue @@ -0,0 +1,249 @@ + + + + + diff --git a/web/src/views/Dashboard.vue b/web/src/views/Dashboard.vue new file mode 100644 index 0000000..032b5be --- /dev/null +++ b/web/src/views/Dashboard.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/web/src/views/DeviceDetail.vue b/web/src/views/DeviceDetail.vue new file mode 100644 index 0000000..6aa5489 --- /dev/null +++ b/web/src/views/DeviceDetail.vue @@ -0,0 +1,409 @@ + + + + + diff --git a/web/src/views/DeviceList.vue b/web/src/views/DeviceList.vue new file mode 100644 index 0000000..810c948 --- /dev/null +++ b/web/src/views/DeviceList.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..a18b191 --- /dev/null +++ b/web/tsconfig.json @@ -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" }] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..513f2d8 --- /dev/null +++ b/web/vite.config.ts @@ -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 + } +})