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