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:
2025-12-28 04:26:11 +01:00
commit 38a598baaa
55 changed files with 3201 additions and 0 deletions

24
.gitignore vendored Normal file
View 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
View 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"]

View 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
View 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
View 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=

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

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

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

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

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

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

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

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

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

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

View 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"`
}

View 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"`
}

View 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"`
}

View 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"`
}

View 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"`
}

View 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"`
}

View 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"`
}

View 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"`
}

View 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
View 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
View 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
View 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
View 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"
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

161
frontend/src/app.css Normal file
View 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
View 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
View 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>

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

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

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

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

View 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;
}

View 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';
}

View 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>

View 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>

View 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
View 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;

View 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
View 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
View File

@@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});