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

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
}