Initial commit: System monitor web application
Full-stack system monitoring dashboard for Linux with AMD GPU support. Features: - Real-time metrics via Server-Sent Events (SSE) - CPU usage per core with frequency and load averages - Memory and swap utilization - Disk usage and I/O activity - Network interfaces with traffic stats - Process list sorted by CPU or memory - Temperature sensors (CPU, GPU, NVMe, motherboard) - AMD GPU monitoring (utilization, VRAM, temp, clocks, power, fan) - Configurable refresh rate (1-60 seconds) Stack: - Backend: Go + Gin, reading from /proc and /sys - Frontend: SvelteKit 5 + Tailwind CSS - Deployment: Docker Compose with host volume mounts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build outputs
|
||||
frontend/build/
|
||||
frontend/.svelte-kit/
|
||||
backend/server
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
33
backend/Dockerfile
Normal file
33
backend/Dockerfile
Normal file
@@ -0,0 +1,33 @@
|
||||
FROM golang:1.23-alpine AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Install git for fetching dependencies
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# Copy go mod files
|
||||
COPY go.mod go.sum* ./
|
||||
|
||||
# Download dependencies
|
||||
RUN go mod download || true
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Tidy and download any missing deps
|
||||
RUN go mod tidy
|
||||
|
||||
# Build
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server ./cmd/server
|
||||
|
||||
# Runtime
|
||||
FROM alpine:3.21
|
||||
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/server .
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["/app/server"]
|
||||
25
backend/cmd/server/main.go
Normal file
25
backend/cmd/server/main.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"system-monitor/internal/api"
|
||||
"system-monitor/internal/config"
|
||||
"system-monitor/internal/sse"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
|
||||
log.Printf("Starting system monitor backend on port %s", cfg.Port)
|
||||
log.Printf("Reading from: proc=%s, sys=%s", cfg.ProcPath, cfg.SysPath)
|
||||
log.Printf("Default refresh interval: %s", cfg.RefreshInterval)
|
||||
|
||||
broker := sse.NewBroker(cfg)
|
||||
go broker.Run()
|
||||
|
||||
server := api.NewServer(cfg, broker)
|
||||
if err := server.Run(); err != nil {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
}
|
||||
}
|
||||
37
backend/go.mod
Normal file
37
backend/go.mod
Normal file
@@ -0,0 +1,37 @@
|
||||
module system-monitor
|
||||
|
||||
go 1.23
|
||||
|
||||
require github.com/gin-gonic/gin v1.10.0
|
||||
|
||||
require github.com/gin-contrib/cors v1.7.2
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.23.0 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
golang.org/x/text v0.15.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
99
backend/go.sum
Normal file
99
backend/go.sum
Normal file
@@ -0,0 +1,99 @@
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
|
||||
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
138
backend/internal/api/routes.go
Normal file
138
backend/internal/api/routes.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"system-monitor/internal/config"
|
||||
"system-monitor/internal/sse"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
router *gin.Engine
|
||||
broker *sse.Broker
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewServer(cfg *config.Config, broker *sse.Broker) *Server {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
router := gin.New()
|
||||
router.Use(gin.Recovery())
|
||||
|
||||
// CORS configuration
|
||||
router.Use(cors.New(cors.Config{
|
||||
AllowOrigins: []string{"*"},
|
||||
AllowMethods: []string{"GET", "POST", "OPTIONS"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Accept"},
|
||||
ExposeHeaders: []string{"Content-Length"},
|
||||
AllowCredentials: false,
|
||||
MaxAge: 12 * time.Hour,
|
||||
}))
|
||||
|
||||
s := &Server{
|
||||
router: router,
|
||||
broker: broker,
|
||||
cfg: cfg,
|
||||
}
|
||||
|
||||
s.setupRoutes()
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) setupRoutes() {
|
||||
// Health check
|
||||
s.router.GET("/health", s.healthHandler)
|
||||
|
||||
// API v1
|
||||
v1 := s.router.Group("/api/v1")
|
||||
{
|
||||
v1.GET("/metrics", s.metricsHandler)
|
||||
v1.GET("/stream", s.streamHandler)
|
||||
v1.POST("/settings/refresh", s.setRefreshHandler)
|
||||
v1.GET("/settings/refresh", s.getRefreshHandler)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Run() error {
|
||||
return s.router.Run(":" + s.cfg.Port)
|
||||
}
|
||||
|
||||
func (s *Server) healthHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) metricsHandler(c *gin.Context) {
|
||||
metrics := s.broker.CollectAll()
|
||||
c.JSON(http.StatusOK, metrics)
|
||||
}
|
||||
|
||||
func (s *Server) streamHandler(c *gin.Context) {
|
||||
// Set SSE headers
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
|
||||
// Create client channel
|
||||
clientChan := make(chan []byte, 10)
|
||||
s.broker.Register(clientChan)
|
||||
|
||||
// Handle client disconnect
|
||||
notify := c.Request.Context().Done()
|
||||
|
||||
go func() {
|
||||
<-notify
|
||||
s.broker.Unregister(clientChan)
|
||||
}()
|
||||
|
||||
// Send initial data immediately
|
||||
initial := s.broker.CollectAll()
|
||||
c.SSEvent("message", initial)
|
||||
c.Writer.Flush()
|
||||
|
||||
// Stream data - write raw SSE format to avoid double-encoding
|
||||
c.Stream(func(w io.Writer) bool {
|
||||
select {
|
||||
case <-notify:
|
||||
return false
|
||||
case data := <-clientChan:
|
||||
// Write SSE format directly: "data: {json}\n\n"
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type RefreshRequest struct {
|
||||
Interval int `json:"interval"` // seconds
|
||||
}
|
||||
|
||||
func (s *Server) setRefreshHandler(c *gin.Context) {
|
||||
var req RefreshRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Interval < 1 || req.Interval > 60 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "interval must be between 1 and 60 seconds"})
|
||||
return
|
||||
}
|
||||
|
||||
s.broker.SetInterval(time.Duration(req.Interval) * time.Second)
|
||||
c.JSON(http.StatusOK, gin.H{"interval": req.Interval})
|
||||
}
|
||||
|
||||
func (s *Server) getRefreshHandler(c *gin.Context) {
|
||||
interval := s.broker.GetInterval()
|
||||
c.JSON(http.StatusOK, gin.H{"interval": int(interval.Seconds())})
|
||||
}
|
||||
|
||||
func (s *Server) ListenAddr() string {
|
||||
return fmt.Sprintf(":%s", s.cfg.Port)
|
||||
}
|
||||
169
backend/internal/collectors/amdgpu.go
Normal file
169
backend/internal/collectors/amdgpu.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package collectors
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"system-monitor/internal/models"
|
||||
)
|
||||
|
||||
type AMDGPUCollector struct {
|
||||
sysPath string
|
||||
cardPath string
|
||||
hwmonPath string
|
||||
available bool
|
||||
name string
|
||||
}
|
||||
|
||||
func NewAMDGPUCollector(sysPath string) *AMDGPUCollector {
|
||||
c := &AMDGPUCollector{sysPath: sysPath}
|
||||
c.detectCard()
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *AMDGPUCollector) detectCard() {
|
||||
drmPath := filepath.Join(c.sysPath, "class/drm")
|
||||
entries, err := os.ReadDir(drmPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
// Look for card directories (card0, card1, ...) but not render nodes
|
||||
if !strings.HasPrefix(name, "card") || strings.Contains(name, "-") {
|
||||
continue
|
||||
}
|
||||
|
||||
devicePath := filepath.Join(drmPath, name, "device")
|
||||
|
||||
// Check if this is an AMD GPU by looking at the driver
|
||||
driverLink, err := os.Readlink(filepath.Join(devicePath, "driver"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.Contains(driverLink, "amdgpu") {
|
||||
continue
|
||||
}
|
||||
|
||||
c.cardPath = devicePath
|
||||
c.available = true
|
||||
|
||||
// Find hwmon path
|
||||
hwmonDir := filepath.Join(devicePath, "hwmon")
|
||||
hwmonEntries, err := os.ReadDir(hwmonDir)
|
||||
if err == nil && len(hwmonEntries) > 0 {
|
||||
c.hwmonPath = filepath.Join(hwmonDir, hwmonEntries[0].Name())
|
||||
}
|
||||
|
||||
// Try to get GPU name from uevent
|
||||
ueventData, err := os.ReadFile(filepath.Join(devicePath, "uevent"))
|
||||
if err == nil {
|
||||
for _, line := range strings.Split(string(ueventData), "\n") {
|
||||
if strings.HasPrefix(line, "PCI_ID=") {
|
||||
c.name = strings.TrimPrefix(line, "PCI_ID=")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (c *AMDGPUCollector) Collect() (models.AMDGPUStats, error) {
|
||||
stats := models.AMDGPUStats{
|
||||
Available: c.available,
|
||||
Name: c.name,
|
||||
}
|
||||
|
||||
if !c.available {
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GPU utilization
|
||||
if val, err := c.readInt(filepath.Join(c.cardPath, "gpu_busy_percent")); err == nil {
|
||||
stats.Utilization = val
|
||||
}
|
||||
|
||||
// VRAM usage
|
||||
if val, err := c.readUint64(filepath.Join(c.cardPath, "mem_info_vram_used")); err == nil {
|
||||
stats.VRAMUsed = val
|
||||
}
|
||||
if val, err := c.readUint64(filepath.Join(c.cardPath, "mem_info_vram_total")); err == nil {
|
||||
stats.VRAMTotal = val
|
||||
}
|
||||
|
||||
// Temperature from hwmon (millidegrees Celsius)
|
||||
if c.hwmonPath != "" {
|
||||
if val, err := c.readInt(filepath.Join(c.hwmonPath, "temp1_input")); err == nil {
|
||||
stats.Temperature = float64(val) / 1000.0
|
||||
}
|
||||
|
||||
// Fan speed (RPM)
|
||||
if val, err := c.readInt(filepath.Join(c.hwmonPath, "fan1_input")); err == nil {
|
||||
stats.FanRPM = val
|
||||
}
|
||||
|
||||
// Power usage (microwatts to watts)
|
||||
if val, err := c.readInt(filepath.Join(c.hwmonPath, "power1_average")); err == nil {
|
||||
stats.PowerWatts = float64(val) / 1000000.0
|
||||
}
|
||||
}
|
||||
|
||||
// Clock speeds from pp_dpm_sclk and pp_dpm_mclk
|
||||
stats.ClockGPU = c.parseCurrentClock(filepath.Join(c.cardPath, "pp_dpm_sclk"))
|
||||
stats.ClockMemory = c.parseCurrentClock(filepath.Join(c.cardPath, "pp_dpm_mclk"))
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (c *AMDGPUCollector) parseCurrentClock(path string) int {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Parse lines like "1: 1311Mhz *" where * indicates current
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasSuffix(line, "*") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Remove the * and parse
|
||||
line = strings.TrimSuffix(line, "*")
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
freqStr := parts[1]
|
||||
freqStr = strings.TrimSuffix(freqStr, "Mhz")
|
||||
freqStr = strings.TrimSuffix(freqStr, "MHz")
|
||||
|
||||
if freq, err := strconv.Atoi(freqStr); err == nil {
|
||||
return freq
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func (c *AMDGPUCollector) readInt(path string) (int, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
}
|
||||
|
||||
func (c *AMDGPUCollector) readUint64(path string) (uint64, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return strconv.ParseUint(strings.TrimSpace(string(data)), 10, 64)
|
||||
}
|
||||
153
backend/internal/collectors/cpu.go
Normal file
153
backend/internal/collectors/cpu.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package collectors
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"system-monitor/internal/models"
|
||||
)
|
||||
|
||||
type CPUCollector struct {
|
||||
procPath string
|
||||
sysPath string
|
||||
prevStats map[string]cpuTime
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type cpuTime struct {
|
||||
user, nice, system, idle, iowait, irq, softirq uint64
|
||||
}
|
||||
|
||||
func NewCPUCollector(procPath, sysPath string) *CPUCollector {
|
||||
return &CPUCollector{
|
||||
procPath: procPath,
|
||||
sysPath: sysPath,
|
||||
prevStats: make(map[string]cpuTime),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CPUCollector) Collect() (models.CPUStats, error) {
|
||||
stats := models.CPUStats{
|
||||
Cores: []models.CPUCore{}, // Initialize to empty slice, not nil
|
||||
}
|
||||
|
||||
// Read /proc/stat for usage
|
||||
file, err := os.Open(filepath.Join(c.procPath, "stat"))
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
coreID := 0
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !strings.HasPrefix(line, "cpu") {
|
||||
continue
|
||||
}
|
||||
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 8 {
|
||||
continue
|
||||
}
|
||||
|
||||
name := fields[0]
|
||||
current := cpuTime{
|
||||
user: parseUint(fields[1]),
|
||||
nice: parseUint(fields[2]),
|
||||
system: parseUint(fields[3]),
|
||||
idle: parseUint(fields[4]),
|
||||
iowait: parseUint(fields[5]),
|
||||
irq: parseUint(fields[6]),
|
||||
softirq: parseUint(fields[7]),
|
||||
}
|
||||
|
||||
usage := c.calculateUsage(name, current)
|
||||
|
||||
if name == "cpu" {
|
||||
stats.TotalUsage = usage
|
||||
} else {
|
||||
freq := c.getCoreFrequency(coreID)
|
||||
stats.Cores = append(stats.Cores, models.CPUCore{
|
||||
ID: coreID,
|
||||
Usage: usage,
|
||||
Frequency: freq,
|
||||
})
|
||||
coreID++
|
||||
}
|
||||
|
||||
c.prevStats[name] = current
|
||||
}
|
||||
|
||||
// Load average from /proc/loadavg
|
||||
stats.LoadAverage = c.getLoadAverage()
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (c *CPUCollector) calculateUsage(name string, current cpuTime) float64 {
|
||||
prev, exists := c.prevStats[name]
|
||||
if !exists {
|
||||
return 0
|
||||
}
|
||||
|
||||
prevTotal := prev.user + prev.nice + prev.system + prev.idle + prev.iowait + prev.irq + prev.softirq
|
||||
currTotal := current.user + current.nice + current.system + current.idle + current.iowait + current.irq + current.softirq
|
||||
|
||||
totalDiff := float64(currTotal - prevTotal)
|
||||
if totalDiff == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
idleDiff := float64((current.idle + current.iowait) - (prev.idle + prev.iowait))
|
||||
return (1 - idleDiff/totalDiff) * 100
|
||||
}
|
||||
|
||||
func (c *CPUCollector) getCoreFrequency(coreID int) int64 {
|
||||
// Try scaling_cur_freq first (more accurate)
|
||||
path := filepath.Join(c.sysPath, "devices/system/cpu",
|
||||
"cpu"+strconv.Itoa(coreID), "cpufreq/scaling_cur_freq")
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
freq, _ := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
|
||||
return freq / 1000 // Convert kHz to MHz
|
||||
}
|
||||
|
||||
func (c *CPUCollector) getLoadAverage() models.LoadAverage {
|
||||
data, err := os.ReadFile(filepath.Join(c.procPath, "loadavg"))
|
||||
if err != nil {
|
||||
return models.LoadAverage{}
|
||||
}
|
||||
|
||||
fields := strings.Fields(string(data))
|
||||
if len(fields) < 3 {
|
||||
return models.LoadAverage{}
|
||||
}
|
||||
|
||||
load1, _ := strconv.ParseFloat(fields[0], 64)
|
||||
load5, _ := strconv.ParseFloat(fields[1], 64)
|
||||
load15, _ := strconv.ParseFloat(fields[2], 64)
|
||||
|
||||
return models.LoadAverage{
|
||||
Load1: load1,
|
||||
Load5: load5,
|
||||
Load15: load15,
|
||||
}
|
||||
}
|
||||
|
||||
func parseUint(s string) uint64 {
|
||||
v, _ := strconv.ParseUint(s, 10, 64)
|
||||
return v
|
||||
}
|
||||
167
backend/internal/collectors/disk.go
Normal file
167
backend/internal/collectors/disk.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package collectors
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"system-monitor/internal/models"
|
||||
)
|
||||
|
||||
type DiskCollector struct {
|
||||
procPath string
|
||||
mtabPath string
|
||||
}
|
||||
|
||||
func NewDiskCollector(procPath, mtabPath string) *DiskCollector {
|
||||
return &DiskCollector{
|
||||
procPath: procPath,
|
||||
mtabPath: mtabPath,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *DiskCollector) Collect() (models.DiskStats, error) {
|
||||
stats := models.DiskStats{
|
||||
Mounts: []models.MountStats{},
|
||||
IO: []models.DiskIO{},
|
||||
}
|
||||
|
||||
// Get mount points
|
||||
mounts, err := c.getMounts()
|
||||
if err == nil && mounts != nil {
|
||||
stats.Mounts = mounts
|
||||
}
|
||||
|
||||
// Get I/O stats
|
||||
io, err := c.getIOStats()
|
||||
if err == nil && io != nil {
|
||||
stats.IO = io
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (c *DiskCollector) getMounts() ([]models.MountStats, error) {
|
||||
file, err := os.Open(c.mtabPath)
|
||||
if err != nil {
|
||||
// Fallback to /proc/mounts
|
||||
file, err = os.Open(filepath.Join(c.procPath, "mounts"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var mounts []models.MountStats
|
||||
scanner := bufio.NewScanner(file)
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
device := fields[0]
|
||||
mountPoint := fields[1]
|
||||
fsType := fields[2]
|
||||
|
||||
// Skip virtual filesystems and duplicates
|
||||
if !strings.HasPrefix(device, "/dev/") {
|
||||
continue
|
||||
}
|
||||
if seen[mountPoint] {
|
||||
continue
|
||||
}
|
||||
seen[mountPoint] = true
|
||||
|
||||
// Skip certain filesystem types
|
||||
if fsType == "squashfs" || fsType == "overlay" {
|
||||
continue
|
||||
}
|
||||
|
||||
var stat syscall.Statfs_t
|
||||
if err := syscall.Statfs(mountPoint, &stat); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
total := stat.Blocks * uint64(stat.Bsize)
|
||||
free := stat.Bfree * uint64(stat.Bsize)
|
||||
available := stat.Bavail * uint64(stat.Bsize)
|
||||
used := total - free
|
||||
|
||||
usedPercent := float64(0)
|
||||
if total > 0 {
|
||||
usedPercent = float64(used) / float64(total) * 100
|
||||
}
|
||||
|
||||
mounts = append(mounts, models.MountStats{
|
||||
Device: device,
|
||||
MountPoint: mountPoint,
|
||||
Filesystem: fsType,
|
||||
Total: total,
|
||||
Used: used,
|
||||
Available: available,
|
||||
UsedPercent: usedPercent,
|
||||
})
|
||||
}
|
||||
|
||||
return mounts, nil
|
||||
}
|
||||
|
||||
func (c *DiskCollector) getIOStats() ([]models.DiskIO, error) {
|
||||
file, err := os.Open(filepath.Join(c.procPath, "diskstats"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var stats []models.DiskIO
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 14 {
|
||||
continue
|
||||
}
|
||||
|
||||
device := fields[2]
|
||||
|
||||
// Only include real block devices (sd*, nvme*, vd*)
|
||||
if !strings.HasPrefix(device, "sd") &&
|
||||
!strings.HasPrefix(device, "nvme") &&
|
||||
!strings.HasPrefix(device, "vd") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip partitions (e.g., sda1, nvme0n1p1)
|
||||
if strings.ContainsAny(device[len(device)-1:], "0123456789") {
|
||||
lastChar := device[len(device)-1]
|
||||
if lastChar >= '0' && lastChar <= '9' {
|
||||
// Check if it's a partition
|
||||
if strings.Contains(device, "nvme") {
|
||||
if strings.Contains(device, "p") {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sectors read/written (field 5 and 9), sector = 512 bytes
|
||||
readSectors, _ := strconv.ParseUint(fields[5], 10, 64)
|
||||
writeSectors, _ := strconv.ParseUint(fields[9], 10, 64)
|
||||
|
||||
stats = append(stats, models.DiskIO{
|
||||
Device: device,
|
||||
ReadBytes: readSectors * 512,
|
||||
WriteBytes: writeSectors * 512,
|
||||
})
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
58
backend/internal/collectors/memory.go
Normal file
58
backend/internal/collectors/memory.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package collectors
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"system-monitor/internal/models"
|
||||
)
|
||||
|
||||
type MemoryCollector struct {
|
||||
procPath string
|
||||
}
|
||||
|
||||
func NewMemoryCollector(procPath string) *MemoryCollector {
|
||||
return &MemoryCollector{procPath: procPath}
|
||||
}
|
||||
|
||||
func (c *MemoryCollector) Collect() (models.MemoryStats, error) {
|
||||
stats := models.MemoryStats{}
|
||||
|
||||
file, err := os.Open(filepath.Join(c.procPath, "meminfo"))
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
values := make(map[string]uint64)
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.TrimSuffix(fields[0], ":")
|
||||
value, _ := strconv.ParseUint(fields[1], 10, 64)
|
||||
// Values in /proc/meminfo are in kB, convert to bytes
|
||||
values[key] = value * 1024
|
||||
}
|
||||
|
||||
stats.Total = values["MemTotal"]
|
||||
stats.Available = values["MemAvailable"]
|
||||
stats.Cached = values["Cached"]
|
||||
stats.Buffers = values["Buffers"]
|
||||
stats.SwapTotal = values["SwapTotal"]
|
||||
stats.SwapFree = values["SwapFree"]
|
||||
stats.SwapUsed = stats.SwapTotal - stats.SwapFree
|
||||
|
||||
// Calculate used memory (total - available is most accurate)
|
||||
stats.Used = stats.Total - stats.Available
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
120
backend/internal/collectors/network.go
Normal file
120
backend/internal/collectors/network.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package collectors
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"system-monitor/internal/models"
|
||||
)
|
||||
|
||||
type NetworkCollector struct {
|
||||
procPath string
|
||||
}
|
||||
|
||||
func NewNetworkCollector(procPath string) *NetworkCollector {
|
||||
return &NetworkCollector{procPath: procPath}
|
||||
}
|
||||
|
||||
func (c *NetworkCollector) Collect() (models.NetworkStats, error) {
|
||||
stats := models.NetworkStats{
|
||||
Interfaces: []models.InterfaceStats{},
|
||||
}
|
||||
|
||||
interfaces, err := c.getInterfaceStats()
|
||||
if err == nil && interfaces != nil {
|
||||
stats.Interfaces = interfaces
|
||||
}
|
||||
|
||||
stats.ConnectionCount = c.getConnectionCount()
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (c *NetworkCollector) getInterfaceStats() ([]models.InterfaceStats, error) {
|
||||
file, err := os.Open(filepath.Join(c.procPath, "net/dev"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var interfaces []models.InterfaceStats
|
||||
scanner := bufio.NewScanner(file)
|
||||
lineNum := 0
|
||||
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
// Skip header lines
|
||||
if lineNum <= 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
line := scanner.Text()
|
||||
// Format: "iface: rx_bytes rx_packets ... tx_bytes tx_packets ..."
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(parts[0])
|
||||
|
||||
// Skip loopback
|
||||
if name == "lo" {
|
||||
continue
|
||||
}
|
||||
|
||||
fields := strings.Fields(parts[1])
|
||||
if len(fields) < 10 {
|
||||
continue
|
||||
}
|
||||
|
||||
rxBytes, _ := strconv.ParseUint(fields[0], 10, 64)
|
||||
rxPackets, _ := strconv.ParseUint(fields[1], 10, 64)
|
||||
txBytes, _ := strconv.ParseUint(fields[8], 10, 64)
|
||||
txPackets, _ := strconv.ParseUint(fields[9], 10, 64)
|
||||
|
||||
interfaces = append(interfaces, models.InterfaceStats{
|
||||
Name: name,
|
||||
RxBytes: rxBytes,
|
||||
TxBytes: txBytes,
|
||||
RxPackets: rxPackets,
|
||||
TxPackets: txPackets,
|
||||
})
|
||||
}
|
||||
|
||||
return interfaces, nil
|
||||
}
|
||||
|
||||
func (c *NetworkCollector) getConnectionCount() int {
|
||||
count := 0
|
||||
|
||||
// Count TCP connections
|
||||
tcpFile, err := os.Open(filepath.Join(c.procPath, "net/tcp"))
|
||||
if err == nil {
|
||||
scanner := bufio.NewScanner(tcpFile)
|
||||
for scanner.Scan() {
|
||||
count++
|
||||
}
|
||||
tcpFile.Close()
|
||||
count-- // Subtract header line
|
||||
}
|
||||
|
||||
// Count TCP6 connections
|
||||
tcp6File, err := os.Open(filepath.Join(c.procPath, "net/tcp6"))
|
||||
if err == nil {
|
||||
scanner := bufio.NewScanner(tcp6File)
|
||||
for scanner.Scan() {
|
||||
count++
|
||||
}
|
||||
tcp6File.Close()
|
||||
count-- // Subtract header line
|
||||
}
|
||||
|
||||
if count < 0 {
|
||||
count = 0
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
171
backend/internal/collectors/processes.go
Normal file
171
backend/internal/collectors/processes.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package collectors
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"system-monitor/internal/models"
|
||||
)
|
||||
|
||||
type ProcessCollector struct {
|
||||
procPath string
|
||||
pageSize int64
|
||||
totalMem uint64
|
||||
numCPU int
|
||||
}
|
||||
|
||||
func NewProcessCollector(procPath string) *ProcessCollector {
|
||||
pageSize := int64(os.Getpagesize())
|
||||
|
||||
// Get total memory for percentage calculation
|
||||
var totalMem uint64
|
||||
data, err := os.ReadFile(filepath.Join(procPath, "meminfo"))
|
||||
if err == nil {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.HasPrefix(line, "MemTotal:") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
val, _ := strconv.ParseUint(fields[1], 10, 64)
|
||||
totalMem = val * 1024 // kB to bytes
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Count CPUs
|
||||
numCPU := 1
|
||||
cpuData, err := os.ReadFile(filepath.Join(procPath, "stat"))
|
||||
if err == nil {
|
||||
for _, line := range strings.Split(string(cpuData), "\n") {
|
||||
if strings.HasPrefix(line, "cpu") && !strings.HasPrefix(line, "cpu ") {
|
||||
numCPU++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &ProcessCollector{
|
||||
procPath: procPath,
|
||||
pageSize: pageSize,
|
||||
totalMem: totalMem,
|
||||
numCPU: numCPU,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ProcessCollector) Collect() (models.ProcessStats, error) {
|
||||
stats := models.ProcessStats{
|
||||
TopByCPU: []models.ProcessInfo{},
|
||||
TopByMemory: []models.ProcessInfo{},
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(c.procPath)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
|
||||
processes := make([]models.ProcessInfo, 0)
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
pid, err := strconv.Atoi(entry.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
proc, err := c.readProcess(pid)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
processes = append(processes, proc)
|
||||
}
|
||||
|
||||
stats.Total = len(processes)
|
||||
|
||||
// Sort by CPU and get top 10
|
||||
sort.Slice(processes, func(i, j int) bool {
|
||||
return processes[i].CPUPercent > processes[j].CPUPercent
|
||||
})
|
||||
topCount := min(10, len(processes))
|
||||
stats.TopByCPU = make([]models.ProcessInfo, topCount)
|
||||
copy(stats.TopByCPU, processes[:topCount])
|
||||
|
||||
// Sort by memory and get top 10
|
||||
sort.Slice(processes, func(i, j int) bool {
|
||||
return processes[i].MemoryMB > processes[j].MemoryMB
|
||||
})
|
||||
topCount = min(10, len(processes))
|
||||
stats.TopByMemory = make([]models.ProcessInfo, topCount)
|
||||
copy(stats.TopByMemory, processes[:topCount])
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (c *ProcessCollector) readProcess(pid int) (models.ProcessInfo, error) {
|
||||
proc := models.ProcessInfo{PID: pid}
|
||||
pidPath := filepath.Join(c.procPath, strconv.Itoa(pid))
|
||||
|
||||
// Read /proc/[pid]/stat
|
||||
statData, err := os.ReadFile(filepath.Join(pidPath, "stat"))
|
||||
if err != nil {
|
||||
return proc, err
|
||||
}
|
||||
|
||||
// Parse stat file - name is in parentheses, can contain spaces
|
||||
statStr := string(statData)
|
||||
nameStart := strings.Index(statStr, "(")
|
||||
nameEnd := strings.LastIndex(statStr, ")")
|
||||
if nameStart == -1 || nameEnd == -1 {
|
||||
return proc, err
|
||||
}
|
||||
|
||||
proc.Name = statStr[nameStart+1 : nameEnd]
|
||||
|
||||
// Fields after the name
|
||||
fields := strings.Fields(statStr[nameEnd+2:])
|
||||
if len(fields) < 22 {
|
||||
return proc, err
|
||||
}
|
||||
|
||||
proc.State = fields[0]
|
||||
|
||||
// RSS (resident set size) is field 23 (index 21 after name)
|
||||
rss, _ := strconv.ParseInt(fields[21], 10, 64)
|
||||
memBytes := rss * c.pageSize
|
||||
proc.MemoryMB = float64(memBytes) / (1024 * 1024)
|
||||
|
||||
// CPU time (utime + stime) - simplified, not actual percentage
|
||||
// For accurate CPU%, we'd need to track over time like we do for system CPU
|
||||
utime, _ := strconv.ParseUint(fields[11], 10, 64)
|
||||
stime, _ := strconv.ParseUint(fields[12], 10, 64)
|
||||
|
||||
// Read uptime to calculate CPU percentage
|
||||
uptimeData, err := os.ReadFile(filepath.Join(c.procPath, "uptime"))
|
||||
if err == nil {
|
||||
uptimeFields := strings.Fields(string(uptimeData))
|
||||
if len(uptimeFields) >= 1 {
|
||||
uptime, _ := strconv.ParseFloat(uptimeFields[0], 64)
|
||||
|
||||
// Get process start time (field 21, starttime in clock ticks)
|
||||
starttime, _ := strconv.ParseUint(fields[19], 10, 64)
|
||||
|
||||
// Clock ticks per second (usually 100)
|
||||
clkTck := uint64(100)
|
||||
|
||||
totalTime := float64(utime + stime) / float64(clkTck)
|
||||
processUptime := uptime - (float64(starttime) / float64(clkTck))
|
||||
|
||||
if processUptime > 0 {
|
||||
proc.CPUPercent = (totalTime / processUptime) * 100
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return proc, nil
|
||||
}
|
||||
53
backend/internal/collectors/system.go
Normal file
53
backend/internal/collectors/system.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package collectors
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"system-monitor/internal/models"
|
||||
)
|
||||
|
||||
type SystemCollector struct {
|
||||
procPath string
|
||||
}
|
||||
|
||||
func NewSystemCollector(procPath string) *SystemCollector {
|
||||
return &SystemCollector{procPath: procPath}
|
||||
}
|
||||
|
||||
func (c *SystemCollector) Collect() (models.SystemInfo, error) {
|
||||
info := models.SystemInfo{
|
||||
OS: "linux",
|
||||
Architecture: runtime.GOARCH,
|
||||
}
|
||||
|
||||
// Hostname
|
||||
hostname, err := os.Hostname()
|
||||
if err == nil {
|
||||
info.Hostname = hostname
|
||||
}
|
||||
|
||||
// Kernel version from /proc/version
|
||||
versionData, err := os.ReadFile(filepath.Join(c.procPath, "version"))
|
||||
if err == nil {
|
||||
fields := strings.Fields(string(versionData))
|
||||
if len(fields) >= 3 {
|
||||
info.Kernel = fields[2]
|
||||
}
|
||||
}
|
||||
|
||||
// Uptime from /proc/uptime
|
||||
uptimeData, err := os.ReadFile(filepath.Join(c.procPath, "uptime"))
|
||||
if err == nil {
|
||||
fields := strings.Fields(string(uptimeData))
|
||||
if len(fields) >= 1 {
|
||||
uptime, _ := strconv.ParseFloat(fields[0], 64)
|
||||
info.Uptime = int64(uptime)
|
||||
}
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
107
backend/internal/collectors/temperature.go
Normal file
107
backend/internal/collectors/temperature.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package collectors
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"system-monitor/internal/models"
|
||||
)
|
||||
|
||||
type TemperatureCollector struct {
|
||||
sysPath string
|
||||
}
|
||||
|
||||
func NewTemperatureCollector(sysPath string) *TemperatureCollector {
|
||||
return &TemperatureCollector{sysPath: sysPath}
|
||||
}
|
||||
|
||||
func (c *TemperatureCollector) Collect() (models.TemperatureStats, error) {
|
||||
stats := models.TemperatureStats{
|
||||
Sensors: []models.TemperatureSensor{}, // Initialize to empty slice, not nil
|
||||
}
|
||||
hwmonPath := filepath.Join(c.sysPath, "class/hwmon")
|
||||
|
||||
entries, err := os.ReadDir(hwmonPath)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
// hwmon entries in /sys/class/hwmon are symlinks to directories
|
||||
// Check if entry is a directory OR a symlink (which might point to a directory)
|
||||
if !entry.IsDir() && entry.Type()&os.ModeSymlink == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
sensorPath := filepath.Join(hwmonPath, entry.Name())
|
||||
sensors := c.readHwmonSensors(sensorPath)
|
||||
stats.Sensors = append(stats.Sensors, sensors...)
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (c *TemperatureCollector) readHwmonSensors(hwmonPath string) []models.TemperatureSensor {
|
||||
var sensors []models.TemperatureSensor
|
||||
|
||||
// Get sensor name
|
||||
nameData, err := os.ReadFile(filepath.Join(hwmonPath, "name"))
|
||||
sensorName := "unknown"
|
||||
if err == nil {
|
||||
sensorName = strings.TrimSpace(string(nameData))
|
||||
}
|
||||
|
||||
// Find all temperature inputs
|
||||
entries, err := os.ReadDir(hwmonPath)
|
||||
if err != nil {
|
||||
return sensors
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if !strings.HasPrefix(name, "temp") || !strings.HasSuffix(name, "_input") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Read temperature (in millidegrees)
|
||||
tempData, err := os.ReadFile(filepath.Join(hwmonPath, name))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
tempMilli, err := strconv.ParseInt(strings.TrimSpace(string(tempData)), 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
temp := float64(tempMilli) / 1000.0
|
||||
|
||||
// Get label if available
|
||||
labelFile := strings.Replace(name, "_input", "_label", 1)
|
||||
label := ""
|
||||
labelData, err := os.ReadFile(filepath.Join(hwmonPath, labelFile))
|
||||
if err == nil {
|
||||
label = strings.TrimSpace(string(labelData))
|
||||
}
|
||||
|
||||
// Get critical temperature if available
|
||||
critFile := strings.Replace(name, "_input", "_crit", 1)
|
||||
var crit float64
|
||||
critData, err := os.ReadFile(filepath.Join(hwmonPath, critFile))
|
||||
if err == nil {
|
||||
critMilli, _ := strconv.ParseInt(strings.TrimSpace(string(critData)), 10, 64)
|
||||
crit = float64(critMilli) / 1000.0
|
||||
}
|
||||
|
||||
sensors = append(sensors, models.TemperatureSensor{
|
||||
Name: sensorName,
|
||||
Label: label,
|
||||
Temperature: temp,
|
||||
Critical: crit,
|
||||
})
|
||||
}
|
||||
|
||||
return sensors
|
||||
}
|
||||
36
backend/internal/config/config.go
Normal file
36
backend/internal/config/config.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port string
|
||||
RefreshInterval time.Duration
|
||||
ProcPath string
|
||||
SysPath string
|
||||
MtabPath string
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
interval, err := time.ParseDuration(getEnv("DEFAULT_REFRESH_INTERVAL", "5s"))
|
||||
if err != nil {
|
||||
interval = 5 * time.Second
|
||||
}
|
||||
|
||||
return &Config{
|
||||
Port: getEnv("PORT", "8080"),
|
||||
RefreshInterval: interval,
|
||||
ProcPath: getEnv("PROC_PATH", "/proc"),
|
||||
SysPath: getEnv("SYS_PATH", "/sys"),
|
||||
MtabPath: getEnv("MTAB_PATH", "/etc/mtab"),
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, defaultVal string) string {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
return val
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
19
backend/internal/models/cpu.go
Normal file
19
backend/internal/models/cpu.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package models
|
||||
|
||||
type CPUStats struct {
|
||||
Cores []CPUCore `json:"cores"`
|
||||
TotalUsage float64 `json:"totalUsage"`
|
||||
LoadAverage LoadAverage `json:"loadAverage"`
|
||||
}
|
||||
|
||||
type CPUCore struct {
|
||||
ID int `json:"id"`
|
||||
Usage float64 `json:"usage"`
|
||||
Frequency int64 `json:"frequency"` // MHz
|
||||
}
|
||||
|
||||
type LoadAverage struct {
|
||||
Load1 float64 `json:"load1"`
|
||||
Load5 float64 `json:"load5"`
|
||||
Load15 float64 `json:"load15"`
|
||||
}
|
||||
22
backend/internal/models/disk.go
Normal file
22
backend/internal/models/disk.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package models
|
||||
|
||||
type DiskStats struct {
|
||||
Mounts []MountStats `json:"mounts"`
|
||||
IO []DiskIO `json:"io"`
|
||||
}
|
||||
|
||||
type MountStats struct {
|
||||
Device string `json:"device"`
|
||||
MountPoint string `json:"mountPoint"`
|
||||
Filesystem string `json:"filesystem"`
|
||||
Total uint64 `json:"total"`
|
||||
Used uint64 `json:"used"`
|
||||
Available uint64 `json:"available"`
|
||||
UsedPercent float64 `json:"usedPercent"`
|
||||
}
|
||||
|
||||
type DiskIO struct {
|
||||
Device string `json:"device"`
|
||||
ReadBytes uint64 `json:"readBytes"`
|
||||
WriteBytes uint64 `json:"writeBytes"`
|
||||
}
|
||||
14
backend/internal/models/gpu.go
Normal file
14
backend/internal/models/gpu.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package models
|
||||
|
||||
type AMDGPUStats struct {
|
||||
Available bool `json:"available"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Utilization int `json:"utilization"`
|
||||
VRAMUsed uint64 `json:"vramUsed"`
|
||||
VRAMTotal uint64 `json:"vramTotal"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
FanRPM int `json:"fanRpm"`
|
||||
PowerWatts float64 `json:"powerWatts"`
|
||||
ClockGPU int `json:"clockGpu"`
|
||||
ClockMemory int `json:"clockMemory"`
|
||||
}
|
||||
12
backend/internal/models/memory.go
Normal file
12
backend/internal/models/memory.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package models
|
||||
|
||||
type MemoryStats struct {
|
||||
Total uint64 `json:"total"`
|
||||
Used uint64 `json:"used"`
|
||||
Available uint64 `json:"available"`
|
||||
Cached uint64 `json:"cached"`
|
||||
Buffers uint64 `json:"buffers"`
|
||||
SwapTotal uint64 `json:"swapTotal"`
|
||||
SwapUsed uint64 `json:"swapUsed"`
|
||||
SwapFree uint64 `json:"swapFree"`
|
||||
}
|
||||
14
backend/internal/models/network.go
Normal file
14
backend/internal/models/network.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package models
|
||||
|
||||
type NetworkStats struct {
|
||||
Interfaces []InterfaceStats `json:"interfaces"`
|
||||
ConnectionCount int `json:"connectionCount"`
|
||||
}
|
||||
|
||||
type InterfaceStats struct {
|
||||
Name string `json:"name"`
|
||||
RxBytes uint64 `json:"rxBytes"`
|
||||
TxBytes uint64 `json:"txBytes"`
|
||||
RxPackets uint64 `json:"rxPackets"`
|
||||
TxPackets uint64 `json:"txPackets"`
|
||||
}
|
||||
15
backend/internal/models/processes.go
Normal file
15
backend/internal/models/processes.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package models
|
||||
|
||||
type ProcessStats struct {
|
||||
TopByCPU []ProcessInfo `json:"topByCpu"`
|
||||
TopByMemory []ProcessInfo `json:"topByMemory"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type ProcessInfo struct {
|
||||
PID int `json:"pid"`
|
||||
Name string `json:"name"`
|
||||
CPUPercent float64 `json:"cpuPercent"`
|
||||
MemoryMB float64 `json:"memoryMb"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
23
backend/internal/models/system.go
Normal file
23
backend/internal/models/system.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type SystemInfo struct {
|
||||
Hostname string `json:"hostname"`
|
||||
OS string `json:"os"`
|
||||
Kernel string `json:"kernel"`
|
||||
Uptime int64 `json:"uptime"`
|
||||
Architecture string `json:"architecture"`
|
||||
}
|
||||
|
||||
type AllMetrics struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
System SystemInfo `json:"system"`
|
||||
CPU CPUStats `json:"cpu"`
|
||||
Memory MemoryStats `json:"memory"`
|
||||
Disk DiskStats `json:"disk"`
|
||||
Network NetworkStats `json:"network"`
|
||||
Processes ProcessStats `json:"processes"`
|
||||
Temperature TemperatureStats `json:"temperature"`
|
||||
GPU AMDGPUStats `json:"gpu"`
|
||||
}
|
||||
12
backend/internal/models/temperature.go
Normal file
12
backend/internal/models/temperature.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package models
|
||||
|
||||
type TemperatureStats struct {
|
||||
Sensors []TemperatureSensor `json:"sensors"`
|
||||
}
|
||||
|
||||
type TemperatureSensor struct {
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
Critical float64 `json:"critical,omitempty"`
|
||||
}
|
||||
163
backend/internal/sse/broker.go
Normal file
163
backend/internal/sse/broker.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package sse
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"system-monitor/internal/collectors"
|
||||
"system-monitor/internal/config"
|
||||
"system-monitor/internal/models"
|
||||
)
|
||||
|
||||
type Broker struct {
|
||||
clients map[chan []byte]bool
|
||||
register chan chan []byte
|
||||
unregister chan chan []byte
|
||||
intervalChange chan time.Duration
|
||||
mu sync.RWMutex
|
||||
interval time.Duration
|
||||
cfg *config.Config
|
||||
|
||||
// Collectors
|
||||
system *collectors.SystemCollector
|
||||
cpu *collectors.CPUCollector
|
||||
memory *collectors.MemoryCollector
|
||||
disk *collectors.DiskCollector
|
||||
network *collectors.NetworkCollector
|
||||
processes *collectors.ProcessCollector
|
||||
temperature *collectors.TemperatureCollector
|
||||
gpu *collectors.AMDGPUCollector
|
||||
}
|
||||
|
||||
func NewBroker(cfg *config.Config) *Broker {
|
||||
return &Broker{
|
||||
clients: make(map[chan []byte]bool),
|
||||
register: make(chan chan []byte),
|
||||
unregister: make(chan chan []byte),
|
||||
intervalChange: make(chan time.Duration, 1),
|
||||
interval: cfg.RefreshInterval,
|
||||
cfg: cfg,
|
||||
system: collectors.NewSystemCollector(cfg.ProcPath),
|
||||
cpu: collectors.NewCPUCollector(cfg.ProcPath, cfg.SysPath),
|
||||
memory: collectors.NewMemoryCollector(cfg.ProcPath),
|
||||
disk: collectors.NewDiskCollector(cfg.ProcPath, cfg.MtabPath),
|
||||
network: collectors.NewNetworkCollector(cfg.ProcPath),
|
||||
processes: collectors.NewProcessCollector(cfg.ProcPath),
|
||||
temperature: collectors.NewTemperatureCollector(cfg.SysPath),
|
||||
gpu: collectors.NewAMDGPUCollector(cfg.SysPath),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Broker) Run() {
|
||||
ticker := time.NewTicker(b.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case client := <-b.register:
|
||||
b.mu.Lock()
|
||||
b.clients[client] = true
|
||||
b.mu.Unlock()
|
||||
|
||||
case client := <-b.unregister:
|
||||
b.mu.Lock()
|
||||
if _, ok := b.clients[client]; ok {
|
||||
delete(b.clients, client)
|
||||
close(client)
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
case newInterval := <-b.intervalChange:
|
||||
ticker.Reset(newInterval)
|
||||
|
||||
case <-ticker.C:
|
||||
b.mu.RLock()
|
||||
if len(b.clients) > 0 {
|
||||
metrics := b.collectAll()
|
||||
data, err := json.Marshal(metrics)
|
||||
if err == nil {
|
||||
for client := range b.clients {
|
||||
select {
|
||||
case client <- data:
|
||||
default:
|
||||
// Client buffer full, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
b.mu.RUnlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Broker) Register(client chan []byte) {
|
||||
b.register <- client
|
||||
}
|
||||
|
||||
func (b *Broker) Unregister(client chan []byte) {
|
||||
b.unregister <- client
|
||||
}
|
||||
|
||||
func (b *Broker) SetInterval(d time.Duration) {
|
||||
b.mu.Lock()
|
||||
b.interval = d
|
||||
b.mu.Unlock()
|
||||
|
||||
// Signal the Run goroutine to reset the ticker
|
||||
select {
|
||||
case b.intervalChange <- d:
|
||||
default:
|
||||
// Channel has pending update, skip
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Broker) GetInterval() time.Duration {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.interval
|
||||
}
|
||||
|
||||
func (b *Broker) CollectAll() models.AllMetrics {
|
||||
return b.collectAll()
|
||||
}
|
||||
|
||||
func (b *Broker) collectAll() models.AllMetrics {
|
||||
metrics := models.AllMetrics{
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
if sys, err := b.system.Collect(); err == nil {
|
||||
metrics.System = sys
|
||||
}
|
||||
|
||||
if cpu, err := b.cpu.Collect(); err == nil {
|
||||
metrics.CPU = cpu
|
||||
}
|
||||
|
||||
if mem, err := b.memory.Collect(); err == nil {
|
||||
metrics.Memory = mem
|
||||
}
|
||||
|
||||
if disk, err := b.disk.Collect(); err == nil {
|
||||
metrics.Disk = disk
|
||||
}
|
||||
|
||||
if net, err := b.network.Collect(); err == nil {
|
||||
metrics.Network = net
|
||||
}
|
||||
|
||||
if proc, err := b.processes.Collect(); err == nil {
|
||||
metrics.Processes = proc
|
||||
}
|
||||
|
||||
if temp, err := b.temperature.Collect(); err == nil {
|
||||
metrics.Temperature = temp
|
||||
}
|
||||
|
||||
if gpu, err := b.gpu.Collect(); err == nil {
|
||||
metrics.GPU = gpu
|
||||
}
|
||||
|
||||
return metrics
|
||||
}
|
||||
36
docker-compose.yaml
Normal file
36
docker-compose.yaml
Normal file
@@ -0,0 +1,36 @@
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: sysmon-backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- PORT=8080
|
||||
- PROC_PATH=/host/proc
|
||||
- SYS_PATH=/host/sys
|
||||
- MTAB_PATH=/host/etc/mtab
|
||||
- DEFAULT_REFRESH_INTERVAL=5s
|
||||
volumes:
|
||||
- /proc:/host/proc:ro
|
||||
- /sys:/host/sys:ro
|
||||
- /etc/mtab:/host/etc/mtab:ro
|
||||
networks:
|
||||
- sysmon
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: sysmon-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "9847:80"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- sysmon
|
||||
|
||||
networks:
|
||||
sysmon:
|
||||
driver: bridge
|
||||
24
frontend/Dockerfile
Normal file
24
frontend/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
|
||||
# Copy source and build
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Runtime - nginx for static serving
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built files
|
||||
COPY --from=builder /app/build /usr/share/nginx/html
|
||||
|
||||
# Copy nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
41
frontend/nginx.conf
Normal file
41
frontend/nginx.conf
Normal file
@@ -0,0 +1,41 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||
|
||||
# SvelteKit SPA routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API proxy to backend
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# SSE specific settings
|
||||
proxy_set_header X-Accel-Buffering no;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
|
||||
# Health check
|
||||
location /health {
|
||||
proxy_pass http://backend:8080;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
24
frontend/package.json
Normal file
24
frontend/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "system-monitor-frontend",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.9.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"svelte": "^5.11.0",
|
||||
"svelte-check": "^4.1.0",
|
||||
"tailwindcss": "^3.4.16",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.3"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
161
frontend/src/app.css
Normal file
161
frontend/src/app.css
Normal file
@@ -0,0 +1,161 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--gradient-1: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--gradient-2: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
--gradient-3: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
--gradient-4: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
|
||||
--gradient-5: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
|
||||
--gradient-6: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
|
||||
}
|
||||
|
||||
body {
|
||||
@apply antialiased;
|
||||
background: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 50%, #16213e 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
@apply rounded-2xl p-5 relative overflow-hidden;
|
||||
background: rgba(30, 41, 59, 0.5);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.3),
|
||||
0 2px 4px -2px rgba(0, 0, 0, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.4),
|
||||
0 4px 6px -4px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
@apply text-lg font-semibold mb-4 flex items-center gap-2;
|
||||
background: linear-gradient(135deg, #fff 0%, #a0aec0 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
@apply h-2 rounded-full overflow-hidden;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
@apply h-full rounded-full transition-all duration-500 ease-out;
|
||||
box-shadow: 0 0 10px currentColor;
|
||||
}
|
||||
|
||||
.progress-fill.green {
|
||||
background: linear-gradient(90deg, #10b981 0%, #34d399 100%);
|
||||
}
|
||||
|
||||
.progress-fill.yellow {
|
||||
background: linear-gradient(90deg, #f59e0b 0%, #fbbf24 100%);
|
||||
}
|
||||
|
||||
.progress-fill.red {
|
||||
background: linear-gradient(90deg, #ef4444 0%, #f87171 100%);
|
||||
}
|
||||
|
||||
.progress-fill.blue {
|
||||
background: linear-gradient(90deg, #3b82f6 0%, #60a5fa 100%);
|
||||
}
|
||||
|
||||
.progress-fill.purple {
|
||||
background: linear-gradient(90deg, #8b5cf6 0%, #a78bfa 100%);
|
||||
}
|
||||
|
||||
.progress-fill.orange {
|
||||
background: linear-gradient(90deg, #f97316 0%, #fb923c 100%);
|
||||
}
|
||||
|
||||
.progress-fill.cyan {
|
||||
background: linear-gradient(90deg, #06b6d4 0%, #22d3ee 100%);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
@apply font-mono text-xl font-bold;
|
||||
background: linear-gradient(135deg, #fff 0%, #94a3b8 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
@apply text-xs uppercase tracking-wider text-slate-400;
|
||||
}
|
||||
|
||||
.metric-badge {
|
||||
@apply px-2 py-1 rounded-lg text-xs font-medium;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.glow-text {
|
||||
text-shadow: 0 0 20px currentColor;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-xl font-medium text-sm transition-all duration-200;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.btn-active {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
|
||||
border-color: transparent;
|
||||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.core-bar {
|
||||
@apply relative rounded-lg overflow-hidden;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
.core-fill {
|
||||
@apply absolute bottom-0 left-0 right-0 transition-all duration-300;
|
||||
border-radius: 0 0 0.5rem 0.5rem;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
@apply grid gap-2 py-2 px-2 rounded-lg transition-colors;
|
||||
}
|
||||
|
||||
.table-row:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { box-shadow: 0 0 5px currentColor; }
|
||||
50% { box-shadow: 0 0 20px currentColor; }
|
||||
}
|
||||
|
||||
.connected-indicator {
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
13
frontend/src/app.d.ts
vendored
Normal file
13
frontend/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
13
frontend/src/app.html
Normal file
13
frontend/src/app.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>System Monitor</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover" class="bg-surface-950 text-white">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
54
frontend/src/lib/api/sse.ts
Normal file
54
frontend/src/lib/api/sse.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { metrics, connected } from '$lib/stores/metrics';
|
||||
import { browser } from '$app/environment';
|
||||
import type { AllMetrics } from '$lib/types/metrics';
|
||||
|
||||
let eventSource: EventSource | null = null;
|
||||
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
export function connectSSE() {
|
||||
if (!browser) return;
|
||||
|
||||
disconnectSSE();
|
||||
|
||||
const url = '/api/v1/stream';
|
||||
eventSource = new EventSource(url);
|
||||
|
||||
eventSource.onopen = () => {
|
||||
connected.set(true);
|
||||
console.log('SSE connected');
|
||||
};
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data: AllMetrics = JSON.parse(event.data);
|
||||
metrics.set(data);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse SSE message:', e);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
connected.set(false);
|
||||
console.warn('SSE connection error, reconnecting in 3s...');
|
||||
|
||||
disconnectSSE();
|
||||
|
||||
reconnectTimeout = setTimeout(() => {
|
||||
connectSSE();
|
||||
}, 3000);
|
||||
};
|
||||
}
|
||||
|
||||
export function disconnectSSE() {
|
||||
if (reconnectTimeout) {
|
||||
clearTimeout(reconnectTimeout);
|
||||
reconnectTimeout = null;
|
||||
}
|
||||
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
|
||||
connected.set(false);
|
||||
}
|
||||
67
frontend/src/lib/components/Header.svelte
Normal file
67
frontend/src/lib/components/Header.svelte
Normal file
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import { connected, systemInfo } from '$lib/stores/metrics';
|
||||
import { settings } from '$lib/stores/settings';
|
||||
import { formatUptime } from '$lib/utils/formatters';
|
||||
|
||||
const refreshRates = [1, 2, 5, 10, 30];
|
||||
</script>
|
||||
|
||||
<header class="sticky top-0 z-50 backdrop-blur-xl border-b border-white/5">
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-slate-900/90 via-slate-800/90 to-slate-900/90"></div>
|
||||
<div class="relative container mx-auto px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-6">
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center shadow-lg shadow-blue-500/20">
|
||||
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold bg-gradient-to-r from-white to-slate-300 bg-clip-text text-transparent">
|
||||
System Monitor
|
||||
</h1>
|
||||
{#if $systemInfo}
|
||||
<p class="text-xs text-slate-400">{$systemInfo.hostname}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System info badges -->
|
||||
{#if $systemInfo}
|
||||
<div class="hidden md:flex items-center gap-2">
|
||||
<span class="metric-badge">{$systemInfo.kernel}</span>
|
||||
<span class="metric-badge">Up: {formatUptime($systemInfo.uptime)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Refresh rate -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-slate-400 hidden sm:inline">Refresh</span>
|
||||
<select
|
||||
class="bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500/50 cursor-pointer"
|
||||
value={$settings.refreshRate}
|
||||
onchange={(e) => settings.setRefreshRate(parseInt(e.currentTarget.value))}
|
||||
>
|
||||
{#each refreshRates as rate}
|
||||
<option value={rate} class="bg-slate-800">{rate}s</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Connection status -->
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 rounded-lg {$connected ? 'bg-emerald-500/10' : 'bg-red-500/10'}">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full {$connected ? 'bg-emerald-400 connected-indicator' : 'bg-red-400'}"
|
||||
></div>
|
||||
<span class="text-sm {$connected ? 'text-emerald-400' : 'text-red-400'}">
|
||||
{$connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
77
frontend/src/lib/components/cards/CpuCard.svelte
Normal file
77
frontend/src/lib/components/cards/CpuCard.svelte
Normal file
@@ -0,0 +1,77 @@
|
||||
<script lang="ts">
|
||||
import Card from '$lib/components/common/Card.svelte';
|
||||
import ProgressBar from '$lib/components/common/ProgressBar.svelte';
|
||||
import SparkLine from '$lib/components/common/SparkLine.svelte';
|
||||
import { cpuStats, cpuHistory } from '$lib/stores/metrics';
|
||||
import { formatFrequency } from '$lib/utils/formatters';
|
||||
|
||||
function getCoreColor(usage: number): string {
|
||||
if (usage >= 90) return 'from-red-500 to-red-400';
|
||||
if (usage >= 70) return 'from-yellow-500 to-yellow-400';
|
||||
if (usage >= 40) return 'from-blue-500 to-blue-400';
|
||||
return 'from-emerald-500 to-emerald-400';
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card title="CPU" icon="⚡">
|
||||
{#if $cpuStats}
|
||||
<div class="space-y-5">
|
||||
<!-- Total usage with large display -->
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-end gap-3 mb-2">
|
||||
<span class="text-4xl font-bold bg-gradient-to-r from-blue-400 to-cyan-400 bg-clip-text text-transparent">
|
||||
{$cpuStats.totalUsage.toFixed(1)}%
|
||||
</span>
|
||||
<span class="text-slate-400 text-sm mb-1">usage</span>
|
||||
</div>
|
||||
<ProgressBar value={$cpuStats.totalUsage} color="auto" showLabel={false} size="lg" />
|
||||
</div>
|
||||
<div class="w-32 h-16">
|
||||
<SparkLine data={$cpuHistory} width={128} height={64} color="#3b82f6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load average badges -->
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-1 bg-white/5 rounded-xl p-3 text-center">
|
||||
<div class="text-lg font-mono font-bold text-slate-200">{$cpuStats.loadAverage.load1.toFixed(2)}</div>
|
||||
<div class="stat-label">1 min</div>
|
||||
</div>
|
||||
<div class="flex-1 bg-white/5 rounded-xl p-3 text-center">
|
||||
<div class="text-lg font-mono font-bold text-slate-200">{$cpuStats.loadAverage.load5.toFixed(2)}</div>
|
||||
<div class="stat-label">5 min</div>
|
||||
</div>
|
||||
<div class="flex-1 bg-white/5 rounded-xl p-3 text-center">
|
||||
<div class="text-lg font-mono font-bold text-slate-200">{$cpuStats.loadAverage.load15.toFixed(2)}</div>
|
||||
<div class="stat-label">15 min</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-core usage grid -->
|
||||
<div class="grid grid-cols-4 sm:grid-cols-8 gap-2">
|
||||
{#each $cpuStats.cores as core}
|
||||
<div class="text-center group">
|
||||
<div class="core-bar">
|
||||
<div
|
||||
class="core-fill bg-gradient-to-t {getCoreColor(core.usage)}"
|
||||
style="height: {core.usage}%"
|
||||
></div>
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span class="text-[10px] text-slate-400">C{core.id}</span>
|
||||
<span class="text-xs font-bold text-white">{core.usage.toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-[9px] text-slate-500 mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{formatFrequency(core.frequency)}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-48 flex items-center justify-center text-slate-400">
|
||||
<div class="animate-pulse">Loading...</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
78
frontend/src/lib/components/cards/DiskCard.svelte
Normal file
78
frontend/src/lib/components/cards/DiskCard.svelte
Normal file
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import Card from '$lib/components/common/Card.svelte';
|
||||
import ProgressBar from '$lib/components/common/ProgressBar.svelte';
|
||||
import { diskStats } from '$lib/stores/metrics';
|
||||
import { formatBytes } from '$lib/utils/formatters';
|
||||
|
||||
// Get unique mounts by device (container shows same device multiple times)
|
||||
const uniqueMounts = $derived(() => {
|
||||
if (!$diskStats?.mounts) return [];
|
||||
const seen = new Map<string, typeof $diskStats.mounts[0]>();
|
||||
for (const m of $diskStats.mounts) {
|
||||
if (!seen.has(m.device)) {
|
||||
seen.set(m.device, m);
|
||||
}
|
||||
}
|
||||
return Array.from(seen.values());
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card title="Disk" icon="💾">
|
||||
{#if $diskStats}
|
||||
<div class="space-y-4">
|
||||
<!-- Show unique mounts by device -->
|
||||
{#each uniqueMounts() as mount}
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 rounded-full bg-blue-400"></div>
|
||||
<span class="text-sm font-medium text-slate-200 font-mono">
|
||||
{mount.device.split('/').pop()}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-slate-400">
|
||||
{mount.filesystem}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar value={mount.usedPercent} color="auto" showLabel={false} size="sm" />
|
||||
<div class="flex justify-between text-xs">
|
||||
<span class="text-slate-400">{formatBytes(mount.used)} used</span>
|
||||
<span class="text-slate-300 font-mono">{formatBytes(mount.available)} free</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if uniqueMounts().length === 0 && $diskStats.io.length > 0}
|
||||
<!-- Fallback: just show IO stats as main content -->
|
||||
<div class="text-center text-slate-400 py-2 text-sm">
|
||||
Mount info unavailable in container
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $diskStats.io && $diskStats.io.length > 0}
|
||||
<div class="pt-3 border-t border-white/5">
|
||||
<div class="stat-label mb-3">I/O Activity</div>
|
||||
<div class="space-y-2">
|
||||
{#each $diskStats.io as io}
|
||||
<div class="flex items-center justify-between py-1.5 px-2 rounded-lg bg-white/[0.02]">
|
||||
<span class="font-mono text-sm text-slate-300">{io.device}</span>
|
||||
<div class="flex gap-3 text-xs font-mono">
|
||||
<span class="text-emerald-400 flex items-center gap-1">
|
||||
<span class="text-[10px]">↓</span> {formatBytes(io.readBytes)}
|
||||
</span>
|
||||
<span class="text-blue-400 flex items-center gap-1">
|
||||
<span class="text-[10px]">↑</span> {formatBytes(io.writeBytes)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-48 flex items-center justify-center text-slate-400">
|
||||
<div class="animate-pulse">Loading...</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
88
frontend/src/lib/components/cards/GpuCard.svelte
Normal file
88
frontend/src/lib/components/cards/GpuCard.svelte
Normal file
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import Card from '$lib/components/common/Card.svelte';
|
||||
import ProgressBar from '$lib/components/common/ProgressBar.svelte';
|
||||
import SparkLine from '$lib/components/common/SparkLine.svelte';
|
||||
import { gpuStats, gpuHistory } from '$lib/stores/metrics';
|
||||
import { formatBytes, formatTemperature, formatWatts, formatFrequency } from '$lib/utils/formatters';
|
||||
|
||||
function getTempColor(temp: number): string {
|
||||
if (temp >= 80) return 'text-red-400';
|
||||
if (temp >= 60) return 'text-yellow-400';
|
||||
return 'text-emerald-400';
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card title="GPU" icon="🎮">
|
||||
{#if $gpuStats}
|
||||
{#if $gpuStats.available}
|
||||
{@const vramPercent = $gpuStats.vramTotal > 0 ? ($gpuStats.vramUsed / $gpuStats.vramTotal) * 100 : 0}
|
||||
|
||||
<div class="space-y-5">
|
||||
<!-- Utilization -->
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-end gap-3 mb-2">
|
||||
<span class="text-4xl font-bold bg-gradient-to-r from-orange-400 to-red-400 bg-clip-text text-transparent">
|
||||
{$gpuStats.utilization}%
|
||||
</span>
|
||||
<span class="text-slate-400 text-sm mb-1">GPU</span>
|
||||
</div>
|
||||
<ProgressBar value={$gpuStats.utilization} color="orange" showLabel={false} size="lg" />
|
||||
</div>
|
||||
<div class="w-32 h-16">
|
||||
<SparkLine data={$gpuHistory} width={128} height={64} color="#f97316" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VRAM -->
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-sm text-slate-300">VRAM</span>
|
||||
<span class="text-sm font-mono text-slate-400">
|
||||
{formatBytes($gpuStats.vramUsed)} / {formatBytes($gpuStats.vramTotal)}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar value={vramPercent} color="orange" showLabel={false} size="sm" />
|
||||
</div>
|
||||
|
||||
<!-- Stats grid -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="bg-white/5 rounded-xl p-3">
|
||||
<div class="stat-label mb-1">Temperature</div>
|
||||
<div class="text-lg font-mono font-bold {getTempColor($gpuStats.temperature)}">
|
||||
{formatTemperature($gpuStats.temperature)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white/5 rounded-xl p-3">
|
||||
<div class="stat-label mb-1">Power</div>
|
||||
<div class="text-lg font-mono font-bold text-slate-200">{formatWatts($gpuStats.powerWatts)}</div>
|
||||
</div>
|
||||
<div class="bg-white/5 rounded-xl p-3">
|
||||
<div class="stat-label mb-1">GPU Clock</div>
|
||||
<div class="text-lg font-mono font-bold text-slate-200">{formatFrequency($gpuStats.clockGpu)}</div>
|
||||
</div>
|
||||
<div class="bg-white/5 rounded-xl p-3">
|
||||
<div class="stat-label mb-1">Mem Clock</div>
|
||||
<div class="text-lg font-mono font-bold text-slate-200">{formatFrequency($gpuStats.clockMemory)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if $gpuStats.fanRpm > 0}
|
||||
<div class="flex items-center gap-2 text-sm text-slate-400">
|
||||
<span>🌀</span>
|
||||
<span>Fan: {$gpuStats.fanRpm.toLocaleString()} RPM</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-48 flex flex-col items-center justify-center text-slate-400">
|
||||
<span class="text-4xl mb-2">🎮</span>
|
||||
<span>No AMD GPU detected</span>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="h-48 flex items-center justify-center text-slate-400">
|
||||
<div class="animate-pulse">Loading...</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
69
frontend/src/lib/components/cards/MemoryCard.svelte
Normal file
69
frontend/src/lib/components/cards/MemoryCard.svelte
Normal file
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import Card from '$lib/components/common/Card.svelte';
|
||||
import ProgressBar from '$lib/components/common/ProgressBar.svelte';
|
||||
import SparkLine from '$lib/components/common/SparkLine.svelte';
|
||||
import { memoryStats, memoryHistory } from '$lib/stores/metrics';
|
||||
import { formatBytes } from '$lib/utils/formatters';
|
||||
</script>
|
||||
|
||||
<Card title="Memory" icon="🧠">
|
||||
{#if $memoryStats}
|
||||
{@const usedPercent = ($memoryStats.used / $memoryStats.total) * 100}
|
||||
{@const swapPercent = $memoryStats.swapTotal > 0 ? ($memoryStats.swapUsed / $memoryStats.swapTotal) * 100 : 0}
|
||||
|
||||
<div class="space-y-5">
|
||||
<!-- Main memory display -->
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-end gap-3 mb-2">
|
||||
<span class="text-4xl font-bold bg-gradient-to-r from-purple-400 to-pink-400 bg-clip-text text-transparent">
|
||||
{usedPercent.toFixed(1)}%
|
||||
</span>
|
||||
<span class="text-slate-400 text-sm mb-1">used</span>
|
||||
</div>
|
||||
<ProgressBar value={usedPercent} color="purple" showLabel={false} size="lg" />
|
||||
</div>
|
||||
<div class="w-32 h-16">
|
||||
<SparkLine data={$memoryHistory} width={128} height={64} color="#8b5cf6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Memory stats grid -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="bg-white/5 rounded-xl p-3">
|
||||
<div class="stat-label mb-1">Used</div>
|
||||
<div class="text-lg font-mono font-bold text-slate-200">{formatBytes($memoryStats.used)}</div>
|
||||
</div>
|
||||
<div class="bg-white/5 rounded-xl p-3">
|
||||
<div class="stat-label mb-1">Available</div>
|
||||
<div class="text-lg font-mono font-bold text-emerald-400">{formatBytes($memoryStats.available)}</div>
|
||||
</div>
|
||||
<div class="bg-white/5 rounded-xl p-3">
|
||||
<div class="stat-label mb-1">Cached</div>
|
||||
<div class="text-lg font-mono font-bold text-slate-200">{formatBytes($memoryStats.cached)}</div>
|
||||
</div>
|
||||
<div class="bg-white/5 rounded-xl p-3">
|
||||
<div class="stat-label mb-1">Total</div>
|
||||
<div class="text-lg font-mono font-bold text-slate-200">{formatBytes($memoryStats.total)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Swap -->
|
||||
{#if $memoryStats.swapTotal > 0}
|
||||
<div class="pt-3 border-t border-white/5">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-sm text-slate-300">Swap</span>
|
||||
<span class="text-sm font-mono text-slate-400">
|
||||
{formatBytes($memoryStats.swapUsed)} / {formatBytes($memoryStats.swapTotal)}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar value={swapPercent} color="cyan" showLabel={false} size="sm" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-48 flex items-center justify-center text-slate-400">
|
||||
<div class="animate-pulse">Loading...</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
49
frontend/src/lib/components/cards/NetworkCard.svelte
Normal file
49
frontend/src/lib/components/cards/NetworkCard.svelte
Normal file
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import Card from '$lib/components/common/Card.svelte';
|
||||
import { networkStats } from '$lib/stores/metrics';
|
||||
import { formatBytes } from '$lib/utils/formatters';
|
||||
</script>
|
||||
|
||||
<Card title="Network" icon="🌐">
|
||||
{#if $networkStats}
|
||||
<div class="space-y-4">
|
||||
{#each $networkStats.interfaces as iface}
|
||||
<div class="bg-white/5 rounded-xl p-4">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<span class="text-sm font-semibold text-slate-200">{iface.name}</span>
|
||||
<span class="metric-badge text-xs">Active</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-emerald-400">↓</span>
|
||||
<span class="stat-label">Download</span>
|
||||
</div>
|
||||
<div class="text-lg font-mono font-bold text-emerald-400">{formatBytes(iface.rxBytes)}</div>
|
||||
<div class="text-xs text-slate-500">{iface.rxPackets.toLocaleString()} packets</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-blue-400">↑</span>
|
||||
<span class="stat-label">Upload</span>
|
||||
</div>
|
||||
<div class="text-lg font-mono font-bold text-blue-400">{formatBytes(iface.txBytes)}</div>
|
||||
<div class="text-xs text-slate-500">{iface.txPackets.toLocaleString()} packets</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="flex items-center justify-between pt-2 text-sm">
|
||||
<span class="text-slate-400">TCP Connections</span>
|
||||
<span class="font-mono text-slate-200 bg-white/5 px-3 py-1 rounded-lg">
|
||||
{$networkStats.connectionCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-48 flex items-center justify-center text-slate-400">
|
||||
<div class="animate-pulse">Loading...</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
83
frontend/src/lib/components/cards/ProcessesCard.svelte
Normal file
83
frontend/src/lib/components/cards/ProcessesCard.svelte
Normal file
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import Card from '$lib/components/common/Card.svelte';
|
||||
import { processStats } from '$lib/stores/metrics';
|
||||
|
||||
let view = $state<'cpu' | 'memory'>('cpu');
|
||||
|
||||
// Use $derived with explicit dependency on view
|
||||
const processes = $derived.by(() => {
|
||||
if (view === 'cpu') {
|
||||
return $processStats?.topByCpu ?? [];
|
||||
}
|
||||
return $processStats?.topByMemory ?? [];
|
||||
});
|
||||
|
||||
function getCpuColor(percent: number): string {
|
||||
if (percent > 50) return 'text-red-400';
|
||||
if (percent > 20) return 'text-amber-400';
|
||||
if (percent > 5) return 'text-blue-400';
|
||||
return 'text-slate-400';
|
||||
}
|
||||
|
||||
function getMemColor(mb: number): string {
|
||||
if (mb > 2000) return 'text-purple-400';
|
||||
if (mb > 500) return 'text-blue-400';
|
||||
return 'text-slate-400';
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card title="Processes" icon="📊">
|
||||
{#if $processStats && ($processStats.topByCpu?.length > 0 || $processStats.topByMemory?.length > 0)}
|
||||
<div class="space-y-3">
|
||||
<!-- Toggle and count -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex rounded-lg bg-white/5 p-0.5">
|
||||
<button
|
||||
class="px-3 py-1 text-xs font-medium rounded-md transition-all {view === 'cpu' ? 'bg-blue-500/20 text-blue-400' : 'text-slate-400 hover:text-slate-300'}"
|
||||
onclick={() => { view = 'cpu'; }}
|
||||
>
|
||||
By CPU
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1 text-xs font-medium rounded-md transition-all {view === 'memory' ? 'bg-purple-500/20 text-purple-400' : 'text-slate-400 hover:text-slate-300'}"
|
||||
onclick={() => { view = 'memory'; }}
|
||||
>
|
||||
By Memory
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">
|
||||
<span class="text-slate-300 font-medium">{$processStats.total}</span> total
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Process grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-2">
|
||||
{#each processes.slice(0, 10) as proc, i (proc.pid)}
|
||||
<div class="flex items-center gap-3 py-2 px-3 rounded-lg bg-white/[0.02] hover:bg-white/[0.04] transition-colors border border-white/5">
|
||||
<div class="flex-shrink-0 w-6 h-6 rounded-full bg-slate-700/50 flex items-center justify-center text-[10px] text-slate-400 font-mono">
|
||||
{i + 1}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm text-slate-200 truncate font-medium" title={proc.name}>
|
||||
{proc.name}
|
||||
</div>
|
||||
<div class="text-[10px] text-slate-500 font-mono">PID {proc.pid}</div>
|
||||
</div>
|
||||
<div class="text-right flex-shrink-0">
|
||||
<div class="text-sm font-mono font-bold {getCpuColor(proc.cpuPercent)}">
|
||||
{proc.cpuPercent.toFixed(1)}%
|
||||
</div>
|
||||
<div class="text-[10px] font-mono {getMemColor(proc.memoryMb)}">
|
||||
{proc.memoryMb.toFixed(0)} MB
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-32 flex items-center justify-center text-slate-400">
|
||||
<div class="animate-pulse">Loading processes...</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
120
frontend/src/lib/components/cards/TemperatureCard.svelte
Normal file
120
frontend/src/lib/components/cards/TemperatureCard.svelte
Normal file
@@ -0,0 +1,120 @@
|
||||
<script lang="ts">
|
||||
import Card from '$lib/components/common/Card.svelte';
|
||||
import { temperatureStats } from '$lib/stores/metrics';
|
||||
import { formatTemperature } from '$lib/utils/formatters';
|
||||
|
||||
// Group sensors by type and pick the most relevant ones
|
||||
const groupedSensors = $derived(() => {
|
||||
if (!$temperatureStats?.sensors) return [];
|
||||
|
||||
const groups: Record<string, { name: string; label: string; temp: number; critical?: number }[]> = {};
|
||||
|
||||
for (const s of $temperatureStats.sensors) {
|
||||
const key = s.name;
|
||||
if (!groups[key]) groups[key] = [];
|
||||
groups[key].push({ name: s.name, label: s.label, temp: s.temperature, critical: s.critical });
|
||||
}
|
||||
|
||||
// Pick the hottest sensor from each group (most relevant)
|
||||
return Object.entries(groups).map(([name, sensors]) => {
|
||||
const hottest = sensors.reduce((a, b) => a.temp > b.temp ? a : b);
|
||||
return {
|
||||
name,
|
||||
label: hottest.label,
|
||||
temperature: hottest.temp,
|
||||
critical: hottest.critical,
|
||||
count: sensors.length
|
||||
};
|
||||
}).sort((a, b) => b.temperature - a.temperature);
|
||||
});
|
||||
|
||||
// Key sensors to highlight
|
||||
const keySensors = $derived(() => {
|
||||
const g = groupedSensors();
|
||||
return g.filter(s =>
|
||||
s.name === 'k10temp' ||
|
||||
s.name === 'amdgpu' ||
|
||||
s.name.includes('nvme') ||
|
||||
s.name === 'coretemp'
|
||||
);
|
||||
});
|
||||
|
||||
const otherSensors = $derived(() => {
|
||||
const g = groupedSensors();
|
||||
return g.filter(s =>
|
||||
s.name !== 'k10temp' &&
|
||||
s.name !== 'amdgpu' &&
|
||||
!s.name.includes('nvme') &&
|
||||
s.name !== 'coretemp'
|
||||
);
|
||||
});
|
||||
|
||||
function getTempColor(temp: number, critical?: number): string {
|
||||
const threshold = critical || 90;
|
||||
if (temp >= threshold * 0.9) return 'text-red-400';
|
||||
if (temp >= threshold * 0.7) return 'text-amber-400';
|
||||
return 'text-emerald-400';
|
||||
}
|
||||
|
||||
function getDisplayName(name: string): string {
|
||||
const names: Record<string, string> = {
|
||||
'k10temp': 'CPU',
|
||||
'coretemp': 'CPU',
|
||||
'amdgpu': 'GPU',
|
||||
'nvme': 'NVMe',
|
||||
'acpitz': 'ACPI',
|
||||
'gigabyte_wmi': 'Motherboard'
|
||||
};
|
||||
return names[name] || name;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card title="Temperature" icon="🌡️">
|
||||
{#if $temperatureStats && $temperatureStats.sensors.length > 0}
|
||||
<div class="space-y-3">
|
||||
<!-- Key sensors as prominent cards -->
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#each keySensors() as sensor}
|
||||
<div class="bg-white/[0.03] rounded-xl p-3 border border-white/5">
|
||||
<div class="text-[10px] uppercase tracking-wider text-slate-500 mb-1">
|
||||
{getDisplayName(sensor.name)}
|
||||
</div>
|
||||
<div class="text-xl font-bold {getTempColor(sensor.temperature, sensor.critical)}">
|
||||
{formatTemperature(sensor.temperature)}
|
||||
</div>
|
||||
{#if sensor.label}
|
||||
<div class="text-[10px] text-slate-500 mt-0.5">{sensor.label}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Other sensors as compact list -->
|
||||
{#if otherSensors().length > 0}
|
||||
<div class="pt-2 border-t border-white/5">
|
||||
<div class="stat-label mb-2">Other Sensors</div>
|
||||
<div class="space-y-1 max-h-32 overflow-y-auto">
|
||||
{#each otherSensors() as sensor}
|
||||
<div class="flex justify-between items-center py-1 px-2 rounded text-sm hover:bg-white/[0.02] transition-colors">
|
||||
<span class="text-slate-400">
|
||||
{getDisplayName(sensor.name)}
|
||||
{#if sensor.count > 1}
|
||||
<span class="text-slate-600 text-xs">×{sensor.count}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="font-mono {getTempColor(sensor.temperature, sensor.critical)}">
|
||||
{formatTemperature(sensor.temperature)}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-32 flex flex-col items-center justify-center text-slate-400">
|
||||
<span class="text-3xl mb-2">🌡️</span>
|
||||
<span class="text-sm">No sensors detected</span>
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
21
frontend/src/lib/components/common/Card.svelte
Normal file
21
frontend/src/lib/components/common/Card.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
icon?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { title, icon = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="card-title">
|
||||
{#if icon}
|
||||
<span class="text-xl">{icon}</span>
|
||||
{/if}
|
||||
{title}
|
||||
</h2>
|
||||
{@render children()}
|
||||
</div>
|
||||
40
frontend/src/lib/components/common/ProgressBar.svelte
Normal file
40
frontend/src/lib/components/common/ProgressBar.svelte
Normal file
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
value: number;
|
||||
max?: number;
|
||||
color?: 'auto' | 'blue' | 'purple' | 'orange' | 'cyan' | 'green';
|
||||
showLabel?: boolean;
|
||||
label?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
let { value, max = 100, color = 'auto', showLabel = true, label = '', size = 'md' }: Props = $props();
|
||||
|
||||
const percent = $derived(Math.min(100, Math.max(0, (value / max) * 100)));
|
||||
|
||||
function getColorClass(pct: number): string {
|
||||
if (color !== 'auto') return color;
|
||||
if (pct >= 90) return 'red';
|
||||
if (pct >= 70) return 'yellow';
|
||||
return 'green';
|
||||
}
|
||||
|
||||
const heightClass = $derived(
|
||||
size === 'sm' ? 'h-1' : size === 'lg' ? 'h-3' : 'h-2'
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="w-full">
|
||||
{#if showLabel}
|
||||
<div class="flex justify-between items-center text-sm mb-2">
|
||||
<span class="text-slate-300 font-medium">{label}</span>
|
||||
<span class="stat-value text-base">{percent.toFixed(1)}%</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="progress-bar {heightClass}">
|
||||
<div
|
||||
class="progress-fill {getColorClass(percent)}"
|
||||
style="width: {percent}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
70
frontend/src/lib/components/common/SparkLine.svelte
Normal file
70
frontend/src/lib/components/common/SparkLine.svelte
Normal file
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
data: number[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
color?: string;
|
||||
fill?: boolean;
|
||||
}
|
||||
|
||||
let { data, width = 100, height = 24, color = '#3b82f6', fill = true }: Props = $props();
|
||||
|
||||
const path = $derived(() => {
|
||||
if (data.length < 2) return '';
|
||||
|
||||
const max = Math.max(...data, 100);
|
||||
const min = 0;
|
||||
const range = max - min || 1;
|
||||
|
||||
const points = data.map((v, i) => {
|
||||
const x = (i / (data.length - 1)) * width;
|
||||
const y = height - ((v - min) / range) * (height - 4) - 2;
|
||||
return { x, y };
|
||||
});
|
||||
|
||||
return points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x},${p.y}`).join(' ');
|
||||
});
|
||||
|
||||
const fillPath = $derived(() => {
|
||||
if (data.length < 2 || !fill) return '';
|
||||
|
||||
const max = Math.max(...data, 100);
|
||||
const min = 0;
|
||||
const range = max - min || 1;
|
||||
|
||||
const points = data.map((v, i) => {
|
||||
const x = (i / (data.length - 1)) * width;
|
||||
const y = height - ((v - min) / range) * (height - 4) - 2;
|
||||
return { x, y };
|
||||
});
|
||||
|
||||
const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x},${p.y}`).join(' ');
|
||||
return `${linePath} L ${width},${height} L 0,${height} Z`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svg {width} {height} class="overflow-visible">
|
||||
<defs>
|
||||
<linearGradient id="sparkGradient-{color}" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:{color};stop-opacity:0.3" />
|
||||
<stop offset="100%" style="stop-color:{color};stop-opacity:0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{#if fill}
|
||||
<path
|
||||
d={fillPath()}
|
||||
fill="url(#sparkGradient-{color})"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<path
|
||||
d={path()}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
style="filter: drop-shadow(0 0 4px {color})"
|
||||
/>
|
||||
</svg>
|
||||
52
frontend/src/lib/stores/metrics.ts
Normal file
52
frontend/src/lib/stores/metrics.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import type { AllMetrics } from '$lib/types/metrics';
|
||||
|
||||
// Main metrics store
|
||||
export const metrics = writable<AllMetrics | null>(null);
|
||||
|
||||
// Connection status
|
||||
export const connected = writable(false);
|
||||
|
||||
// Derived stores for individual sections
|
||||
export const cpuStats = derived(metrics, ($m) => $m?.cpu ?? null);
|
||||
export const memoryStats = derived(metrics, ($m) => $m?.memory ?? null);
|
||||
export const diskStats = derived(metrics, ($m) => $m?.disk ?? null);
|
||||
export const networkStats = derived(metrics, ($m) => $m?.network ?? null);
|
||||
export const processStats = derived(metrics, ($m) => $m?.processes ?? null);
|
||||
export const temperatureStats = derived(metrics, ($m) => $m?.temperature ?? null);
|
||||
export const gpuStats = derived(metrics, ($m) => $m?.gpu ?? null);
|
||||
export const systemInfo = derived(metrics, ($m) => $m?.system ?? null);
|
||||
|
||||
// Historical data for sparklines
|
||||
const HISTORY_SIZE = 60;
|
||||
|
||||
function createHistoryStore() {
|
||||
const { subscribe, update } = writable<number[]>([]);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
push: (value: number) => {
|
||||
update((h) => {
|
||||
const newHistory = [...h, value];
|
||||
return newHistory.slice(-HISTORY_SIZE);
|
||||
});
|
||||
},
|
||||
reset: () => update(() => [])
|
||||
};
|
||||
}
|
||||
|
||||
export const cpuHistory = createHistoryStore();
|
||||
export const memoryHistory = createHistoryStore();
|
||||
export const gpuHistory = createHistoryStore();
|
||||
|
||||
// Update history when metrics change
|
||||
metrics.subscribe(($m) => {
|
||||
if ($m) {
|
||||
cpuHistory.push($m.cpu.totalUsage);
|
||||
const memPercent = ($m.memory.used / $m.memory.total) * 100;
|
||||
memoryHistory.push(memPercent);
|
||||
if ($m.gpu.available) {
|
||||
gpuHistory.push($m.gpu.utilization);
|
||||
}
|
||||
}
|
||||
});
|
||||
42
frontend/src/lib/stores/settings.ts
Normal file
42
frontend/src/lib/stores/settings.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export interface Settings {
|
||||
refreshRate: number;
|
||||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
refreshRate: 5
|
||||
};
|
||||
|
||||
function createSettingsStore() {
|
||||
const stored = browser ? localStorage.getItem('sysmon-settings') : null;
|
||||
const initial: Settings = stored ? JSON.parse(stored) : defaultSettings;
|
||||
|
||||
const { subscribe, set, update } = writable<Settings>(initial);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
setRefreshRate: async (rate: number) => {
|
||||
update((s) => ({ ...s, refreshRate: rate }));
|
||||
|
||||
// Notify backend
|
||||
try {
|
||||
await fetch('/api/v1/settings/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ interval: rate })
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to update refresh rate:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const settings = createSettingsStore();
|
||||
|
||||
// Persist to localStorage
|
||||
if (browser) {
|
||||
settings.subscribe((s) => localStorage.setItem('sysmon-settings', JSON.stringify(s)));
|
||||
}
|
||||
120
frontend/src/lib/types/metrics.ts
Normal file
120
frontend/src/lib/types/metrics.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
export interface SystemInfo {
|
||||
hostname: string;
|
||||
os: string;
|
||||
kernel: string;
|
||||
uptime: number;
|
||||
architecture: string;
|
||||
}
|
||||
|
||||
export interface CPUCore {
|
||||
id: number;
|
||||
usage: number;
|
||||
frequency: number;
|
||||
}
|
||||
|
||||
export interface LoadAverage {
|
||||
load1: number;
|
||||
load5: number;
|
||||
load15: number;
|
||||
}
|
||||
|
||||
export interface CPUStats {
|
||||
cores: CPUCore[];
|
||||
totalUsage: number;
|
||||
loadAverage: LoadAverage;
|
||||
}
|
||||
|
||||
export interface MemoryStats {
|
||||
total: number;
|
||||
used: number;
|
||||
available: number;
|
||||
cached: number;
|
||||
buffers: number;
|
||||
swapTotal: number;
|
||||
swapUsed: number;
|
||||
swapFree: number;
|
||||
}
|
||||
|
||||
export interface MountStats {
|
||||
device: string;
|
||||
mountPoint: string;
|
||||
filesystem: string;
|
||||
total: number;
|
||||
used: number;
|
||||
available: number;
|
||||
usedPercent: number;
|
||||
}
|
||||
|
||||
export interface DiskIO {
|
||||
device: string;
|
||||
readBytes: number;
|
||||
writeBytes: number;
|
||||
}
|
||||
|
||||
export interface DiskStats {
|
||||
mounts: MountStats[];
|
||||
io: DiskIO[];
|
||||
}
|
||||
|
||||
export interface InterfaceStats {
|
||||
name: string;
|
||||
rxBytes: number;
|
||||
txBytes: number;
|
||||
rxPackets: number;
|
||||
txPackets: number;
|
||||
}
|
||||
|
||||
export interface NetworkStats {
|
||||
interfaces: InterfaceStats[];
|
||||
connectionCount: number;
|
||||
}
|
||||
|
||||
export interface ProcessInfo {
|
||||
pid: number;
|
||||
name: string;
|
||||
cpuPercent: number;
|
||||
memoryMb: number;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export interface ProcessStats {
|
||||
topByCpu: ProcessInfo[];
|
||||
topByMemory: ProcessInfo[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface TemperatureSensor {
|
||||
name: string;
|
||||
label: string;
|
||||
temperature: number;
|
||||
critical?: number;
|
||||
}
|
||||
|
||||
export interface TemperatureStats {
|
||||
sensors: TemperatureSensor[];
|
||||
}
|
||||
|
||||
export interface AMDGPUStats {
|
||||
available: boolean;
|
||||
name?: string;
|
||||
utilization: number;
|
||||
vramUsed: number;
|
||||
vramTotal: number;
|
||||
temperature: number;
|
||||
fanRpm: number;
|
||||
powerWatts: number;
|
||||
clockGpu: number;
|
||||
clockMemory: number;
|
||||
}
|
||||
|
||||
export interface AllMetrics {
|
||||
timestamp: string;
|
||||
system: SystemInfo;
|
||||
cpu: CPUStats;
|
||||
memory: MemoryStats;
|
||||
disk: DiskStats;
|
||||
network: NetworkStats;
|
||||
processes: ProcessStats;
|
||||
temperature: TemperatureStats;
|
||||
gpu: AMDGPUStats;
|
||||
}
|
||||
41
frontend/src/lib/utils/formatters.ts
Normal file
41
frontend/src/lib/utils/formatters.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export function formatBytes(bytes: number, decimals = 1): string {
|
||||
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(decimals)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
export function formatPercent(value: number, decimals = 1): string {
|
||||
return value.toFixed(decimals) + '%';
|
||||
}
|
||||
|
||||
export function formatUptime(seconds: number): string {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
const parts: string[] = [];
|
||||
if (days > 0) parts.push(`${days}d`);
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
if (minutes > 0) parts.push(`${minutes}m`);
|
||||
|
||||
return parts.join(' ') || '< 1m';
|
||||
}
|
||||
|
||||
export function formatFrequency(mhz: number): string {
|
||||
if (mhz >= 1000) {
|
||||
return (mhz / 1000).toFixed(2) + ' GHz';
|
||||
}
|
||||
return mhz + ' MHz';
|
||||
}
|
||||
|
||||
export function formatTemperature(celsius: number): string {
|
||||
return celsius.toFixed(1) + '°C';
|
||||
}
|
||||
|
||||
export function formatWatts(watts: number): string {
|
||||
return watts.toFixed(1) + ' W';
|
||||
}
|
||||
30
frontend/src/routes/+layout.svelte
Normal file
30
frontend/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import Header from '$lib/components/Header.svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { connectSSE, disconnectSSE } from '$lib/api/sse';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
onMount(() => {
|
||||
connectSSE();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
disconnectSSE();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen text-white">
|
||||
<Header />
|
||||
<main class="container mx-auto px-4 py-6 max-w-7xl">
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<!-- Subtle background effects -->
|
||||
<div class="fixed inset-0 -z-10 overflow-hidden pointer-events-none">
|
||||
<div class="absolute top-0 -left-40 w-80 h-80 bg-blue-500/10 rounded-full blur-3xl"></div>
|
||||
<div class="absolute top-1/3 -right-40 w-80 h-80 bg-purple-500/10 rounded-full blur-3xl"></div>
|
||||
<div class="absolute bottom-0 left-1/3 w-80 h-80 bg-cyan-500/5 rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
</div>
|
||||
32
frontend/src/routes/+page.svelte
Normal file
32
frontend/src/routes/+page.svelte
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import CpuCard from '$lib/components/cards/CpuCard.svelte';
|
||||
import MemoryCard from '$lib/components/cards/MemoryCard.svelte';
|
||||
import DiskCard from '$lib/components/cards/DiskCard.svelte';
|
||||
import NetworkCard from '$lib/components/cards/NetworkCard.svelte';
|
||||
import ProcessesCard from '$lib/components/cards/ProcessesCard.svelte';
|
||||
import TemperatureCard from '$lib/components/cards/TemperatureCard.svelte';
|
||||
import GpuCard from '$lib/components/cards/GpuCard.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>System Monitor</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-5">
|
||||
<!-- Top row: Primary metrics -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||
<CpuCard />
|
||||
<MemoryCard />
|
||||
<GpuCard />
|
||||
</div>
|
||||
|
||||
<!-- Middle row: System info -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
<DiskCard />
|
||||
<NetworkCard />
|
||||
<TemperatureCard />
|
||||
</div>
|
||||
|
||||
<!-- Bottom: Processes -->
|
||||
<ProcessesCard />
|
||||
</div>
|
||||
8
frontend/static/favicon.svg
Normal file
8
frontend/static/favicon.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="4" fill="#1e293b"/>
|
||||
<path d="M6 22 L10 14 L14 18 L18 10 L22 16 L26 12" fill="none" stroke="#3b82f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="10" cy="14" r="2" fill="#3b82f6"/>
|
||||
<circle cx="14" cy="18" r="2" fill="#3b82f6"/>
|
||||
<circle cx="18" cy="10" r="2" fill="#3b82f6"/>
|
||||
<circle cx="22" cy="16" r="2" fill="#3b82f6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 467 B |
18
frontend/svelte.config.js
Normal file
18
frontend/svelte.config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
pages: 'build',
|
||||
assets: 'build',
|
||||
fallback: 'index.html',
|
||||
precompress: false,
|
||||
strict: true
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
20
frontend/tailwind.config.js
Normal file
20
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,20 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
surface: {
|
||||
50: '#f8fafc',
|
||||
100: '#f1f5f9',
|
||||
200: '#e2e8f0',
|
||||
700: '#334155',
|
||||
800: '#1e293b',
|
||||
900: '#0f172a',
|
||||
950: '#020617'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
14
frontend/tsconfig.json
Normal file
14
frontend/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
6
frontend/vite.config.ts
Normal file
6
frontend/vite.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()]
|
||||
});
|
||||
Reference in New Issue
Block a user