From 38a598baaa16750882d4d91b4370a673ffbef263 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sun, 28 Dec 2025 04:26:11 +0100 Subject: [PATCH] Initial commit: System monitor web application MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 24 +++ backend/Dockerfile | 33 ++++ backend/cmd/server/main.go | 25 +++ backend/go.mod | 37 ++++ backend/go.sum | 99 ++++++++++ backend/internal/api/routes.go | 138 ++++++++++++++ backend/internal/collectors/amdgpu.go | 169 +++++++++++++++++ backend/internal/collectors/cpu.go | 153 ++++++++++++++++ backend/internal/collectors/disk.go | 167 +++++++++++++++++ backend/internal/collectors/memory.go | 58 ++++++ backend/internal/collectors/network.go | 120 ++++++++++++ backend/internal/collectors/processes.go | 171 ++++++++++++++++++ backend/internal/collectors/system.go | 53 ++++++ backend/internal/collectors/temperature.go | 107 +++++++++++ backend/internal/config/config.go | 36 ++++ backend/internal/models/cpu.go | 19 ++ backend/internal/models/disk.go | 22 +++ backend/internal/models/gpu.go | 14 ++ backend/internal/models/memory.go | 12 ++ backend/internal/models/network.go | 14 ++ backend/internal/models/processes.go | 15 ++ backend/internal/models/system.go | 23 +++ backend/internal/models/temperature.go | 12 ++ backend/internal/sse/broker.go | 163 +++++++++++++++++ docker-compose.yaml | 36 ++++ frontend/Dockerfile | 24 +++ frontend/nginx.conf | 41 +++++ frontend/package.json | 24 +++ frontend/postcss.config.js | 6 + frontend/src/app.css | 161 +++++++++++++++++ frontend/src/app.d.ts | 13 ++ frontend/src/app.html | 13 ++ frontend/src/lib/api/sse.ts | 54 ++++++ frontend/src/lib/components/Header.svelte | 67 +++++++ .../src/lib/components/cards/CpuCard.svelte | 77 ++++++++ .../src/lib/components/cards/DiskCard.svelte | 78 ++++++++ .../src/lib/components/cards/GpuCard.svelte | 88 +++++++++ .../lib/components/cards/MemoryCard.svelte | 69 +++++++ .../lib/components/cards/NetworkCard.svelte | 49 +++++ .../lib/components/cards/ProcessesCard.svelte | 83 +++++++++ .../components/cards/TemperatureCard.svelte | 120 ++++++++++++ .../src/lib/components/common/Card.svelte | 21 +++ .../lib/components/common/ProgressBar.svelte | 40 ++++ .../lib/components/common/SparkLine.svelte | 70 +++++++ frontend/src/lib/stores/metrics.ts | 52 ++++++ frontend/src/lib/stores/settings.ts | 42 +++++ frontend/src/lib/types/metrics.ts | 120 ++++++++++++ frontend/src/lib/utils/formatters.ts | 41 +++++ frontend/src/routes/+layout.svelte | 30 +++ frontend/src/routes/+page.svelte | 32 ++++ frontend/static/favicon.svg | 8 + frontend/svelte.config.js | 18 ++ frontend/tailwind.config.js | 20 ++ frontend/tsconfig.json | 14 ++ frontend/vite.config.ts | 6 + 55 files changed, 3201 insertions(+) create mode 100644 .gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/cmd/server/main.go create mode 100644 backend/go.mod create mode 100644 backend/go.sum create mode 100644 backend/internal/api/routes.go create mode 100644 backend/internal/collectors/amdgpu.go create mode 100644 backend/internal/collectors/cpu.go create mode 100644 backend/internal/collectors/disk.go create mode 100644 backend/internal/collectors/memory.go create mode 100644 backend/internal/collectors/network.go create mode 100644 backend/internal/collectors/processes.go create mode 100644 backend/internal/collectors/system.go create mode 100644 backend/internal/collectors/temperature.go create mode 100644 backend/internal/config/config.go create mode 100644 backend/internal/models/cpu.go create mode 100644 backend/internal/models/disk.go create mode 100644 backend/internal/models/gpu.go create mode 100644 backend/internal/models/memory.go create mode 100644 backend/internal/models/network.go create mode 100644 backend/internal/models/processes.go create mode 100644 backend/internal/models/system.go create mode 100644 backend/internal/models/temperature.go create mode 100644 backend/internal/sse/broker.go create mode 100644 docker-compose.yaml create mode 100644 frontend/Dockerfile create mode 100644 frontend/nginx.conf create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/app.css create mode 100644 frontend/src/app.d.ts create mode 100644 frontend/src/app.html create mode 100644 frontend/src/lib/api/sse.ts create mode 100644 frontend/src/lib/components/Header.svelte create mode 100644 frontend/src/lib/components/cards/CpuCard.svelte create mode 100644 frontend/src/lib/components/cards/DiskCard.svelte create mode 100644 frontend/src/lib/components/cards/GpuCard.svelte create mode 100644 frontend/src/lib/components/cards/MemoryCard.svelte create mode 100644 frontend/src/lib/components/cards/NetworkCard.svelte create mode 100644 frontend/src/lib/components/cards/ProcessesCard.svelte create mode 100644 frontend/src/lib/components/cards/TemperatureCard.svelte create mode 100644 frontend/src/lib/components/common/Card.svelte create mode 100644 frontend/src/lib/components/common/ProgressBar.svelte create mode 100644 frontend/src/lib/components/common/SparkLine.svelte create mode 100644 frontend/src/lib/stores/metrics.ts create mode 100644 frontend/src/lib/stores/settings.ts create mode 100644 frontend/src/lib/types/metrics.ts create mode 100644 frontend/src/lib/utils/formatters.ts create mode 100644 frontend/src/routes/+layout.svelte create mode 100644 frontend/src/routes/+page.svelte create mode 100644 frontend/static/favicon.svg create mode 100644 frontend/svelte.config.js create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5467b7d --- /dev/null +++ b/.gitignore @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..ff3cbc7 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go new file mode 100644 index 0000000..d76b3cb --- /dev/null +++ b/backend/cmd/server/main.go @@ -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) + } +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..bececfe --- /dev/null +++ b/backend/go.mod @@ -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 +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..71766ff --- /dev/null +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/api/routes.go b/backend/internal/api/routes.go new file mode 100644 index 0000000..626ab98 --- /dev/null +++ b/backend/internal/api/routes.go @@ -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) +} diff --git a/backend/internal/collectors/amdgpu.go b/backend/internal/collectors/amdgpu.go new file mode 100644 index 0000000..cfd389c --- /dev/null +++ b/backend/internal/collectors/amdgpu.go @@ -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) +} diff --git a/backend/internal/collectors/cpu.go b/backend/internal/collectors/cpu.go new file mode 100644 index 0000000..6ef5257 --- /dev/null +++ b/backend/internal/collectors/cpu.go @@ -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 +} diff --git a/backend/internal/collectors/disk.go b/backend/internal/collectors/disk.go new file mode 100644 index 0000000..bafbde2 --- /dev/null +++ b/backend/internal/collectors/disk.go @@ -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 +} diff --git a/backend/internal/collectors/memory.go b/backend/internal/collectors/memory.go new file mode 100644 index 0000000..dbb5d11 --- /dev/null +++ b/backend/internal/collectors/memory.go @@ -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 +} diff --git a/backend/internal/collectors/network.go b/backend/internal/collectors/network.go new file mode 100644 index 0000000..43d7d53 --- /dev/null +++ b/backend/internal/collectors/network.go @@ -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 +} diff --git a/backend/internal/collectors/processes.go b/backend/internal/collectors/processes.go new file mode 100644 index 0000000..c2ce6cc --- /dev/null +++ b/backend/internal/collectors/processes.go @@ -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 +} diff --git a/backend/internal/collectors/system.go b/backend/internal/collectors/system.go new file mode 100644 index 0000000..135deeb --- /dev/null +++ b/backend/internal/collectors/system.go @@ -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 +} diff --git a/backend/internal/collectors/temperature.go b/backend/internal/collectors/temperature.go new file mode 100644 index 0000000..90ffc81 --- /dev/null +++ b/backend/internal/collectors/temperature.go @@ -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 +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..2c4cfaf --- /dev/null +++ b/backend/internal/config/config.go @@ -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 +} diff --git a/backend/internal/models/cpu.go b/backend/internal/models/cpu.go new file mode 100644 index 0000000..d41a8db --- /dev/null +++ b/backend/internal/models/cpu.go @@ -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"` +} diff --git a/backend/internal/models/disk.go b/backend/internal/models/disk.go new file mode 100644 index 0000000..28df56d --- /dev/null +++ b/backend/internal/models/disk.go @@ -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"` +} diff --git a/backend/internal/models/gpu.go b/backend/internal/models/gpu.go new file mode 100644 index 0000000..41e0785 --- /dev/null +++ b/backend/internal/models/gpu.go @@ -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"` +} diff --git a/backend/internal/models/memory.go b/backend/internal/models/memory.go new file mode 100644 index 0000000..4974155 --- /dev/null +++ b/backend/internal/models/memory.go @@ -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"` +} diff --git a/backend/internal/models/network.go b/backend/internal/models/network.go new file mode 100644 index 0000000..5fa4255 --- /dev/null +++ b/backend/internal/models/network.go @@ -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"` +} diff --git a/backend/internal/models/processes.go b/backend/internal/models/processes.go new file mode 100644 index 0000000..8edce37 --- /dev/null +++ b/backend/internal/models/processes.go @@ -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"` +} diff --git a/backend/internal/models/system.go b/backend/internal/models/system.go new file mode 100644 index 0000000..adf5405 --- /dev/null +++ b/backend/internal/models/system.go @@ -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"` +} diff --git a/backend/internal/models/temperature.go b/backend/internal/models/temperature.go new file mode 100644 index 0000000..85bc897 --- /dev/null +++ b/backend/internal/models/temperature.go @@ -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"` +} diff --git a/backend/internal/sse/broker.go b/backend/internal/sse/broker.go new file mode 100644 index 0000000..f3efa93 --- /dev/null +++ b/backend/internal/sse/broker.go @@ -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 +} diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..1967a6a --- /dev/null +++ b/docker-compose.yaml @@ -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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..ba464fa --- /dev/null +++ b/frontend/Dockerfile @@ -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;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..e8fa0a7 --- /dev/null +++ b/frontend/nginx.conf @@ -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"; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..ff7c4ac --- /dev/null +++ b/frontend/package.json @@ -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" +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..0f77216 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +}; diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 0000000..38d3c61 --- /dev/null +++ b/frontend/src/app.css @@ -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; + } +} diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/frontend/src/app.d.ts @@ -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 {}; diff --git a/frontend/src/app.html b/frontend/src/app.html new file mode 100644 index 0000000..d87569b --- /dev/null +++ b/frontend/src/app.html @@ -0,0 +1,13 @@ + + + + + + + System Monitor + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/frontend/src/lib/api/sse.ts b/frontend/src/lib/api/sse.ts new file mode 100644 index 0000000..cd3a37d --- /dev/null +++ b/frontend/src/lib/api/sse.ts @@ -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 | 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); +} diff --git a/frontend/src/lib/components/Header.svelte b/frontend/src/lib/components/Header.svelte new file mode 100644 index 0000000..ab4e13f --- /dev/null +++ b/frontend/src/lib/components/Header.svelte @@ -0,0 +1,67 @@ + + +
+
+
+
+
+ +
+
+ + + +
+
+

+ System Monitor +

+ {#if $systemInfo} +

{$systemInfo.hostname}

+ {/if} +
+
+ + + {#if $systemInfo} + + {/if} +
+ +
+ +
+ + +
+ + +
+
+ + {$connected ? 'Live' : 'Offline'} + +
+
+
+
+
diff --git a/frontend/src/lib/components/cards/CpuCard.svelte b/frontend/src/lib/components/cards/CpuCard.svelte new file mode 100644 index 0000000..3c51afa --- /dev/null +++ b/frontend/src/lib/components/cards/CpuCard.svelte @@ -0,0 +1,77 @@ + + + + {#if $cpuStats} +
+ +
+
+
+ + {$cpuStats.totalUsage.toFixed(1)}% + + usage +
+ +
+
+ +
+
+ + +
+
+
{$cpuStats.loadAverage.load1.toFixed(2)}
+
1 min
+
+
+
{$cpuStats.loadAverage.load5.toFixed(2)}
+
5 min
+
+
+
{$cpuStats.loadAverage.load15.toFixed(2)}
+
15 min
+
+
+ + +
+ {#each $cpuStats.cores as core} +
+
+
+
+ C{core.id} + {core.usage.toFixed(0)}% +
+
+
+ {formatFrequency(core.frequency)} +
+
+ {/each} +
+
+ {:else} +
+
Loading...
+
+ {/if} +
diff --git a/frontend/src/lib/components/cards/DiskCard.svelte b/frontend/src/lib/components/cards/DiskCard.svelte new file mode 100644 index 0000000..71515d4 --- /dev/null +++ b/frontend/src/lib/components/cards/DiskCard.svelte @@ -0,0 +1,78 @@ + + + + {#if $diskStats} +
+ + {#each uniqueMounts() as mount} +
+
+
+
+ + {mount.device.split('/').pop()} + +
+ + {mount.filesystem} + +
+ +
+ {formatBytes(mount.used)} used + {formatBytes(mount.available)} free +
+
+ {/each} + + {#if uniqueMounts().length === 0 && $diskStats.io.length > 0} + +
+ Mount info unavailable in container +
+ {/if} + + {#if $diskStats.io && $diskStats.io.length > 0} +
+
I/O Activity
+
+ {#each $diskStats.io as io} +
+ {io.device} +
+ + {formatBytes(io.readBytes)} + + + {formatBytes(io.writeBytes)} + +
+
+ {/each} +
+
+ {/if} +
+ {:else} +
+
Loading...
+
+ {/if} +
diff --git a/frontend/src/lib/components/cards/GpuCard.svelte b/frontend/src/lib/components/cards/GpuCard.svelte new file mode 100644 index 0000000..71c6310 --- /dev/null +++ b/frontend/src/lib/components/cards/GpuCard.svelte @@ -0,0 +1,88 @@ + + + + {#if $gpuStats} + {#if $gpuStats.available} + {@const vramPercent = $gpuStats.vramTotal > 0 ? ($gpuStats.vramUsed / $gpuStats.vramTotal) * 100 : 0} + +
+ +
+
+
+ + {$gpuStats.utilization}% + + GPU +
+ +
+
+ +
+
+ + +
+
+ VRAM + + {formatBytes($gpuStats.vramUsed)} / {formatBytes($gpuStats.vramTotal)} + +
+ +
+ + +
+
+
Temperature
+
+ {formatTemperature($gpuStats.temperature)} +
+
+
+
Power
+
{formatWatts($gpuStats.powerWatts)}
+
+
+
GPU Clock
+
{formatFrequency($gpuStats.clockGpu)}
+
+
+
Mem Clock
+
{formatFrequency($gpuStats.clockMemory)}
+
+
+ + {#if $gpuStats.fanRpm > 0} +
+ 🌀 + Fan: {$gpuStats.fanRpm.toLocaleString()} RPM +
+ {/if} +
+ {:else} +
+ 🎮 + No AMD GPU detected +
+ {/if} + {:else} +
+
Loading...
+
+ {/if} +
diff --git a/frontend/src/lib/components/cards/MemoryCard.svelte b/frontend/src/lib/components/cards/MemoryCard.svelte new file mode 100644 index 0000000..7c459a7 --- /dev/null +++ b/frontend/src/lib/components/cards/MemoryCard.svelte @@ -0,0 +1,69 @@ + + + + {#if $memoryStats} + {@const usedPercent = ($memoryStats.used / $memoryStats.total) * 100} + {@const swapPercent = $memoryStats.swapTotal > 0 ? ($memoryStats.swapUsed / $memoryStats.swapTotal) * 100 : 0} + +
+ +
+
+
+ + {usedPercent.toFixed(1)}% + + used +
+ +
+
+ +
+
+ + +
+
+
Used
+
{formatBytes($memoryStats.used)}
+
+
+
Available
+
{formatBytes($memoryStats.available)}
+
+
+
Cached
+
{formatBytes($memoryStats.cached)}
+
+
+
Total
+
{formatBytes($memoryStats.total)}
+
+
+ + + {#if $memoryStats.swapTotal > 0} +
+
+ Swap + + {formatBytes($memoryStats.swapUsed)} / {formatBytes($memoryStats.swapTotal)} + +
+ +
+ {/if} +
+ {:else} +
+
Loading...
+
+ {/if} +
diff --git a/frontend/src/lib/components/cards/NetworkCard.svelte b/frontend/src/lib/components/cards/NetworkCard.svelte new file mode 100644 index 0000000..5f23147 --- /dev/null +++ b/frontend/src/lib/components/cards/NetworkCard.svelte @@ -0,0 +1,49 @@ + + + + {#if $networkStats} +
+ {#each $networkStats.interfaces as iface} +
+
+ {iface.name} + Active +
+
+
+
+ + Download +
+
{formatBytes(iface.rxBytes)}
+
{iface.rxPackets.toLocaleString()} packets
+
+
+
+ + Upload +
+
{formatBytes(iface.txBytes)}
+
{iface.txPackets.toLocaleString()} packets
+
+
+
+ {/each} + +
+ TCP Connections + + {$networkStats.connectionCount} + +
+
+ {:else} +
+
Loading...
+
+ {/if} +
diff --git a/frontend/src/lib/components/cards/ProcessesCard.svelte b/frontend/src/lib/components/cards/ProcessesCard.svelte new file mode 100644 index 0000000..6302b24 --- /dev/null +++ b/frontend/src/lib/components/cards/ProcessesCard.svelte @@ -0,0 +1,83 @@ + + + + {#if $processStats && ($processStats.topByCpu?.length > 0 || $processStats.topByMemory?.length > 0)} +
+ +
+
+ + +
+
+ {$processStats.total} total +
+
+ + +
+ {#each processes.slice(0, 10) as proc, i (proc.pid)} +
+
+ {i + 1} +
+
+
+ {proc.name} +
+
PID {proc.pid}
+
+
+
+ {proc.cpuPercent.toFixed(1)}% +
+
+ {proc.memoryMb.toFixed(0)} MB +
+
+
+ {/each} +
+
+ {:else} +
+
Loading processes...
+
+ {/if} +
diff --git a/frontend/src/lib/components/cards/TemperatureCard.svelte b/frontend/src/lib/components/cards/TemperatureCard.svelte new file mode 100644 index 0000000..6fee3a5 --- /dev/null +++ b/frontend/src/lib/components/cards/TemperatureCard.svelte @@ -0,0 +1,120 @@ + + + + {#if $temperatureStats && $temperatureStats.sensors.length > 0} +
+ +
+ {#each keySensors() as sensor} +
+
+ {getDisplayName(sensor.name)} +
+
+ {formatTemperature(sensor.temperature)} +
+ {#if sensor.label} +
{sensor.label}
+ {/if} +
+ {/each} +
+ + + {#if otherSensors().length > 0} +
+
Other Sensors
+
+ {#each otherSensors() as sensor} +
+ + {getDisplayName(sensor.name)} + {#if sensor.count > 1} + ×{sensor.count} + {/if} + + + {formatTemperature(sensor.temperature)} + +
+ {/each} +
+
+ {/if} +
+ {:else} +
+ 🌡️ + No sensors detected +
+ {/if} +
diff --git a/frontend/src/lib/components/common/Card.svelte b/frontend/src/lib/components/common/Card.svelte new file mode 100644 index 0000000..956aa4c --- /dev/null +++ b/frontend/src/lib/components/common/Card.svelte @@ -0,0 +1,21 @@ + + +
+

+ {#if icon} + {icon} + {/if} + {title} +

+ {@render children()} +
diff --git a/frontend/src/lib/components/common/ProgressBar.svelte b/frontend/src/lib/components/common/ProgressBar.svelte new file mode 100644 index 0000000..bb7678c --- /dev/null +++ b/frontend/src/lib/components/common/ProgressBar.svelte @@ -0,0 +1,40 @@ + + +
+ {#if showLabel} +
+ {label} + {percent.toFixed(1)}% +
+ {/if} +
+
+
+
diff --git a/frontend/src/lib/components/common/SparkLine.svelte b/frontend/src/lib/components/common/SparkLine.svelte new file mode 100644 index 0000000..316c286 --- /dev/null +++ b/frontend/src/lib/components/common/SparkLine.svelte @@ -0,0 +1,70 @@ + + + + + + + + + + + {#if fill} + + {/if} + + + diff --git a/frontend/src/lib/stores/metrics.ts b/frontend/src/lib/stores/metrics.ts new file mode 100644 index 0000000..bf9fc69 --- /dev/null +++ b/frontend/src/lib/stores/metrics.ts @@ -0,0 +1,52 @@ +import { writable, derived } from 'svelte/store'; +import type { AllMetrics } from '$lib/types/metrics'; + +// Main metrics store +export const metrics = writable(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([]); + + 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); + } + } +}); diff --git a/frontend/src/lib/stores/settings.ts b/frontend/src/lib/stores/settings.ts new file mode 100644 index 0000000..8ab2fbe --- /dev/null +++ b/frontend/src/lib/stores/settings.ts @@ -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(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))); +} diff --git a/frontend/src/lib/types/metrics.ts b/frontend/src/lib/types/metrics.ts new file mode 100644 index 0000000..5d88477 --- /dev/null +++ b/frontend/src/lib/types/metrics.ts @@ -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; +} diff --git a/frontend/src/lib/utils/formatters.ts b/frontend/src/lib/utils/formatters.ts new file mode 100644 index 0000000..18f38b8 --- /dev/null +++ b/frontend/src/lib/utils/formatters.ts @@ -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'; +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte new file mode 100644 index 0000000..6cfa270 --- /dev/null +++ b/frontend/src/routes/+layout.svelte @@ -0,0 +1,30 @@ + + +
+
+
+ {@render children()} +
+ + +
+
+
+
+
+
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte new file mode 100644 index 0000000..6a25ec9 --- /dev/null +++ b/frontend/src/routes/+page.svelte @@ -0,0 +1,32 @@ + + + + System Monitor + + +
+ +
+ + + +
+ + +
+ + + +
+ + + +
diff --git a/frontend/static/favicon.svg b/frontend/static/favicon.svg new file mode 100644 index 0000000..a5daf98 --- /dev/null +++ b/frontend/static/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js new file mode 100644 index 0000000..700fe58 --- /dev/null +++ b/frontend/svelte.config.js @@ -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; diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..9493bee --- /dev/null +++ b/frontend/tailwind.config.js @@ -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: [] +}; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..a8f10c8 --- /dev/null +++ b/frontend/tsconfig.json @@ -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" + } +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..bbf8c7d --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,6 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()] +});