├── .github └── workflows │ └── release.yml ├── .gitignore ├── CLAUDE.md ├── LICENSE ├── Makefile ├── README.md ├── api ├── gops │ ├── all.go │ ├── cpu.go │ ├── disk.go │ ├── diskrate.go │ ├── gops.go │ ├── hardware.go │ ├── memory.go │ ├── meta.go │ ├── netrate.go │ ├── network.go │ ├── processes.go │ └── system.go ├── middleware │ ├── logger.go │ ├── logger_test.go │ ├── middleware.go │ ├── recoverer.go │ ├── request_id.go │ ├── request_id_test.go │ ├── terminal.go │ ├── wrap_writer.go │ └── wrap_writer_test.go └── server │ └── server.go ├── cmd └── cli │ ├── commands.go │ ├── help.go │ ├── main.go │ ├── server.go │ ├── tui │ ├── charts.go │ ├── components.go │ ├── data.go │ ├── memdisk.go │ ├── model.go │ ├── panels.go │ ├── processes.go │ ├── styles.go │ └── tui.go │ └── tui_runner.go ├── config ├── colors.go └── config.go ├── errdefs └── errors.go ├── flake.lock ├── flake.nix ├── go.mod ├── go.sum ├── gops ├── cpu.go ├── disk.go ├── diskrate.go ├── gops.go ├── hardware.go ├── memory.go ├── meta.go ├── net.go ├── netrate.go ├── processes.go └── system.go ├── internal └── log │ └── log.go ├── models ├── colors.go ├── cpu.go ├── disk.go ├── hardware.go ├── memory.go ├── meta.go ├── network.go ├── process.go └── temperature.go └── utils ├── net_headers.go └── net_headers_test.go /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | on: 3 | push: 4 | tags: 5 | - 'v*' 6 | 7 | concurrency: 8 | group: release-${{ github.ref_name }} 9 | cancel-in-progress: true 10 | 11 | jobs: 12 | build: 13 | name: 🔨 Build Binaries 14 | strategy: 15 | matrix: 16 | include: 17 | - goos: linux 18 | goarch: amd64 19 | runs-on: ubuntu-24.04 20 | - goos: linux 21 | goarch: arm64 22 | runs-on: ubuntu-24.04 23 | runs-on: ${{ matrix.runs-on }} 24 | steps: 25 | - uses: actions/checkout@v4 26 | 27 | - name: Setup Go with cache 28 | uses: actions/setup-go@v5 29 | with: 30 | go-version-file: ./go.mod 31 | 32 | - name: Build CLI Binary 33 | env: 34 | GOOS: ${{ matrix.goos }} 35 | GOARCH: ${{ matrix.goarch }} 36 | CGO_ENABLED: 0 37 | run: | 38 | VERSION=${{ github.ref_name }} 39 | BINARY_NAME="dgop-${{ matrix.goos }}-${{ matrix.goarch }}" 40 | cd cmd/cli && go build -ldflags "-s -w -X main.Version=${VERSION}" -o "../../${BINARY_NAME}" 41 | cd ../.. 42 | gzip "${BINARY_NAME}" 43 | 44 | - name: Generate checksums 45 | run: | 46 | BINARY_NAME="dgop-${{ matrix.goos }}-${{ matrix.goarch }}.gz" 47 | sha256sum "${BINARY_NAME}" > "${BINARY_NAME}.sha256" 48 | 49 | - name: Upload artifacts 50 | uses: actions/upload-artifact@v4 51 | with: 52 | name: dgop-${{ matrix.goos }}-${{ matrix.goarch }} 53 | path: | 54 | dgop-${{ matrix.goos }}-${{ matrix.goarch }}.gz 55 | dgop-${{ matrix.goos }}-${{ matrix.goarch }}.gz.sha256 56 | 57 | create_release: 58 | name: 📦 Create GitHub Release 59 | runs-on: ubuntu-24.04 60 | needs: [build] 61 | steps: 62 | - uses: actions/checkout@v4 63 | with: 64 | fetch-depth: 0 # Fetch full history for changelog generation 65 | 66 | - name: Download all artifacts 67 | uses: actions/download-artifact@v4 68 | with: 69 | path: ./artifacts 70 | 71 | # Generate changelog 72 | - name: Generate Changelog 73 | id: changelog 74 | run: | 75 | # Get the previous tag 76 | PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") 77 | 78 | if [ -z "$PREVIOUS_TAG" ]; then 79 | echo "No previous tag found, using all commits" 80 | CHANGELOG=$(git log --oneline --pretty=format:"- %s (%h)" | head -50) 81 | else 82 | echo "Generating changelog from $PREVIOUS_TAG to HEAD" 83 | CHANGELOG=$(git log --oneline --pretty=format:"- %s (%h)" $PREVIOUS_TAG..HEAD) 84 | fi 85 | 86 | # Create the changelog with proper formatting 87 | cat > CHANGELOG.md << EOF 88 | ## What's Changed 89 | 90 | $CHANGELOG 91 | 92 | **Full Changelog**: https://github.com/${{ github.repository }}/compare/${PREVIOUS_TAG}...${{ github.ref_name }} 93 | EOF 94 | 95 | # Set output for use in release step 96 | echo "changelog<> $GITHUB_OUTPUT 97 | cat CHANGELOG.md >> $GITHUB_OUTPUT 98 | echo "EOF" >> $GITHUB_OUTPUT 99 | 100 | # Create GitHub Release 101 | - name: Create GitHub Release 102 | id: create_release 103 | uses: comnoco/create-release-action@v2.0.5 104 | env: 105 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 106 | with: 107 | tag_name: ${{ github.ref_name }} 108 | release_name: Release ${{ github.ref_name }} 109 | body: ${{ steps.changelog.outputs.changelog }} 110 | draft: false 111 | prerelease: ${{ contains(github.ref_name, '-') }} 112 | 113 | # Upload release assets 114 | - name: Upload release assets 115 | run: | 116 | VERSION=${{ github.ref_name }} 117 | 118 | # Find and upload all binaries and checksums 119 | find ./artifacts -name "dgop-*" -type f | while read file; do 120 | echo "Uploading: $file" 121 | filename=$(basename "$file") 122 | gh release upload "$VERSION" "$file" --clobber 123 | done 124 | env: 125 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 126 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | bin/ 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Code coverage profiles and other test artifacts 15 | *.out 16 | coverage.* 17 | *.coverprofile 18 | profile.cov 19 | 20 | # Dependency directories (remove the comment below to include it) 21 | # vendor/ 22 | 23 | # Go workspace file 24 | go.work 25 | go.work.sum 26 | 27 | # env file 28 | .env 29 | 30 | # Editor/IDE 31 | # .idea/ 32 | # .vscode/ 33 | vendor 34 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | DankGop is a system monitoring tool with CLI and REST API, designed to provide comprehensive system metrics. It's written in Go and compiles to a single binary that provides both CLI commands and a REST API server. 8 | 9 | ## Development Commands 10 | 11 | ### Building 12 | ```bash 13 | # Standard build 14 | make 15 | 16 | # Development build (with debug info) 17 | make dev 18 | 19 | # Build and install system-wide 20 | make && sudo make install 21 | ``` 22 | 23 | ### Testing 24 | ```bash 25 | # Run all tests 26 | make test 27 | 28 | # Run tests for specific package 29 | go test -v ./api/middleware/... 30 | ``` 31 | 32 | ### Code Quality 33 | ```bash 34 | # Format code 35 | make fmt 36 | 37 | # Run go vet 38 | make vet 39 | 40 | # Update dependencies 41 | make deps 42 | ``` 43 | 44 | ### Running the Application 45 | ```bash 46 | # Run locally without building 47 | go run ./cmd/cli [command] 48 | 49 | # After building 50 | ./bin/dgop [command] 51 | 52 | # Start API server 53 | ./bin/dgop server 54 | ``` 55 | 56 | ## Architecture 57 | 58 | ### Core Components 59 | 60 | 1. **CLI Entry Point** (`cmd/cli/`) 61 | - `main.go`: Sets up Cobra commands and initializes GopsUtil 62 | - `commands.go`: Implements individual CLI commands (cpu, memory, network, etc.) 63 | - `server.go`: Starts the REST API server 64 | 65 | 2. **Data Collection** (`gops/`) 66 | - `gops.go`: Main utility class that orchestrates all metric collection 67 | - Individual collectors: `cpu.go`, `memory.go`, `disk.go`, `net.go`, `processes.go`, `system.go`, `hardware.go` 68 | - Each collector uses `github.com/shirou/gopsutil/v4` for system information 69 | 70 | 3. **API Layer** (`api/`) 71 | - `api/gops/`: HTTP handlers that wrap the gops collectors 72 | - `api/server/`: Server implementation 73 | - `api/middleware/`: Request logging, recovery, and request ID middleware 74 | - Uses Huma v2 framework for OpenAPI-compliant REST API 75 | 76 | 4. **Data Models** (`models/`) 77 | - Defines structures for all system metrics (CPU, Memory, Disk, Network, Process, etc.) 78 | - Shared between CLI output and API responses 79 | 80 | ### Key Design Patterns 81 | 82 | - **Single Responsibility**: Each file in `gops/` handles one type of metric 83 | - **Dependency Injection**: GopsUtil is passed to commands and API handlers 84 | - **Process Sampling**: CPU and process metrics support sampling for performance optimization 85 | - **Modular Commands**: Meta command allows combining multiple metrics dynamically 86 | 87 | ### API Server Details 88 | 89 | - Default port: 63484 90 | - OpenAPI docs available at `/docs` 91 | - All endpoints under `/gops/` prefix 92 | - Supports JSON output and query parameters for filtering/sorting 93 | 94 | ### Important Implementation Notes 95 | 96 | 1. **Process CPU Calculation**: Can be disabled with `--no-cpu` flag for faster results 97 | 2. **GPU Temperature**: Requires `nvidia-smi` for NVIDIA GPUs, uses PCI IDs for identification 98 | 3. **CGO Disabled**: Built with `CGO_ENABLED=0` for maximum portability 99 | 4. **Error Handling**: Non-critical errors are logged but don't stop execution (graceful degradation) 100 | 5. **Sampling Support**: CPU and process commands support `--sample` flag for testing with mock data -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Avenge Media LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for dgop 2 | 3 | # Project settings 4 | BINARY_NAME=dgop 5 | SOURCE_DIR=cmd/cli 6 | BUILD_DIR=bin 7 | INSTALL_DIR=/usr/local/bin 8 | 9 | # Go settings 10 | GO=go 11 | GOFLAGS=-ldflags="-s -w" 12 | 13 | # Version and build info 14 | VERSION=$(shell git describe --tags --always 2>/dev/null || echo "dev") 15 | BUILD_TIME=$(shell date -u '+%Y-%m-%d_%H:%M:%S') 16 | COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") 17 | 18 | # Build flags with version info 19 | BUILD_LDFLAGS=-ldflags="-s -w -X main.Version=$(VERSION) -X main.buildTime=$(BUILD_TIME) -X main.commit=$(COMMIT)" 20 | 21 | .PHONY: all build clean install uninstall test fmt vet deps help 22 | 23 | # Default target 24 | all: build 25 | 26 | # Build the binary 27 | build: 28 | @echo "Building $(BINARY_NAME)..." 29 | @mkdir -p $(BUILD_DIR) 30 | CGO_ENABLED=0 $(GO) build $(BUILD_LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) $(SOURCE_DIR)/*.go 31 | @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)" 32 | 33 | # Install the binary to system path 34 | install: build 35 | @echo "Installing $(BINARY_NAME) to $(INSTALL_DIR)..." 36 | @cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME) 37 | @chmod +x $(INSTALL_DIR)/$(BINARY_NAME) 38 | @echo "Installation complete" 39 | 40 | # Uninstall the binary from system path 41 | uninstall: 42 | @echo "Uninstalling $(BINARY_NAME) from $(INSTALL_DIR)..." 43 | @rm -f $(INSTALL_DIR)/$(BINARY_NAME) 44 | @echo "Uninstall complete" 45 | 46 | # Clean build artifacts 47 | clean: 48 | @echo "Cleaning build artifacts..." 49 | @rm -rf $(BUILD_DIR) 50 | @echo "Clean complete" 51 | 52 | # Run tests 53 | test: 54 | @echo "Running tests..." 55 | $(GO) test -v ./... 56 | 57 | # Format Go code 58 | fmt: 59 | @echo "Formatting Go code..." 60 | $(GO) fmt ./... 61 | 62 | # Run Go vet 63 | vet: 64 | @echo "Running go vet..." 65 | $(GO) vet ./... 66 | 67 | # Update dependencies 68 | deps: 69 | @echo "Updating dependencies..." 70 | $(GO) mod tidy 71 | $(GO) mod download 72 | 73 | # Development build (with debug info) 74 | dev: 75 | @echo "Building $(BINARY_NAME) for development..." 76 | @mkdir -p $(BUILD_DIR) 77 | $(GO) build -o $(BUILD_DIR)/$(BINARY_NAME) $(SOURCE_DIR)/*.go 78 | @echo "Development build complete: $(BUILD_DIR)/$(BINARY_NAME)" 79 | 80 | # Check Go version compatibility 81 | check-go: 82 | @echo "Checking Go version..." 83 | @go version | grep -E "go1\.(2[2-9]|[3-9][0-9])" > /dev/null || (echo "ERROR: Go 1.22 or higher required" && exit 1) 84 | @echo "Go version OK" 85 | 86 | # Build with version info 87 | version: check-go 88 | @echo "Version: $(VERSION)" 89 | @echo "Build Time: $(BUILD_TIME)" 90 | @echo "Commit: $(COMMIT)" 91 | 92 | # Help target 93 | help: 94 | @echo "Available targets:" 95 | @echo " all - Build the binary (default)" 96 | @echo " build - Build the binary" 97 | @echo " install - Install binary to $(INSTALL_DIR)" 98 | @echo " uninstall - Remove binary from $(INSTALL_DIR)" 99 | @echo " clean - Clean build artifacts" 100 | @echo " test - Run tests" 101 | @echo " fmt - Format Go code" 102 | @echo " vet - Run go vet" 103 | @echo " deps - Update dependencies" 104 | @echo " dev - Build with debug info" 105 | @echo " check-go - Check Go version compatibility" 106 | @echo " version - Show version information" 107 | @echo " help - Show this help message" 108 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dgop 2 | 3 |
4 | 5 | ![GitHub last commit](https://img.shields.io/github/last-commit/AvengeMedia/dgop?style=for-the-badge&labelColor=101418&color=9ccbfb) 6 | ![GitHub License](https://img.shields.io/github/license/AvengeMedia/dgop?style=for-the-badge&labelColor=101418&color=b9c8da) 7 | ![GitHub Release](https://img.shields.io/github/v/release/AvengeMedia/dgop?style=for-the-badge&labelColor=101418&color=a6da95) 8 | [![AUR](https://img.shields.io/aur/version/dgop?style=for-the-badge&labelColor=101418&color=f5a97f)](https://aur.archlinux.org/packages/dgop) 9 | 10 |
11 | 12 |
13 | dgop 14 |
15 | 16 | System monitoring tool with CLI and REST API. 17 | 18 | Can be used standalone, or as a companion for [DankMaterialShell](https://github.com/AvengeMedia/DankMaterialShell) to unlock system information functionality. 19 | 20 | ## Installation 21 | 22 | ### Latest Release 23 | Download the latest binary from [GitHub Releases](https://github.com/AvengeMedia/dgop/releases/latest) 24 | 25 | ### Arch Linux (AUR) 26 | ```bash 27 | # Using yay 28 | yay -S dgop 29 | 30 | # Using paru 31 | paru -S dgop 32 | ``` 33 | 34 | ### Build from Source 35 | ```bash 36 | # Build it 37 | make 38 | 39 | # Install system-wide 40 | sudo make install 41 | 42 | # Or just run locally 43 | go run ./cmd/cli [command] 44 | ``` 45 | 46 | ## Basic Commands 47 | 48 | ```bash 49 | # See all at once 50 | dgop all 51 | 52 | # Just CPU info 53 | dgop cpu 54 | 55 | # Memory usage 56 | dgop memory 57 | 58 | # Network interfaces 59 | dgop network 60 | 61 | # Disk usage and mounts 62 | dgop disk 63 | 64 | # Running processes (sorted by CPU usage) 65 | dgop processes 66 | 67 | # System load and uptime 68 | dgop system 69 | 70 | # Hardware info (BIOS, motherboard, etc) 71 | dgop hardware 72 | 73 | # GPU information 74 | dgop gpu 75 | 76 | # Get temperature for specific GPU 77 | dgop gpu-temp --pci-id 10de:2684 78 | 79 | # List available modules 80 | dgop modules 81 | ``` 82 | 83 | ## Meta Command 84 | 85 | Mix and match any modules you want: 86 | 87 | ```bash 88 | # Just CPU and memory 89 | dgop meta --modules cpu,memory 90 | 91 | # Everything except processes 92 | dgop meta --modules cpu,memory,network,disk,system,hardware,gpu 93 | 94 | # GPU with temperatures 95 | dgop meta --modules gpu --gpu-pci-ids 10de:2684 96 | 97 | # Multiple GPU temperatures 98 | dgop meta --modules gpu --gpu-pci-ids 10de:2684,1002:164e 99 | 100 | # Everything (same as 'dgop all') 101 | dgop meta --modules all 102 | ``` 103 | 104 | ## JSON Output 105 | 106 | Add `--json` to any command: 107 | 108 | ```bash 109 | dgop cpu --json 110 | dgop meta --modules gpu,memory --json 111 | ``` 112 | 113 | ## Process Options 114 | 115 | ```bash 116 | # Sort by memory instead of CPU 117 | dgop processes --sort memory 118 | 119 | # Limit to top 10 120 | dgop processes --limit 10 121 | 122 | # Skip CPU calculation for faster results 123 | dgop processes --no-cpu 124 | 125 | # Combine options 126 | dgop meta --modules processes --sort memory --limit 20 --no-cpu 127 | ``` 128 | 129 | ## API Server 130 | 131 | Start the REST API: 132 | 133 | ```bash 134 | dgop server 135 | ``` 136 | 137 | Then hit these endpoints: 138 | 139 | - **GET** `/gops/cpu` - CPU info 140 | - **GET** `/gops/memory` - Memory usage 141 | - **GET** `/gops/network` - Network interfaces 142 | - **GET** `/gops/disk` - Disk usage 143 | - **GET** `/gops/processes?sort_by=memory&limit=10` - Top 10 processes by memory 144 | - **GET** `/gops/system` - System load and uptime 145 | - **GET** `/gops/hardware` - Hardware info 146 | - **GET** `/gops/gpu` - GPU information 147 | - **GET** `/gops/gpu/temp?pciId=10de:2684` - GPU temperature 148 | - **GET** `/gops/modules` - List available modules 149 | - **GET** `/gops/meta?modules=cpu,memory&gpu_pci_ids=10de:2684` - Dynamic modules 150 | 151 | API docs: http://localhost:63484/docs 152 | 153 | ## Examples 154 | 155 | ### Get GPU temps for both your cards 156 | ```bash 157 | dgop meta --modules gpu --gpu-pci-ids 10de:2684,1002:164e 158 | ``` 159 | 160 | ### Monitor system without slow CPU calculations 161 | ```bash 162 | dgop meta --modules cpu,memory,network --no-cpu 163 | ``` 164 | 165 | ### API: Get CPU and memory as JSON 166 | ```bash 167 | curl http://localhost:63484/gops/meta?modules=cpu,memory 168 | ``` 169 | 170 | ### API: Get GPU with temperature 171 | ```bash 172 | curl "http://localhost:63484/gops/meta?modules=gpu&gpu_pci_ids=10de:2684" 173 | ``` 174 | 175 | ## Real-time Monitoring with Cursors 176 | 177 | dgop supports cursor-based sampling for building real-time monitoring tools like htop. Instead of relying on instantaneous snapshots, you can track system state changes over time for more accurate CPU usage calculations and network/disk rates. 178 | 179 | The cursor system works by: 180 | - Taking an initial measurement that establishes baseline metrics and timestamps 181 | - Returning a base64-encoded cursor containing the current state data 182 | - Using that cursor in subsequent calls to calculate precise percentages and rates over the sampling interval 183 | 184 | This approach accounts for the actual time elapsed between measurements, making it ideal for monitoring tools that poll every few seconds. 185 | 186 | ### CPU Usage with Cursors 187 | 188 | ```bash 189 | # First call - establishes baseline and returns cursor 190 | dgop cpu --json 191 | # Returns: {"usage":1.68, ..., "cursor":"eyJ0b3RhbCI6WzE2MjMwLjAzLDUuOTUsNTEyMy4yNV0..."} 192 | 193 | # Wait a few seconds, then use cursor for accurate CPU calculations 194 | sleep 3 195 | dgop cpu --json --cursor "eyJ0b3RhbCI6WzE2MjMwLjAzLDUuOTUsNTEyMy4yNV0..." 196 | # Returns more accurate usage percentages based on time delta 197 | ``` 198 | 199 | ### Process Monitoring with Cursors 200 | 201 | ```bash 202 | # First call - establishes process baseline 203 | dgop processes --json --limit 5 204 | # Returns: {"processes":[...], "cursor":"W3sicGlkIjoyODE2NTYsInRpY2tzIjozOS43Mix9XQ..."} 205 | 206 | # Use cursor for accurate per-process CPU calculations 207 | sleep 2 208 | dgop processes --json --limit 5 --cursor "W3sicGlkIjoyODE2NTYsInRpY2tzIjozOS43Mix9XQ..." 209 | ``` 210 | 211 | ### Network Rate Monitoring 212 | 213 | ```bash 214 | # First call - establishes network baseline 215 | dgop net-rate --json 216 | # Returns: {"interfaces":[...], "cursor":"eyJ0aW1lc3RhbXAiOiIyMDI1LTA4LTExVDE2OjE1OjM1..."} 217 | 218 | # Get real-time transfer rates 219 | sleep 3 220 | dgop net-rate --json --cursor "eyJ0aW1lc3RhbXAiOiIyMDI1LTA4LTExVDE2OjE1OjM1..." 221 | # Returns: {"interfaces":[{"interface":"wlp99s0","rxrate":67771,"txrate":16994}]} 222 | ``` 223 | 224 | ### Disk I/O Rate Monitoring 225 | 226 | ```bash 227 | # Establish disk I/O baseline 228 | dgop disk-rate --json 229 | # Returns cursor for disk rate calculations 230 | 231 | # Get real-time disk I/O rates 232 | sleep 2 233 | dgop disk-rate --json --cursor "eyJ0aW1lc3RhbXAiOiIyMDI1LTA4LTExVDE2OjE2..." 234 | ``` 235 | 236 | ### Combined Monitoring with Meta Command 237 | 238 | ```bash 239 | # Monitor CPU, processes, and network rates together 240 | dgop meta --modules cpu,processes,net-rate --json --limit 10 241 | 242 | # Use multiple cursors for comprehensive monitoring 243 | dgop meta --modules cpu,processes,net-rate --json --limit 10 \ 244 | --cpu-cursor "eyJ0b3RhbCI6WzE2MjMwLjAz..." \ 245 | --proc-cursor "W3sicGlkIjoyODE2NTYsInRpY2tzIjo..." \ 246 | --net-rate-cursor "eyJ0aW1lc3RhbXAiOiIyMDI1LTA4LTEx..." 247 | ``` 248 | 249 | ## Development 250 | 251 | ```bash 252 | # Build 253 | make 254 | 255 | # Run tests 256 | make test 257 | 258 | # Format code 259 | make fmt 260 | 261 | # Build and install 262 | make && sudo make install 263 | 264 | # Clean build artifacts 265 | make clean 266 | ``` 267 | 268 | ## Requirements 269 | 270 | - Go 1.22+ 271 | - Linux (uses `/proc`, `/sys`, and system commands) 272 | - Optional: `nvidia-smi` for NVIDIA GPU temperatures 273 | - Optional: `lspci` for GPU detection 274 | 275 | ## Why Another Monitoring Tool? 276 | 277 | Because nothing did what i wanted, i didnt want to run a metrics server, I wanted GO because its fast and compiles to a single binary, bash scripts got too messy. 278 | 279 | TL;DR single binary cli and server with json output, openapi spec, and a bunch of data. 280 | -------------------------------------------------------------------------------- /api/gops/all.go: -------------------------------------------------------------------------------- 1 | package gops_handler 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/AvengeMedia/dgop/gops" 7 | "github.com/AvengeMedia/dgop/internal/log" 8 | "github.com/AvengeMedia/dgop/models" 9 | "github.com/danielgtaylor/huma/v2" 10 | ) 11 | 12 | type AllInput struct { 13 | SortBy gops.ProcSortBy `query:"ps_sort_by" required:"true" default:"cpu"` 14 | Limit int `query:"ps_limit"` 15 | DisableProcCPU bool `query:"disable_proc_cpu" default:"false"` 16 | } 17 | 18 | type AllResponse struct { 19 | Body struct { 20 | Data *models.SystemMetrics `json:"data"` 21 | } 22 | } 23 | 24 | // GET /all 25 | func (self *HandlerGroup) All(ctx context.Context, input *AllInput) (*AllResponse, error) { 26 | enableCPU := !input.DisableProcCPU 27 | all, err := self.srv.Gops.GetAllMetrics(input.SortBy, input.Limit, enableCPU) 28 | if err != nil { 29 | log.Error("Error getting all metrics") 30 | return nil, huma.Error500InternalServerError("Unable to retrieve all metrics") 31 | } 32 | 33 | resp := &AllResponse{} 34 | resp.Body.Data = all 35 | return resp, nil 36 | } 37 | -------------------------------------------------------------------------------- /api/gops/cpu.go: -------------------------------------------------------------------------------- 1 | package gops_handler 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/AvengeMedia/dgop/internal/log" 7 | "github.com/AvengeMedia/dgop/models" 8 | "github.com/danielgtaylor/huma/v2" 9 | ) 10 | 11 | type CpuInput struct { 12 | Cursor string `query:"cursor" required:"false"` 13 | } 14 | 15 | type CpuResponse struct { 16 | Body struct { 17 | Data *models.CPUInfo `json:"data"` 18 | } 19 | } 20 | 21 | // GET /cpu 22 | func (self *HandlerGroup) Cpu(ctx context.Context, input *CpuInput) (*CpuResponse, error) { 23 | cpuInfo, err := self.srv.Gops.GetCPUInfoWithCursor(input.Cursor) 24 | if err != nil { 25 | log.Error("Error getting CPU info") 26 | return nil, huma.Error500InternalServerError("Unable to retrieve CPU info") 27 | } 28 | 29 | resp := &CpuResponse{} 30 | resp.Body.Data = cpuInfo 31 | return resp, nil 32 | } 33 | -------------------------------------------------------------------------------- /api/gops/disk.go: -------------------------------------------------------------------------------- 1 | package gops_handler 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/AvengeMedia/dgop/api/server" 7 | "github.com/AvengeMedia/dgop/internal/log" 8 | "github.com/AvengeMedia/dgop/models" 9 | "github.com/danielgtaylor/huma/v2" 10 | ) 11 | 12 | type DiskResponse struct { 13 | Body struct { 14 | Data []*models.DiskInfo `json:"data"` 15 | } 16 | } 17 | 18 | // GET /disk 19 | func (self *HandlerGroup) Disk(ctx context.Context, _ *server.EmptyInput) (*DiskResponse, error) { 20 | 21 | diskInfo, err := self.srv.Gops.GetDiskInfo() 22 | if err != nil { 23 | log.Error("Error getting Disk info") 24 | return nil, huma.Error500InternalServerError("Unable to retrieve Disk info") 25 | } 26 | 27 | resp := &DiskResponse{} 28 | resp.Body.Data = diskInfo 29 | return resp, nil 30 | } 31 | 32 | // GET /disk/mounts 33 | type DiskMountsResponse struct { 34 | Body struct { 35 | Data []*models.DiskMountInfo `json:"data"` 36 | } 37 | } 38 | 39 | func (self *HandlerGroup) DiskMounts(ctx context.Context, _ *server.EmptyInput) (*DiskMountsResponse, error) { 40 | 41 | diskMountsInfo, err := self.srv.Gops.GetDiskMounts() 42 | if err != nil { 43 | log.Error("Error getting Disk Mounts info") 44 | return nil, huma.Error500InternalServerError("Unable to retrieve Disk Mounts info") 45 | } 46 | 47 | resp := &DiskMountsResponse{} 48 | resp.Body.Data = diskMountsInfo 49 | return resp, nil 50 | } 51 | -------------------------------------------------------------------------------- /api/gops/diskrate.go: -------------------------------------------------------------------------------- 1 | package gops_handler 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/AvengeMedia/dgop/internal/log" 7 | "github.com/AvengeMedia/dgop/models" 8 | "github.com/danielgtaylor/huma/v2" 9 | ) 10 | 11 | type DiskRateInput struct { 12 | Cursor string `query:"cursor" doc:"Base64 cursor for rate calculation"` 13 | } 14 | 15 | type DiskRateResponse struct { 16 | Body *models.DiskRateResponse 17 | } 18 | 19 | // GET /disk-rate 20 | func (self *HandlerGroup) DiskRate(ctx context.Context, input *DiskRateInput) (*DiskRateResponse, error) { 21 | diskRateInfo, err := self.srv.Gops.GetDiskRates(input.Cursor) 22 | if err != nil { 23 | log.Error("Error getting disk rates") 24 | return nil, huma.Error500InternalServerError("Unable to retrieve disk rates") 25 | } 26 | 27 | resp := &DiskRateResponse{} 28 | resp.Body = diskRateInfo 29 | return resp, nil 30 | } 31 | -------------------------------------------------------------------------------- /api/gops/gops.go: -------------------------------------------------------------------------------- 1 | package gops_handler 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/AvengeMedia/dgop/api/server" 7 | "github.com/danielgtaylor/huma/v2" 8 | ) 9 | 10 | type HandlerGroup struct { 11 | srv *server.Server 12 | } 13 | 14 | func RegisterHandlers(server *server.Server, grp *huma.Group) { 15 | handlers := &HandlerGroup{ 16 | srv: server, 17 | } 18 | 19 | huma.Register( 20 | grp, 21 | huma.Operation{ 22 | OperationID: "all", 23 | Summary: "Get All Metrics", 24 | Description: "Get all system metrics including CPU, memory, network, and processes", 25 | Path: "/all", 26 | Method: http.MethodGet, 27 | }, 28 | handlers.All, 29 | ) 30 | 31 | huma.Register( 32 | grp, 33 | huma.Operation{ 34 | OperationID: "cpu", 35 | Summary: "Get CPU Info", 36 | Description: "Get information about the CPUs", 37 | Path: "/cpu", 38 | Method: http.MethodGet, 39 | }, 40 | handlers.Cpu, 41 | ) 42 | 43 | huma.Register( 44 | grp, 45 | huma.Operation{ 46 | OperationID: "memory", 47 | Summary: "Get Memory Info", 48 | Description: "Get information about the system memory", 49 | Path: "/memory", 50 | Method: http.MethodGet, 51 | }, 52 | handlers.Memory, 53 | ) 54 | 55 | huma.Register( 56 | grp, 57 | huma.Operation{ 58 | OperationID: "network", 59 | Summary: "Get Network Info", 60 | Description: "Get information about the network interfaces", 61 | Path: "/network", 62 | Method: http.MethodGet, 63 | }, 64 | handlers.Network, 65 | ) 66 | 67 | huma.Register( 68 | grp, 69 | huma.Operation{ 70 | OperationID: "net-rate", 71 | Summary: "Get Network Rates", 72 | Description: "Get network transfer rates with cursor-based sampling for accurate rate calculations", 73 | Path: "/net-rate", 74 | Method: http.MethodGet, 75 | }, 76 | handlers.NetRate, 77 | ) 78 | 79 | huma.Register( 80 | grp, 81 | huma.Operation{ 82 | OperationID: "disk-rate", 83 | Summary: "Get Disk I/O Rates", 84 | Description: "Get disk I/O rates with cursor-based sampling for accurate rate calculations", 85 | Path: "/disk-rate", 86 | Method: http.MethodGet, 87 | }, 88 | handlers.DiskRate, 89 | ) 90 | 91 | huma.Register( 92 | grp, 93 | huma.Operation{ 94 | OperationID: "system", 95 | Summary: "Get System Info", 96 | Description: "Get general system information", 97 | Path: "/system", 98 | Method: http.MethodGet, 99 | }, 100 | handlers.System, 101 | ) 102 | 103 | huma.Register( 104 | grp, 105 | huma.Operation{ 106 | OperationID: "processes", 107 | Summary: "Get Processes", 108 | Description: "Get a list of running processes", 109 | Path: "/processes", 110 | Method: http.MethodGet, 111 | }, 112 | handlers.Processes, 113 | ) 114 | 115 | huma.Register( 116 | grp, 117 | huma.Operation{ 118 | OperationID: "disks", 119 | Summary: "Get Disk Info", 120 | Description: "Get information about the system disks", 121 | Path: "/disk", 122 | Method: http.MethodGet, 123 | }, 124 | handlers.Disk, 125 | ) 126 | 127 | huma.Register( 128 | grp, 129 | huma.Operation{ 130 | OperationID: "disk-mounts", 131 | Summary: "Get Disk Mounts", 132 | Description: "Get information about the disk mounts", 133 | Path: "/disk/mounts", 134 | Method: http.MethodGet, 135 | }, 136 | handlers.DiskMounts, 137 | ) 138 | 139 | huma.Register( 140 | grp, 141 | huma.Operation{ 142 | OperationID: "hardware", 143 | Summary: "Get Hardware Info", 144 | Description: "Get system hardware information including BIOS, motherboard, and CPU", 145 | Path: "/hardware", 146 | Method: http.MethodGet, 147 | }, 148 | handlers.SystemHardware, 149 | ) 150 | 151 | huma.Register( 152 | grp, 153 | huma.Operation{ 154 | OperationID: "gpu", 155 | Summary: "Get GPU Info", 156 | Description: "Get information about GPUs and graphics cards", 157 | Path: "/gpu", 158 | Method: http.MethodGet, 159 | }, 160 | handlers.GPU, 161 | ) 162 | 163 | huma.Register( 164 | grp, 165 | huma.Operation{ 166 | OperationID: "gpu-temp", 167 | Summary: "Get GPU Temperature", 168 | Description: "Get temperature for a specific GPU by PCI ID", 169 | Path: "/gpu/temp", 170 | Method: http.MethodGet, 171 | }, 172 | handlers.GPUTemp, 173 | ) 174 | 175 | huma.Register( 176 | grp, 177 | huma.Operation{ 178 | OperationID: "meta", 179 | Summary: "Get Dynamic Metrics", 180 | Description: "Get system metrics for specified modules (e.g., cpu,memory,network)", 181 | Path: "/meta", 182 | Method: http.MethodGet, 183 | }, 184 | handlers.Meta, 185 | ) 186 | 187 | huma.Register( 188 | grp, 189 | huma.Operation{ 190 | OperationID: "modules", 191 | Summary: "List Available Modules", 192 | Description: "Get a list of all available modules for the meta endpoint", 193 | Path: "/modules", 194 | Method: http.MethodGet, 195 | }, 196 | handlers.Modules, 197 | ) 198 | } 199 | -------------------------------------------------------------------------------- /api/gops/hardware.go: -------------------------------------------------------------------------------- 1 | package gops_handler 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/AvengeMedia/dgop/internal/log" 7 | "github.com/AvengeMedia/dgop/models" 8 | "github.com/danielgtaylor/huma/v2" 9 | ) 10 | 11 | type SystemHardwareResponse struct { 12 | Body *models.SystemHardware 13 | } 14 | 15 | type GPUResponse struct { 16 | Body *models.GPUInfo 17 | } 18 | 19 | type GPUTempInput struct { 20 | PciId string `query:"pciId" required:"true" example:"10de:2684"` 21 | } 22 | 23 | type GPUTempResponse struct { 24 | Body *models.GPUTempInfo 25 | } 26 | 27 | // GET /hardware 28 | func (self *HandlerGroup) SystemHardware(ctx context.Context, input *struct{}) (*SystemHardwareResponse, error) { 29 | systemInfo, err := self.srv.Gops.GetSystemHardware() 30 | if err != nil { 31 | log.Error("Error getting system hardware info") 32 | return nil, huma.Error500InternalServerError("Unable to retrieve system hardware info") 33 | } 34 | 35 | return &SystemHardwareResponse{Body: systemInfo}, nil 36 | } 37 | 38 | // GET /gpu 39 | func (self *HandlerGroup) GPU(ctx context.Context, input *struct{}) (*GPUResponse, error) { 40 | gpuInfo, err := self.srv.Gops.GetGPUInfo() 41 | if err != nil { 42 | log.Error("Error getting GPU info") 43 | return nil, huma.Error500InternalServerError("Unable to retrieve GPU info") 44 | } 45 | 46 | return &GPUResponse{Body: gpuInfo}, nil 47 | } 48 | 49 | // GET /gpu/temp 50 | func (self *HandlerGroup) GPUTemp(ctx context.Context, input *GPUTempInput) (*GPUTempResponse, error) { 51 | gpuTempInfo, err := self.srv.Gops.GetGPUTemp(input.PciId) 52 | if err != nil { 53 | log.Error("Error getting GPU temperature") 54 | return nil, huma.Error400BadRequest(err.Error()) 55 | } 56 | 57 | return &GPUTempResponse{Body: gpuTempInfo}, nil 58 | } 59 | -------------------------------------------------------------------------------- /api/gops/memory.go: -------------------------------------------------------------------------------- 1 | package gops_handler 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/AvengeMedia/dgop/api/server" 7 | "github.com/AvengeMedia/dgop/internal/log" 8 | "github.com/AvengeMedia/dgop/models" 9 | "github.com/danielgtaylor/huma/v2" 10 | ) 11 | 12 | type MemoryResponse struct { 13 | Body struct { 14 | Data *models.MemoryInfo `json:"data"` 15 | } 16 | } 17 | 18 | // GET /memory 19 | func (self *HandlerGroup) Memory(ctx context.Context, _ *server.EmptyInput) (*MemoryResponse, error) { 20 | 21 | memoryInfo, err := self.srv.Gops.GetMemoryInfo() 22 | if err != nil { 23 | log.Error("Error getting memory info") 24 | return nil, huma.Error500InternalServerError("Unable to retrieve memory info") 25 | } 26 | 27 | resp := &MemoryResponse{} 28 | resp.Body.Data = memoryInfo 29 | return resp, nil 30 | } 31 | -------------------------------------------------------------------------------- /api/gops/meta.go: -------------------------------------------------------------------------------- 1 | package gops_handler 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | "github.com/AvengeMedia/dgop/gops" 8 | "github.com/AvengeMedia/dgop/internal/log" 9 | "github.com/AvengeMedia/dgop/models" 10 | "github.com/danielgtaylor/huma/v2" 11 | ) 12 | 13 | type MetaInput struct { 14 | Modules []string `query:"modules" required:"true" example:"cpu,memory,network"` 15 | SortBy gops.ProcSortBy `query:"sort_by" default:"cpu"` 16 | Limit int `query:"limit" default:"0"` 17 | DisableProcCPU bool `query:"disable_proc_cpu" default:"false"` 18 | 19 | // Module-specific parameters 20 | GPUPciIds []string `query:"gpu_pci_ids" example:"10de:2684,1002:164e" doc:"PCI IDs for GPU temperatures (when gpu module is requested)"` 21 | CPUCursor string `query:"cpu_cursor" doc:"CPU cursor from previous request"` 22 | ProcCursor string `query:"proc_cursor" doc:"Process cursor from previous request"` 23 | NetRateCursor string `query:"net_rate_cursor" doc:"Network rate cursor from previous request"` 24 | DiskRateCursor string `query:"disk_rate_cursor" doc:"Disk rate cursor from previous request"` 25 | } 26 | 27 | type MetaResponse struct { 28 | Body *models.MetaInfo 29 | } 30 | 31 | type ModulesResponse struct { 32 | Body *models.ModulesInfo 33 | } 34 | 35 | // GET /meta 36 | func (self *HandlerGroup) Meta(ctx context.Context, input *MetaInput) (*MetaResponse, error) { 37 | // Parse modules if it's a single comma-separated string 38 | var modules []string 39 | if len(input.Modules) == 1 && strings.Contains(input.Modules[0], ",") { 40 | modules = strings.Split(input.Modules[0], ",") 41 | // Trim whitespace 42 | for i, module := range modules { 43 | modules[i] = strings.TrimSpace(module) 44 | } 45 | } else { 46 | modules = input.Modules 47 | } 48 | 49 | params := gops.MetaParams{ 50 | SortBy: input.SortBy, 51 | ProcLimit: input.Limit, 52 | EnableCPU: !input.DisableProcCPU, 53 | GPUPciIds: input.GPUPciIds, 54 | CPUCursor: input.CPUCursor, 55 | ProcCursor: input.ProcCursor, 56 | NetRateCursor: input.NetRateCursor, 57 | DiskRateCursor: input.DiskRateCursor, 58 | } 59 | 60 | metaInfo, err := self.srv.Gops.GetMeta(modules, params) 61 | if err != nil { 62 | log.Error("Error getting meta info") 63 | return nil, huma.Error400BadRequest(err.Error()) 64 | } 65 | 66 | return &MetaResponse{Body: metaInfo}, nil 67 | } 68 | 69 | // GET /modules 70 | func (self *HandlerGroup) Modules(ctx context.Context, input *struct{}) (*ModulesResponse, error) { 71 | modulesInfo, err := self.srv.Gops.GetModules() 72 | if err != nil { 73 | log.Error("Error getting modules info") 74 | return nil, huma.Error500InternalServerError("Unable to retrieve modules info") 75 | } 76 | 77 | return &ModulesResponse{Body: modulesInfo}, nil 78 | } 79 | -------------------------------------------------------------------------------- /api/gops/netrate.go: -------------------------------------------------------------------------------- 1 | package gops_handler 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/AvengeMedia/dgop/internal/log" 7 | "github.com/AvengeMedia/dgop/models" 8 | "github.com/danielgtaylor/huma/v2" 9 | ) 10 | 11 | type NetRateInput struct { 12 | Cursor string `query:"cursor" doc:"Base64 cursor for rate calculation"` 13 | } 14 | 15 | type NetRateResponse struct { 16 | Body *models.NetworkRateResponse 17 | } 18 | 19 | // GET /net-rate 20 | func (self *HandlerGroup) NetRate(ctx context.Context, input *NetRateInput) (*NetRateResponse, error) { 21 | netRateInfo, err := self.srv.Gops.GetNetworkRates(input.Cursor) 22 | if err != nil { 23 | log.Error("Error getting network rates") 24 | return nil, huma.Error500InternalServerError("Unable to retrieve network rates") 25 | } 26 | 27 | resp := &NetRateResponse{} 28 | resp.Body = netRateInfo 29 | return resp, nil 30 | } 31 | -------------------------------------------------------------------------------- /api/gops/network.go: -------------------------------------------------------------------------------- 1 | package gops_handler 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/AvengeMedia/dgop/api/server" 7 | "github.com/AvengeMedia/dgop/internal/log" 8 | "github.com/AvengeMedia/dgop/models" 9 | "github.com/danielgtaylor/huma/v2" 10 | ) 11 | 12 | type NetworkResponse struct { 13 | Body struct { 14 | Data []*models.NetworkInfo `json:"data"` 15 | } 16 | } 17 | 18 | // GET /network 19 | func (self *HandlerGroup) Network(ctx context.Context, _ *server.EmptyInput) (*NetworkResponse, error) { 20 | 21 | networkInfo, err := self.srv.Gops.GetNetworkInfo() 22 | if err != nil { 23 | log.Error("Error getting Network info") 24 | return nil, huma.Error500InternalServerError("Unable to retrieve Network info") 25 | } 26 | 27 | resp := &NetworkResponse{} 28 | resp.Body.Data = networkInfo 29 | return resp, nil 30 | } 31 | -------------------------------------------------------------------------------- /api/gops/processes.go: -------------------------------------------------------------------------------- 1 | package gops_handler 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/AvengeMedia/dgop/gops" 7 | "github.com/AvengeMedia/dgop/internal/log" 8 | "github.com/AvengeMedia/dgop/models" 9 | "github.com/danielgtaylor/huma/v2" 10 | ) 11 | 12 | type ProcessInput struct { 13 | SortBy gops.ProcSortBy `query:"sort_by" required:"true" default:"cpu"` 14 | Limit int `query:"limit"` 15 | DisableProcCPU bool `query:"disable_proc_cpu" default:"false"` 16 | Cursor string `query:"cursor" required:"false"` 17 | } 18 | 19 | type ProcessResponse struct { 20 | Body struct { 21 | Data []*models.ProcessInfo `json:"data"` 22 | Cursor string `json:"cursor,omitempty"` 23 | } 24 | } 25 | 26 | // GET /processes 27 | func (self *HandlerGroup) Processes(ctx context.Context, input *ProcessInput) (*ProcessResponse, error) { 28 | enableCPU := !input.DisableProcCPU 29 | 30 | result, err := self.srv.Gops.GetProcessesWithCursor(input.SortBy, input.Limit, enableCPU, input.Cursor) 31 | if err != nil { 32 | log.Error("Error getting process info") 33 | return nil, huma.Error500InternalServerError("Unable to retrieve process info") 34 | } 35 | 36 | resp := &ProcessResponse{} 37 | resp.Body.Data = result.Processes 38 | resp.Body.Cursor = result.Cursor 39 | return resp, nil 40 | } 41 | -------------------------------------------------------------------------------- /api/gops/system.go: -------------------------------------------------------------------------------- 1 | package gops_handler 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/AvengeMedia/dgop/api/server" 7 | "github.com/AvengeMedia/dgop/internal/log" 8 | "github.com/AvengeMedia/dgop/models" 9 | "github.com/danielgtaylor/huma/v2" 10 | ) 11 | 12 | type SystemResponse struct { 13 | Body struct { 14 | Data *models.SystemInfo `json:"data"` 15 | } 16 | } 17 | 18 | // GET /system 19 | func (self *HandlerGroup) System(ctx context.Context, _ *server.EmptyInput) (*SystemResponse, error) { 20 | 21 | systemInfo, err := self.srv.Gops.GetSystemInfo() 22 | if err != nil { 23 | log.Error("Error getting system info") 24 | return nil, huma.Error500InternalServerError("Unable to retrieve system info") 25 | } 26 | 27 | resp := &SystemResponse{} 28 | resp.Body.Data = systemInfo 29 | return resp, nil 30 | } 31 | -------------------------------------------------------------------------------- /api/middleware/logger.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | // Log middleware from go-chi 4 | // https://github.com/go-chi/chi/blob/d32a83448b5f43e42bc96487c6b0b3667a92a2e4/middleware/logger.go 5 | // Modified for our custom logger 6 | 7 | import ( 8 | "bytes" 9 | "context" 10 | "net/http" 11 | "runtime" 12 | "time" 13 | 14 | "github.com/AvengeMedia/dgop/internal/log" 15 | "github.com/AvengeMedia/dgop/utils" 16 | ) 17 | 18 | // for defining context keys was copied from Go 1.7's new use of context in net/http. 19 | type contextKey struct { 20 | name string 21 | } 22 | 23 | var ( 24 | // LogEntryCtxKey is the context.Context key to store the request log entry. 25 | LogEntryCtxKey = &contextKey{"LogEntry"} 26 | 27 | // DefaultLogger is called by the Logger middleware handler to log each request. 28 | // Its made a package-level variable so that it can be reconfigured for custom 29 | // logging configurations. 30 | DefaultLogger func(next http.Handler) http.Handler 31 | ) 32 | 33 | // Logger is a middleware that logs the start and end of each request, along 34 | // with some useful data about what was requested, what the response status was, 35 | // and how long it took to return. When standard output is a TTY, Logger will 36 | // print in color, otherwise it will print in black and white. Logger prints a 37 | // request ID if one is provided. 38 | // 39 | // Alternatively, look at https://github.com/goware/httplog for a more in-depth 40 | // http logger with structured logging support. 41 | // 42 | // IMPORTANT NOTE: Logger should go before any other middleware that may change 43 | // the response, such as middleware.Recoverer. Example: 44 | // 45 | // r := chi.NewRouter() 46 | // r.Use(middleware.Logger) // <--<< Logger should come before Recoverer 47 | // r.Use(middleware.Recoverer) 48 | // r.Get("/", handler) 49 | func Logger(next http.Handler) http.Handler { 50 | return DefaultLogger(next) 51 | } 52 | 53 | // RequestLogger returns a logger handler using a custom LogFormatter. 54 | func RequestLogger(f LogFormatter) func(next http.Handler) http.Handler { 55 | return func(next http.Handler) http.Handler { 56 | fn := func(w http.ResponseWriter, r *http.Request) { 57 | entry := f.NewLogEntry(r) 58 | ww := NewWrapResponseWriter(w, r.ProtoMajor) 59 | 60 | t1 := time.Now() 61 | defer func() { 62 | entry.Write(ww.Status(), ww.BytesWritten(), ww.Header(), time.Since(t1), nil) 63 | }() 64 | 65 | next.ServeHTTP(ww, WithLogEntry(r, entry)) 66 | } 67 | return http.HandlerFunc(fn) 68 | } 69 | } 70 | 71 | // LogFormatter initiates the beginning of a new LogEntry per request. 72 | // See DefaultLogFormatter for an example implementation. 73 | type LogFormatter interface { 74 | NewLogEntry(r *http.Request) LogEntry 75 | } 76 | 77 | // LogEntry records the final log when a request completes. 78 | // See defaultLogEntry for an example implementation. 79 | type LogEntry interface { 80 | Write(status, bytes int, header http.Header, elapsed time.Duration, extra interface{}) 81 | } 82 | 83 | // GetLogEntry returns the in-context LogEntry for a request. 84 | func GetLogEntry(r *http.Request) LogEntry { 85 | entry, _ := r.Context().Value(LogEntryCtxKey).(LogEntry) 86 | return entry 87 | } 88 | 89 | // WithLogEntry sets the in-context LogEntry for a request. 90 | func WithLogEntry(r *http.Request, entry LogEntry) *http.Request { 91 | r = r.WithContext(context.WithValue(r.Context(), LogEntryCtxKey, entry)) 92 | return r 93 | } 94 | 95 | // DefaultLogFormatter is a simple logger that implements a LogFormatter. 96 | type DefaultLogFormatter struct { 97 | NoColor bool 98 | } 99 | 100 | // NewLogEntry creates a new LogEntry for the request. 101 | func (l *DefaultLogFormatter) NewLogEntry(r *http.Request) LogEntry { 102 | useColor := !l.NoColor 103 | entry := &defaultLogEntry{ 104 | DefaultLogFormatter: l, 105 | request: r, 106 | buf: &bytes.Buffer{}, 107 | useColor: useColor, 108 | } 109 | 110 | reqID := GetReqID(r.Context()) 111 | if reqID != "" { 112 | cW(entry.buf, useColor, nYellow, "[%s] ", reqID) 113 | } 114 | cW(entry.buf, useColor, nCyan, "\"") 115 | cW(entry.buf, useColor, bMagenta, "%s ", r.Method) 116 | 117 | scheme := "http" 118 | if r.TLS != nil { 119 | scheme = "https" 120 | } 121 | cW(entry.buf, useColor, nCyan, "%s://%s%s %s\" ", scheme, r.Host, r.RequestURI, r.Proto) 122 | 123 | entry.buf.WriteString("from ") 124 | entry.buf.WriteString(utils.GetIPAddress(r)) 125 | entry.buf.WriteString(" - ") 126 | 127 | return entry 128 | } 129 | 130 | type defaultLogEntry struct { 131 | *DefaultLogFormatter 132 | request *http.Request 133 | buf *bytes.Buffer 134 | useColor bool 135 | } 136 | 137 | func (l *defaultLogEntry) Write(status, bytes int, header http.Header, elapsed time.Duration, extra interface{}) { 138 | switch { 139 | case status < 200: 140 | cW(l.buf, l.useColor, bBlue, "%03d", status) 141 | case status < 300: 142 | cW(l.buf, l.useColor, bGreen, "%03d", status) 143 | case status < 400: 144 | cW(l.buf, l.useColor, bCyan, "%03d", status) 145 | case status < 500: 146 | cW(l.buf, l.useColor, bYellow, "%03d", status) 147 | default: 148 | cW(l.buf, l.useColor, bRed, "%03d", status) 149 | } 150 | 151 | cW(l.buf, l.useColor, bBlue, " %dB", bytes) 152 | 153 | l.buf.WriteString(" in ") 154 | 155 | elapsedMillis := elapsed.Truncate(time.Millisecond) 156 | 157 | if elapsed < 500*time.Millisecond { 158 | cW(l.buf, l.useColor, nGreen, "%s", elapsedMillis) 159 | } else if elapsed < 5*time.Second { 160 | cW(l.buf, l.useColor, nYellow, "%s", elapsedMillis) 161 | } else { 162 | cW(l.buf, l.useColor, nRed, "%s", elapsedMillis) 163 | } 164 | 165 | log.Infof("%s", l.buf.String()) 166 | } 167 | 168 | func init() { 169 | color := true 170 | if runtime.GOOS == "windows" { 171 | color = false 172 | } 173 | DefaultLogger = RequestLogger(&DefaultLogFormatter{NoColor: !color}) 174 | } 175 | -------------------------------------------------------------------------------- /api/middleware/logger_test.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "net" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | "time" 11 | 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | type testLoggerWriter struct { 16 | *httptest.ResponseRecorder 17 | } 18 | 19 | func (cw testLoggerWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { 20 | return nil, nil, nil 21 | } 22 | 23 | func TestRequestLogger(t *testing.T) { 24 | testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 25 | _, ok := w.(http.Hijacker) 26 | if !ok { 27 | t.Errorf("http.Hijacker is unavailable on the writer. add the interface methods.") 28 | } 29 | }) 30 | 31 | r := httptest.NewRequest("GET", "/", nil) 32 | w := testLoggerWriter{ 33 | ResponseRecorder: httptest.NewRecorder(), 34 | } 35 | 36 | handler := DefaultLogger(testHandler) 37 | handler.ServeHTTP(w, r) 38 | } 39 | 40 | func TestRequestLoggerReadFrom(t *testing.T) { 41 | data := []byte("file data") 42 | testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 43 | http.ServeContent(w, r, "file", time.Time{}, bytes.NewReader(data)) 44 | }) 45 | 46 | r := httptest.NewRequest("GET", "/", nil) 47 | w := httptest.NewRecorder() 48 | 49 | handler := DefaultLogger(testHandler) 50 | handler.ServeHTTP(w, r) 51 | 52 | assert.Equal(t, data, w.Body.Bytes()) 53 | } 54 | -------------------------------------------------------------------------------- /api/middleware/middleware.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/AvengeMedia/dgop/config" 5 | "github.com/danielgtaylor/huma/v2" 6 | ) 7 | 8 | type Middleware struct { 9 | api huma.API 10 | cfg *config.Config 11 | } 12 | 13 | func NewMiddleware(cfg *config.Config, api huma.API) *Middleware { 14 | return &Middleware{ 15 | api: api, 16 | cfg: cfg, 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /api/middleware/recoverer.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | // This is a near verbatim transplant of chi/middleware.Recoverer, but 4 | // adapted to Huma’s (ctx, next) signature and `huma.WriteErr` response. 5 | 6 | import ( 7 | "bytes" 8 | "errors" 9 | "fmt" 10 | "io" 11 | "net/http" 12 | "os" 13 | "runtime/debug" 14 | "strings" 15 | 16 | "github.com/danielgtaylor/huma/v2" 17 | ) 18 | 19 | // Recover catches panics, logs a pretty stack trace, and returns the 20 | // usual Huma error envelope `{error:"Internal server error",status:500}`. 21 | func (self *Middleware) Recoverer(ctx huma.Context, next func(huma.Context)) { 22 | defer func() { 23 | if rvr := recover(); rvr != nil { 24 | // Honour http.ErrAbortHandler (same as chi). 25 | if rvr == http.ErrAbortHandler { 26 | panic(rvr) 27 | } 28 | 29 | // Pretty print stack 30 | PrintPrettyStack(rvr) 31 | 32 | // Skip WebSocket/upgrade connections, just like chi. 33 | if ctx.Header("Connection") != "Upgrade" { 34 | huma.WriteErr(self.api, ctx, http.StatusInternalServerError, "Internal server error") 35 | } 36 | } 37 | }() 38 | 39 | next(ctx) 40 | } 41 | 42 | // * Lifted from go-chi middleware.Recoverer 43 | 44 | var recovererErrorWriter io.Writer = os.Stderr 45 | 46 | func PrintPrettyStack(rvr interface{}) { 47 | debugStack := debug.Stack() 48 | s := prettyStack{} 49 | out, err := s.parse(debugStack, rvr) 50 | if err == nil { 51 | recovererErrorWriter.Write(out) 52 | } else { 53 | // Fallback to the raw stack. 54 | os.Stderr.Write(debugStack) 55 | } 56 | } 57 | 58 | type prettyStack struct{} 59 | 60 | func (s prettyStack) parse(debugStack []byte, rvr interface{}) ([]byte, error) { 61 | var err error 62 | useColor := true 63 | buf := &bytes.Buffer{} 64 | 65 | cW(buf, false, bRed, "\n") 66 | cW(buf, useColor, bCyan, " panic: ") 67 | cW(buf, useColor, bBlue, "%v", rvr) 68 | cW(buf, false, bWhite, "\n \n") 69 | 70 | stack := strings.Split(string(debugStack), "\n") 71 | lines := []string{} 72 | 73 | // Find the innermost panic. 74 | for i := len(stack) - 1; i > 0; i-- { 75 | lines = append(lines, stack[i]) 76 | if strings.HasPrefix(stack[i], "panic(") { 77 | lines = lines[:len(lines)-2] // chop boilerplate 78 | break 79 | } 80 | } 81 | 82 | // Reverse the order. 83 | for i := len(lines)/2 - 1; i >= 0; i-- { 84 | opp := len(lines) - 1 - i 85 | lines[i], lines[opp] = lines[opp], lines[i] 86 | } 87 | 88 | // Decorate lines. 89 | for i, line := range lines { 90 | lines[i], err = s.decorateLine(line, useColor, i) 91 | if err != nil { 92 | return nil, err 93 | } 94 | } 95 | 96 | for _, l := range lines { 97 | fmt.Fprint(buf, l) 98 | } 99 | return buf.Bytes(), nil 100 | } 101 | 102 | func (s prettyStack) decorateLine(line string, useColor bool, num int) (string, error) { 103 | line = strings.TrimSpace(line) 104 | switch { 105 | case strings.HasPrefix(line, "\t"), strings.Contains(line, ".go:"): 106 | return s.decorateSourceLine(line, useColor, num) 107 | case strings.HasSuffix(line, ")"): 108 | return s.decorateFuncCallLine(line, useColor, num) 109 | case strings.HasPrefix(line, "\t"): 110 | return strings.Replace(line, "\t", " ", 1), nil 111 | default: 112 | return fmt.Sprintf(" %s\n", line), nil 113 | } 114 | } 115 | 116 | func (s prettyStack) decorateFuncCallLine(line string, useColor bool, num int) (string, error) { 117 | idx := strings.LastIndex(line, "(") 118 | if idx < 0 { 119 | return "", errors.New("not a func call line") 120 | } 121 | 122 | buf := &bytes.Buffer{} 123 | pkg := line[:idx] 124 | method := "" 125 | 126 | if pos := strings.LastIndex(pkg, string(os.PathSeparator)); pos < 0 { 127 | if pos := strings.Index(pkg, "."); pos > 0 { 128 | method = pkg[pos:] 129 | pkg = pkg[:pos] 130 | } 131 | } else { 132 | method = pkg[pos+1:] 133 | pkg = pkg[:pos+1] 134 | if pos := strings.Index(method, "."); pos > 0 { 135 | pkg += method[:pos] 136 | method = method[pos:] 137 | } 138 | } 139 | 140 | pkgColor := nYellow 141 | methodColor := bGreen 142 | 143 | if num == 0 { 144 | cW(buf, useColor, bRed, " -> ") 145 | pkgColor, methodColor = bMagenta, bRed 146 | } else { 147 | cW(buf, useColor, bWhite, " ") 148 | } 149 | cW(buf, useColor, pkgColor, "%s", pkg) 150 | cW(buf, useColor, methodColor, "%s\n", method) 151 | return buf.String(), nil 152 | } 153 | 154 | func (s prettyStack) decorateSourceLine(line string, useColor bool, num int) (string, error) { 155 | idx := strings.LastIndex(line, ".go:") 156 | if idx < 0 { 157 | return "", errors.New("not a source line") 158 | } 159 | 160 | buf := &bytes.Buffer{} 161 | path, lineno := line[:idx+3], line[idx+3:] 162 | 163 | pos := strings.LastIndex(path, string(os.PathSeparator)) 164 | dir, file := path[:pos+1], path[pos+1:] 165 | 166 | if pos := strings.Index(lineno, " "); pos > 0 { 167 | lineno = lineno[:pos] 168 | } 169 | 170 | fileColor, lineColor := bCyan, bGreen 171 | if num == 1 { 172 | cW(buf, useColor, bRed, " -> ") 173 | fileColor, lineColor = bRed, bMagenta 174 | } else { 175 | cW(buf, false, bWhite, " ") 176 | } 177 | cW(buf, useColor, bWhite, "%s", dir) 178 | cW(buf, useColor, fileColor, "%s", file) 179 | cW(buf, useColor, lineColor, "%s", lineno) 180 | if num == 1 { 181 | cW(buf, false, bWhite, "\n") 182 | } 183 | cW(buf, false, bWhite, "\n") 184 | return buf.String(), nil 185 | } 186 | -------------------------------------------------------------------------------- /api/middleware/request_id.go: -------------------------------------------------------------------------------- 1 | // From go-chi 2 | // https://github.com/go-chi/chi/blob/d32a83448b5f43e42bc96487c6b0b3667a92a2e4/middleware/request_id.go 3 | package middleware 4 | 5 | // Ported from Goji's middleware, source: 6 | // https://github.com/zenazn/goji/tree/master/web/middleware 7 | 8 | import ( 9 | "context" 10 | "crypto/rand" 11 | "encoding/base64" 12 | "fmt" 13 | "net/http" 14 | "os" 15 | "strings" 16 | "sync/atomic" 17 | ) 18 | 19 | // Key to use when setting the request ID. 20 | type ctxKeyRequestID int 21 | 22 | // RequestIDKey is the key that holds the unique request ID in a request context. 23 | const RequestIDKey ctxKeyRequestID = 0 24 | 25 | // RequestIDHeader is the name of the HTTP Header which contains the request id. 26 | // Exported so that it can be changed by developers 27 | var RequestIDHeader = "X-Request-Id" 28 | 29 | var prefix string 30 | var reqid uint64 31 | 32 | // A quick note on the statistics here: we're trying to calculate the chance that 33 | // two randomly generated base62 prefixes will collide. We use the formula from 34 | // http://en.wikipedia.org/wiki/Birthday_problem 35 | // 36 | // P[m, n] \approx 1 - e^{-m^2/2n} 37 | // 38 | // We ballpark an upper bound for $m$ by imagining (for whatever reason) a server 39 | // that restarts every second over 10 years, for $m = 86400 * 365 * 10 = 315360000$ 40 | // 41 | // For a $k$ character base-62 identifier, we have $n(k) = 62^k$ 42 | // 43 | // Plugging this in, we find $P[m, n(10)] \approx 5.75%$, which is good enough for 44 | // our purposes, and is surely more than anyone would ever need in practice -- a 45 | // process that is rebooted a handful of times a day for a hundred years has less 46 | // than a millionth of a percent chance of generating two colliding IDs. 47 | 48 | func init() { 49 | hostname, err := os.Hostname() 50 | if hostname == "" || err != nil { 51 | hostname = "localhost" 52 | } 53 | var buf [12]byte 54 | var b64 string 55 | for len(b64) < 10 { 56 | rand.Read(buf[:]) 57 | b64 = base64.StdEncoding.EncodeToString(buf[:]) 58 | b64 = strings.NewReplacer("+", "", "/", "").Replace(b64) 59 | } 60 | 61 | prefix = fmt.Sprintf("%s/%s", hostname, b64[0:10]) 62 | } 63 | 64 | // RequestID is a middleware that injects a request ID into the context of each 65 | // request. A request ID is a string of the form "host.example.com/random-0001", 66 | // where "random" is a base62 random string that uniquely identifies this go 67 | // process, and where the last number is an atomically incremented request 68 | // counter. 69 | func RequestID(next http.Handler) http.Handler { 70 | fn := func(w http.ResponseWriter, r *http.Request) { 71 | ctx := r.Context() 72 | requestID := r.Header.Get(RequestIDHeader) 73 | if requestID == "" { 74 | myid := atomic.AddUint64(&reqid, 1) 75 | requestID = fmt.Sprintf("%s-%06d", prefix, myid) 76 | } 77 | ctx = context.WithValue(ctx, RequestIDKey, requestID) 78 | next.ServeHTTP(w, r.WithContext(ctx)) 79 | } 80 | return http.HandlerFunc(fn) 81 | } 82 | 83 | // GetReqID returns a request ID from the given context if one is present. 84 | // Returns the empty string if a request ID cannot be found. 85 | func GetReqID(ctx context.Context) string { 86 | if ctx == nil { 87 | return "" 88 | } 89 | if reqID, ok := ctx.Value(RequestIDKey).(string); ok { 90 | return reqID 91 | } 92 | return "" 93 | } 94 | 95 | // NextRequestID generates the next request ID in the sequence. 96 | func NextRequestID() uint64 { 97 | return atomic.AddUint64(&reqid, 1) 98 | } 99 | -------------------------------------------------------------------------------- /api/middleware/request_id_test.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/go-chi/chi/v5" 10 | ) 11 | 12 | func maintainDefaultRequestID() func() { 13 | original := RequestIDHeader 14 | 15 | return func() { 16 | RequestIDHeader = original 17 | } 18 | } 19 | 20 | func TestRequestID(t *testing.T) { 21 | tests := map[string]struct { 22 | requestIDHeader string 23 | request func() *http.Request 24 | expectedResponse string 25 | }{ 26 | "Retrieves Request Id from default header": { 27 | "X-Request-Id", 28 | func() *http.Request { 29 | req, _ := http.NewRequest("GET", "/", nil) 30 | req.Header.Add("X-Request-Id", "req-123456") 31 | 32 | return req 33 | }, 34 | "RequestID: req-123456", 35 | }, 36 | "Retrieves Request Id from custom header": { 37 | "X-Trace-Id", 38 | func() *http.Request { 39 | req, _ := http.NewRequest("GET", "/", nil) 40 | req.Header.Add("X-Trace-Id", "trace:abc123") 41 | 42 | return req 43 | }, 44 | "RequestID: trace:abc123", 45 | }, 46 | } 47 | 48 | defer maintainDefaultRequestID()() 49 | 50 | for _, test := range tests { 51 | w := httptest.NewRecorder() 52 | 53 | r := chi.NewRouter() 54 | 55 | RequestIDHeader = test.requestIDHeader 56 | 57 | r.Use(RequestID) 58 | 59 | r.Get("/", func(w http.ResponseWriter, r *http.Request) { 60 | requestID := GetReqID(r.Context()) 61 | response := fmt.Sprintf("RequestID: %s", requestID) 62 | 63 | w.Write([]byte(response)) 64 | }) 65 | r.ServeHTTP(w, test.request()) 66 | 67 | if w.Body.String() != test.expectedResponse { 68 | t.Fatalf("RequestID was not the expected value") 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /api/middleware/terminal.go: -------------------------------------------------------------------------------- 1 | // From go-chi 2 | // https://github.com/go-chi/chi/blob/d32a83448b5f43e42bc96487c6b0b3667a92a2e4/middleware/terminal.go 3 | 4 | package middleware 5 | 6 | // Ported from Goji's middleware, source: 7 | // https://github.com/zenazn/goji/tree/master/web/middleware 8 | 9 | import ( 10 | "fmt" 11 | "io" 12 | "os" 13 | ) 14 | 15 | var ( 16 | // Normal colors 17 | nBlack = []byte{'\033', '[', '3', '0', 'm'} 18 | nRed = []byte{'\033', '[', '3', '1', 'm'} 19 | nGreen = []byte{'\033', '[', '3', '2', 'm'} 20 | nYellow = []byte{'\033', '[', '3', '3', 'm'} 21 | nBlue = []byte{'\033', '[', '3', '4', 'm'} 22 | nMagenta = []byte{'\033', '[', '3', '5', 'm'} 23 | nCyan = []byte{'\033', '[', '3', '6', 'm'} 24 | nWhite = []byte{'\033', '[', '3', '7', 'm'} 25 | // Bright colors 26 | bBlack = []byte{'\033', '[', '3', '0', ';', '1', 'm'} 27 | bRed = []byte{'\033', '[', '3', '1', ';', '1', 'm'} 28 | bGreen = []byte{'\033', '[', '3', '2', ';', '1', 'm'} 29 | bYellow = []byte{'\033', '[', '3', '3', ';', '1', 'm'} 30 | bBlue = []byte{'\033', '[', '3', '4', ';', '1', 'm'} 31 | bMagenta = []byte{'\033', '[', '3', '5', ';', '1', 'm'} 32 | bCyan = []byte{'\033', '[', '3', '6', ';', '1', 'm'} 33 | bWhite = []byte{'\033', '[', '3', '7', ';', '1', 'm'} 34 | 35 | reset = []byte{'\033', '[', '0', 'm'} 36 | ) 37 | 38 | var IsTTY bool 39 | 40 | func init() { 41 | // This is sort of cheating: if stdout is a character device, we assume 42 | // that means it's a TTY. Unfortunately, there are many non-TTY 43 | // character devices, but fortunately stdout is rarely set to any of 44 | // them. 45 | // 46 | // We could solve this properly by pulling in a dependency on 47 | // code.google.com/p/go.crypto/ssh/terminal, for instance, but as a 48 | // heuristic for whether to print in color or in black-and-white, I'd 49 | // really rather not. 50 | fi, err := os.Stdout.Stat() 51 | if err == nil { 52 | m := os.ModeDevice | os.ModeCharDevice 53 | IsTTY = fi.Mode()&m == m 54 | } 55 | } 56 | 57 | // colorWrite 58 | func cW(w io.Writer, useColor bool, color []byte, s string, args ...interface{}) { 59 | if IsTTY && useColor { 60 | w.Write(color) 61 | } 62 | fmt.Fprintf(w, s, args...) 63 | if IsTTY && useColor { 64 | w.Write(reset) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /api/middleware/wrap_writer.go: -------------------------------------------------------------------------------- 1 | // WrapWriter from go-chi 2 | // https://github.com/go-chi/chi/blob/d32a83448b5f43e42bc96487c6b0b3667a92a2e4/middleware/wrap_writer.go 3 | package middleware 4 | 5 | // The original work was derived from Goji's middleware, source: 6 | // https://github.com/zenazn/goji/tree/master/web/middleware 7 | 8 | import ( 9 | "bufio" 10 | "io" 11 | "net" 12 | "net/http" 13 | ) 14 | 15 | // NewWrapResponseWriter wraps an http.ResponseWriter, returning a proxy that allows you to 16 | // hook into various parts of the response process. 17 | func NewWrapResponseWriter(w http.ResponseWriter, protoMajor int) WrapResponseWriter { 18 | _, fl := w.(http.Flusher) 19 | 20 | bw := basicWriter{ResponseWriter: w} 21 | 22 | if protoMajor == 2 { 23 | _, ps := w.(http.Pusher) 24 | if fl && ps { 25 | return &http2FancyWriter{bw} 26 | } 27 | } else { 28 | _, hj := w.(http.Hijacker) 29 | _, rf := w.(io.ReaderFrom) 30 | if fl && hj && rf { 31 | return &httpFancyWriter{bw} 32 | } 33 | if fl && hj { 34 | return &flushHijackWriter{bw} 35 | } 36 | if hj { 37 | return &hijackWriter{bw} 38 | } 39 | } 40 | 41 | if fl { 42 | return &flushWriter{bw} 43 | } 44 | 45 | return &bw 46 | } 47 | 48 | // WrapResponseWriter is a proxy around an http.ResponseWriter that allows you to hook 49 | // into various parts of the response process. 50 | type WrapResponseWriter interface { 51 | http.ResponseWriter 52 | // Status returns the HTTP status of the request, or 0 if one has not 53 | // yet been sent. 54 | Status() int 55 | // BytesWritten returns the total number of bytes sent to the client. 56 | BytesWritten() int 57 | // Tee causes the response body to be written to the given io.Writer in 58 | // addition to proxying the writes through. Only one io.Writer can be 59 | // tee'd to at once: setting a second one will overwrite the first. 60 | // Writes will be sent to the proxy before being written to this 61 | // io.Writer. It is illegal for the tee'd writer to be modified 62 | // concurrently with writes. 63 | Tee(io.Writer) 64 | // Unwrap returns the original proxied target. 65 | Unwrap() http.ResponseWriter 66 | } 67 | 68 | // basicWriter wraps a http.ResponseWriter that implements the minimal 69 | // http.ResponseWriter interface. 70 | type basicWriter struct { 71 | http.ResponseWriter 72 | wroteHeader bool 73 | code int 74 | bytes int 75 | tee io.Writer 76 | } 77 | 78 | func (b *basicWriter) WriteHeader(code int) { 79 | if !b.wroteHeader { 80 | b.code = code 81 | b.wroteHeader = true 82 | b.ResponseWriter.WriteHeader(code) 83 | } 84 | } 85 | 86 | func (b *basicWriter) Write(buf []byte) (int, error) { 87 | b.maybeWriteHeader() 88 | n, err := b.ResponseWriter.Write(buf) 89 | if b.tee != nil { 90 | _, err2 := b.tee.Write(buf[:n]) 91 | // Prefer errors generated by the proxied writer. 92 | if err == nil { 93 | err = err2 94 | } 95 | } 96 | b.bytes += n 97 | return n, err 98 | } 99 | 100 | func (b *basicWriter) maybeWriteHeader() { 101 | if !b.wroteHeader { 102 | b.WriteHeader(http.StatusOK) 103 | } 104 | } 105 | 106 | func (b *basicWriter) Status() int { 107 | return b.code 108 | } 109 | 110 | func (b *basicWriter) BytesWritten() int { 111 | return b.bytes 112 | } 113 | 114 | func (b *basicWriter) Tee(w io.Writer) { 115 | b.tee = w 116 | } 117 | 118 | func (b *basicWriter) Unwrap() http.ResponseWriter { 119 | return b.ResponseWriter 120 | } 121 | 122 | // flushWriter ... 123 | type flushWriter struct { 124 | basicWriter 125 | } 126 | 127 | func (f *flushWriter) Flush() { 128 | f.wroteHeader = true 129 | fl := f.basicWriter.ResponseWriter.(http.Flusher) 130 | fl.Flush() 131 | } 132 | 133 | var _ http.Flusher = &flushWriter{} 134 | 135 | // hijackWriter ... 136 | type hijackWriter struct { 137 | basicWriter 138 | } 139 | 140 | func (f *hijackWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { 141 | hj := f.basicWriter.ResponseWriter.(http.Hijacker) 142 | return hj.Hijack() 143 | } 144 | 145 | var _ http.Hijacker = &hijackWriter{} 146 | 147 | // flushHijackWriter ... 148 | type flushHijackWriter struct { 149 | basicWriter 150 | } 151 | 152 | func (f *flushHijackWriter) Flush() { 153 | f.wroteHeader = true 154 | fl := f.basicWriter.ResponseWriter.(http.Flusher) 155 | fl.Flush() 156 | } 157 | 158 | func (f *flushHijackWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { 159 | hj := f.basicWriter.ResponseWriter.(http.Hijacker) 160 | return hj.Hijack() 161 | } 162 | 163 | var _ http.Flusher = &flushHijackWriter{} 164 | var _ http.Hijacker = &flushHijackWriter{} 165 | 166 | // httpFancyWriter is a HTTP writer that additionally satisfies 167 | // http.Flusher, http.Hijacker, and io.ReaderFrom. It exists for the common case 168 | // of wrapping the http.ResponseWriter that package http gives you, in order to 169 | // make the proxied object support the full method set of the proxied object. 170 | type httpFancyWriter struct { 171 | basicWriter 172 | } 173 | 174 | func (f *httpFancyWriter) Flush() { 175 | f.wroteHeader = true 176 | fl := f.basicWriter.ResponseWriter.(http.Flusher) 177 | fl.Flush() 178 | } 179 | 180 | func (f *httpFancyWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { 181 | hj := f.basicWriter.ResponseWriter.(http.Hijacker) 182 | return hj.Hijack() 183 | } 184 | 185 | func (f *http2FancyWriter) Push(target string, opts *http.PushOptions) error { 186 | return f.basicWriter.ResponseWriter.(http.Pusher).Push(target, opts) 187 | } 188 | 189 | func (f *httpFancyWriter) ReadFrom(r io.Reader) (int64, error) { 190 | if f.basicWriter.tee != nil { 191 | n, err := io.Copy(&f.basicWriter, r) 192 | f.basicWriter.bytes += int(n) 193 | return n, err 194 | } 195 | rf := f.basicWriter.ResponseWriter.(io.ReaderFrom) 196 | f.basicWriter.maybeWriteHeader() 197 | n, err := rf.ReadFrom(r) 198 | f.basicWriter.bytes += int(n) 199 | return n, err 200 | } 201 | 202 | var _ http.Flusher = &httpFancyWriter{} 203 | var _ http.Hijacker = &httpFancyWriter{} 204 | var _ http.Pusher = &http2FancyWriter{} 205 | var _ io.ReaderFrom = &httpFancyWriter{} 206 | 207 | // http2FancyWriter is a HTTP2 writer that additionally satisfies 208 | // http.Flusher, and io.ReaderFrom. It exists for the common case 209 | // of wrapping the http.ResponseWriter that package http gives you, in order to 210 | // make the proxied object support the full method set of the proxied object. 211 | type http2FancyWriter struct { 212 | basicWriter 213 | } 214 | 215 | func (f *http2FancyWriter) Flush() { 216 | f.wroteHeader = true 217 | fl := f.basicWriter.ResponseWriter.(http.Flusher) 218 | fl.Flush() 219 | } 220 | 221 | var _ http.Flusher = &http2FancyWriter{} 222 | -------------------------------------------------------------------------------- /api/middleware/wrap_writer_test.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http/httptest" 5 | "testing" 6 | ) 7 | 8 | func TestHttpFancyWriterRemembersWroteHeaderWhenFlushed(t *testing.T) { 9 | f := &httpFancyWriter{basicWriter: basicWriter{ResponseWriter: httptest.NewRecorder()}} 10 | f.Flush() 11 | 12 | if !f.wroteHeader { 13 | t.Fatal("want Flush to have set wroteHeader=true") 14 | } 15 | } 16 | 17 | func TestHttp2FancyWriterRemembersWroteHeaderWhenFlushed(t *testing.T) { 18 | f := &http2FancyWriter{basicWriter{ResponseWriter: httptest.NewRecorder()}} 19 | f.Flush() 20 | 21 | if !f.wroteHeader { 22 | t.Fatal("want Flush to have set wroteHeader=true") 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /api/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "github.com/AvengeMedia/dgop/config" 5 | "github.com/AvengeMedia/dgop/gops" 6 | ) 7 | 8 | // EmptyInput can be used when no input is needed. 9 | type EmptyInput struct{} 10 | 11 | // DeletedResponse is used to return a deleted response. 12 | type DeletedResponse struct { 13 | ID string `json:"id"` 14 | Deleted bool `json:"deleted"` 15 | } 16 | 17 | // Server implements generated.ServerInterface 18 | type Server struct { 19 | Cfg *config.Config 20 | Gops *gops.GopsUtil 21 | } 22 | -------------------------------------------------------------------------------- /cmd/cli/commands.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/AvengeMedia/dgop/gops" 9 | "github.com/AvengeMedia/dgop/models" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | var versionCmd = &cobra.Command{ 14 | Use: "version", 15 | Short: "Show version information", 16 | Long: "Display the current version of dankgop.", 17 | Run: func(cmd *cobra.Command, args []string) { 18 | fmt.Printf("dankgop version %s\n", Version) 19 | }, 20 | } 21 | 22 | var allCmd = &cobra.Command{ 23 | Use: "all", 24 | Short: "Get all system metrics", 25 | Long: "Display system information including CPU, memory, disk, network, and process data.", 26 | } 27 | 28 | var cpuCmd = &cobra.Command{ 29 | Use: "cpu", 30 | Short: "Get CPU information", 31 | Long: "Display CPU information including usage percentages and core details.", 32 | } 33 | 34 | var memoryCmd = &cobra.Command{ 35 | Use: "memory", 36 | Short: "Get memory information", 37 | Long: "Display memory usage information including RAM and swap statistics.", 38 | } 39 | 40 | var networkCmd = &cobra.Command{ 41 | Use: "network", 42 | Short: "Get network interface information", 43 | Long: "Display network interface statistics including throughput and connection data.", 44 | } 45 | 46 | var netRateCmd = &cobra.Command{ 47 | Use: "net-rate", 48 | Short: "Get network transfer rates", 49 | Long: "Display network transfer rates with cursor-based sampling for accurate rate calculations.", 50 | } 51 | 52 | var diskRateCmd = &cobra.Command{ 53 | Use: "disk-rate", 54 | Short: "Get disk I/O rates", 55 | Long: "Display disk I/O rates with cursor-based sampling for accurate rate calculations.", 56 | } 57 | 58 | var diskCmd = &cobra.Command{ 59 | Use: "disk", 60 | Short: "Get disk information", 61 | Long: "Display disk usage statistics and mount information.", 62 | } 63 | 64 | var processesCmd = &cobra.Command{ 65 | Use: "processes", 66 | Short: "Get running processes", 67 | Long: "Display information about running processes with sorting and filtering options.", 68 | } 69 | 70 | var systemCmd = &cobra.Command{ 71 | Use: "system", 72 | Short: "Get general system information", 73 | Long: "Display general system information including load averages and boot time.", 74 | } 75 | 76 | var hardwareCmd = &cobra.Command{ 77 | Use: "hardware", 78 | Short: "Get hardware information", 79 | Long: "Display system hardware information including BIOS, motherboard, and CPU data.", 80 | } 81 | 82 | var gpuCmd = &cobra.Command{ 83 | Use: "gpu", 84 | Short: "Get GPU information", 85 | Long: "Display information about GPUs and graphics cards.", 86 | } 87 | 88 | var gpuTempCmd = &cobra.Command{ 89 | Use: "gpu-temp", 90 | Short: "Get GPU temperature", 91 | Long: "Get temperature for a specific GPU by PCI ID (e.g., --pci-id 10de:2684).", 92 | } 93 | 94 | var metaCmd = &cobra.Command{ 95 | Use: "meta", 96 | Short: "Get dynamic system metrics", 97 | Long: "Display system metrics for specified modules (e.g., --modules cpu,memory,network).", 98 | } 99 | 100 | var modulesCmd = &cobra.Command{ 101 | Use: "modules", 102 | Short: "List available modules", 103 | Long: "Display all available modules for the meta command.", 104 | } 105 | 106 | var topCmd = &cobra.Command{ 107 | Use: "top", 108 | Short: "Launch interactive system monitor", 109 | Long: "Launch an interactive system monitor for real-time system monitoring.", 110 | } 111 | 112 | func runAllCommand(gopsUtil *gops.GopsUtil) error { 113 | enableCPU := !disableProcCPU 114 | sortBy := parseProcessSortBy(procSortBy, disableProcCPU) 115 | 116 | metrics, err := gopsUtil.GetAllMetricsWithCursors(sortBy, procLimit, enableCPU, cpuCursor, procCursor) 117 | if err != nil { 118 | return fmt.Errorf("failed to get system metrics: %w", err) 119 | } 120 | 121 | if jsonOutput { 122 | return outputJSON(metrics) 123 | } 124 | 125 | displayAllMetrics(metrics) 126 | return nil 127 | } 128 | 129 | func runCpuCommand(gopsUtil *gops.GopsUtil) error { 130 | cpuInfo, err := gopsUtil.GetCPUInfoWithCursor(cpuCursor) 131 | if err != nil { 132 | return fmt.Errorf("failed to get CPU info: %w", err) 133 | } 134 | 135 | if jsonOutput { 136 | return outputJSON(cpuInfo) 137 | } 138 | 139 | displayCPUInfo(cpuInfo) 140 | return nil 141 | } 142 | 143 | func runMemoryCommand(gopsUtil *gops.GopsUtil) error { 144 | memInfo, err := gopsUtil.GetMemoryInfo() 145 | if err != nil { 146 | return fmt.Errorf("failed to get memory info: %w", err) 147 | } 148 | 149 | if jsonOutput { 150 | return outputJSON(memInfo) 151 | } 152 | 153 | displayMemoryInfo(memInfo) 154 | return nil 155 | } 156 | 157 | func runNetworkCommand(gopsUtil *gops.GopsUtil) error { 158 | networkInfo, err := gopsUtil.GetNetworkInfo() 159 | if err != nil { 160 | return fmt.Errorf("failed to get network info: %w", err) 161 | } 162 | 163 | if jsonOutput { 164 | return outputJSON(networkInfo) 165 | } 166 | 167 | displayNetworkInfo(networkInfo) 168 | return nil 169 | } 170 | 171 | func runDiskCommand(gopsUtil *gops.GopsUtil) error { 172 | diskInfo, err := gopsUtil.GetDiskInfo() 173 | if err != nil { 174 | return fmt.Errorf("failed to get disk info: %w", err) 175 | } 176 | 177 | diskMounts, err := gopsUtil.GetDiskMounts() 178 | if err != nil { 179 | return fmt.Errorf("failed to get disk mounts: %w", err) 180 | } 181 | 182 | if jsonOutput { 183 | data := struct { 184 | Disk []*models.DiskInfo `json:"disk"` 185 | Mounts []*models.DiskMountInfo `json:"mounts"` 186 | }{ 187 | Disk: diskInfo, 188 | Mounts: diskMounts, 189 | } 190 | return outputJSON(data) 191 | } 192 | 193 | displayDiskInfo(diskInfo, diskMounts) 194 | return nil 195 | } 196 | 197 | func runProcessesCommand(gopsUtil *gops.GopsUtil) error { 198 | enableCPU := !disableProcCPU 199 | sortBy := parseProcessSortBy(procSortBy, disableProcCPU) 200 | 201 | result, err := gopsUtil.GetProcessesWithCursor(sortBy, procLimit, enableCPU, procCursor) 202 | if err != nil { 203 | return fmt.Errorf("failed to get processes: %w", err) 204 | } 205 | 206 | if jsonOutput { 207 | return outputJSON(result) 208 | } 209 | 210 | displayProcesses(result.Processes) 211 | return nil 212 | } 213 | 214 | func runSystemCommand(gopsUtil *gops.GopsUtil) error { 215 | systemInfo, err := gopsUtil.GetSystemInfo() 216 | if err != nil { 217 | return fmt.Errorf("failed to get system info: %w", err) 218 | } 219 | 220 | if jsonOutput { 221 | return outputJSON(systemInfo) 222 | } 223 | 224 | displaySystemInfo(systemInfo) 225 | return nil 226 | } 227 | 228 | func runHardwareCommand(gopsUtil *gops.GopsUtil) error { 229 | hardwareInfo, err := gopsUtil.GetSystemHardware() 230 | if err != nil { 231 | return fmt.Errorf("failed to get hardware info: %w", err) 232 | } 233 | 234 | if jsonOutput { 235 | return outputJSON(hardwareInfo) 236 | } 237 | 238 | displayHardwareInfo(hardwareInfo) 239 | return nil 240 | } 241 | 242 | func runGPUCommand(gopsUtil *gops.GopsUtil) error { 243 | gpuInfo, err := gopsUtil.GetGPUInfo() 244 | if err != nil { 245 | return fmt.Errorf("failed to get GPU info: %w", err) 246 | } 247 | 248 | if jsonOutput { 249 | return outputJSON(gpuInfo) 250 | } 251 | 252 | displayGPUInfo(gpuInfo) 253 | return nil 254 | } 255 | 256 | func runGPUTempCommand(gopsUtil *gops.GopsUtil) error { 257 | gpuTempInfo, err := gopsUtil.GetGPUTemp(gpuPciId) 258 | if err != nil { 259 | return fmt.Errorf("failed to get GPU temperature: %w", err) 260 | } 261 | 262 | if jsonOutput { 263 | return outputJSON(gpuTempInfo) 264 | } 265 | 266 | displayGPUTempInfo(gpuTempInfo) 267 | return nil 268 | } 269 | 270 | func runMetaCommand(gopsUtil *gops.GopsUtil) error { 271 | params := gops.MetaParams{ 272 | SortBy: parseProcessSortBy(procSortBy, disableProcCPU), 273 | ProcLimit: procLimit, 274 | EnableCPU: !disableProcCPU, 275 | GPUPciIds: metaGPUPciIds, 276 | CPUCursor: cpuCursor, 277 | ProcCursor: procCursor, 278 | NetRateCursor: netRateCursor, 279 | DiskRateCursor: diskRateCursor, 280 | } 281 | 282 | metaInfo, err := gopsUtil.GetMeta(metaModules, params) 283 | if err != nil { 284 | return fmt.Errorf("failed to get meta info: %w", err) 285 | } 286 | 287 | if jsonOutput { 288 | return outputJSON(metaInfo) 289 | } 290 | 291 | displayMetaInfo(metaInfo) 292 | return nil 293 | } 294 | 295 | func runModulesCommand(gopsUtil *gops.GopsUtil) error { 296 | modulesInfo, err := gopsUtil.GetModules() 297 | if err != nil { 298 | return fmt.Errorf("failed to get modules info: %w", err) 299 | } 300 | 301 | if jsonOutput { 302 | return outputJSON(modulesInfo) 303 | } 304 | 305 | displayModulesInfo(modulesInfo) 306 | return nil 307 | } 308 | 309 | // Display functions for pretty printing 310 | 311 | func displayAllMetrics(metrics *models.SystemMetrics) { 312 | fmt.Println(titleStyle.Render("SYSTEM METRICS")) 313 | fmt.Println() 314 | 315 | if metrics.System != nil { 316 | displaySystemInfo(metrics.System) 317 | fmt.Println() 318 | } 319 | 320 | if metrics.CPU != nil { 321 | displayCPUInfo(metrics.CPU) 322 | fmt.Println() 323 | } 324 | 325 | if metrics.Memory != nil { 326 | displayMemoryInfo(metrics.Memory) 327 | fmt.Println() 328 | } 329 | 330 | if len(metrics.Network) > 0 { 331 | displayNetworkInfo(metrics.Network) 332 | fmt.Println() 333 | } 334 | 335 | if len(metrics.Disk) > 0 || len(metrics.DiskMounts) > 0 { 336 | displayDiskInfo(metrics.Disk, metrics.DiskMounts) 337 | fmt.Println() 338 | } 339 | 340 | if len(metrics.Processes) > 0 { 341 | displayProcesses(metrics.Processes) 342 | } 343 | } 344 | 345 | func displaySystemInfo(info *models.SystemInfo) { 346 | fmt.Println(titleStyle.Render("SYSTEM")) 347 | 348 | rows := [][]string{ 349 | {"Load Average:", info.LoadAvg}, 350 | {"Processes:", strconv.Itoa(info.Processes)}, 351 | {"Threads:", strconv.Itoa(info.Threads)}, 352 | {"Boot Time:", info.BootTime}, 353 | } 354 | 355 | printTable(rows) 356 | } 357 | 358 | func displayCPUInfo(cpu *models.CPUInfo) { 359 | fmt.Println(titleStyle.Render("CPU")) 360 | 361 | rows := [][]string{ 362 | {"Count:", strconv.Itoa(cpu.Count)}, 363 | {"Model:", cpu.Model}, 364 | {"Frequency:", fmt.Sprintf("%.2f MHz", cpu.Frequency)}, 365 | {"Temperature:", fmt.Sprintf("%.2f°C", cpu.Temperature)}, 366 | {"Usage:", fmt.Sprintf("%.1f%%", cpu.Usage)}, 367 | } 368 | 369 | if len(cpu.CoreUsage) > 0 { 370 | coreUsageStr := "" 371 | for i, usage := range cpu.CoreUsage { 372 | if i > 0 && i%4 == 0 { 373 | coreUsageStr += "\n " 374 | } 375 | coreUsageStr += fmt.Sprintf("%d: %5.1f%% ", i, usage) 376 | } 377 | rows = append(rows, []string{"Core Usage:", coreUsageStr}) 378 | } 379 | 380 | printTable(rows) 381 | } 382 | 383 | func displayMemoryInfo(mem *models.MemoryInfo) { 384 | fmt.Println(titleStyle.Render("MEMORY")) 385 | 386 | // Values are in KB, convert to GB 387 | totalGB := float64(mem.Total) / 1024 / 1024 388 | usedGB := float64(mem.Total-mem.Available) / 1024 / 1024 389 | availableGB := float64(mem.Available) / 1024 / 1024 390 | freeGB := float64(mem.Free) / 1024 / 1024 391 | usedPercent := float64(mem.Total-mem.Available) / float64(mem.Total) * 100 392 | 393 | rows := [][]string{ 394 | {"Total:", fmt.Sprintf("%.2f GB", totalGB)}, 395 | {"Used:", fmt.Sprintf("%.2f GB (%.1f%%)", usedGB, usedPercent)}, 396 | {"Available:", fmt.Sprintf("%.2f GB", availableGB)}, 397 | {"Free:", fmt.Sprintf("%.2f GB", freeGB)}, 398 | {"Buffers:", fmt.Sprintf("%.2f GB", float64(mem.Buffers)/1024/1024)}, 399 | {"Cached:", fmt.Sprintf("%.2f GB", float64(mem.Cached)/1024/1024)}, 400 | } 401 | 402 | if mem.SwapTotal > 0 { 403 | swapTotalGB := float64(mem.SwapTotal) / 1024 / 1024 404 | swapUsedGB := float64(mem.SwapTotal-mem.SwapFree) / 1024 / 1024 405 | rows = append(rows, []string{"Swap Total:", fmt.Sprintf("%.2f GB", swapTotalGB)}) 406 | rows = append(rows, []string{"Swap Used:", fmt.Sprintf("%.2f GB", swapUsedGB)}) 407 | } 408 | 409 | printTable(rows) 410 | } 411 | 412 | func displayNetworkInfo(interfaces []*models.NetworkInfo) { 413 | fmt.Println(titleStyle.Render("NETWORK")) 414 | 415 | for i, iface := range interfaces { 416 | if i > 0 { 417 | fmt.Println() 418 | } 419 | 420 | fmt.Println(keyStyle.Render(fmt.Sprintf("Interface: %s", iface.Name))) 421 | 422 | rows := [][]string{ 423 | {"Bytes Received:", formatBytes(iface.Rx)}, 424 | {"Bytes Sent:", formatBytes(iface.Tx)}, 425 | } 426 | 427 | printTable(rows) 428 | } 429 | } 430 | 431 | func displayDiskInfo(disks []*models.DiskInfo, mounts []*models.DiskMountInfo) { 432 | fmt.Println(titleStyle.Render("DISK")) 433 | 434 | if len(disks) > 0 { 435 | fmt.Println(keyStyle.Render("I/O Statistics:")) 436 | for i, disk := range disks { 437 | if i > 0 { 438 | fmt.Println() 439 | } 440 | 441 | fmt.Println(keyStyle.Render(fmt.Sprintf("Device: %s", disk.Name))) 442 | 443 | rows := [][]string{ 444 | {"Read:", formatBytes(disk.Read)}, 445 | {"Write:", formatBytes(disk.Write)}, 446 | } 447 | 448 | printTable(rows) 449 | } 450 | } 451 | 452 | if len(mounts) > 0 { 453 | fmt.Println() 454 | fmt.Println(keyStyle.Render("Mount Points:")) 455 | 456 | for _, mount := range mounts { 457 | fmt.Printf(" %s → %s (%s) [%s used, %s available]\n", 458 | valueStyle.Render(mount.Device), 459 | valueStyle.Render(mount.Mount), 460 | valueStyle.Render(mount.FSType), 461 | valueStyle.Render(mount.Used+" ("+mount.Percent+")"), 462 | valueStyle.Render(mount.Avail)) 463 | } 464 | } 465 | } 466 | 467 | func displayProcesses(processes []*models.ProcessInfo) { 468 | fmt.Println(titleStyle.Render(fmt.Sprintf("PROCESSES (%d)", len(processes)))) 469 | 470 | // Header 471 | header := fmt.Sprintf("%-8s %-8s %-20s %-8s %-8s %s", 472 | "PID", "PPID", "COMMAND", "CPU%", "MEM%", "FULL COMMAND") 473 | fmt.Println(keyStyle.Render(header)) 474 | fmt.Println(strings.Repeat("─", 80)) 475 | 476 | for _, proc := range processes { 477 | row := fmt.Sprintf("%-8d %-8d %-20s %-8.1f %-8.1f %s", 478 | proc.PID, 479 | proc.PPID, 480 | truncateString(proc.Command, 20), 481 | proc.CPU, 482 | proc.MemoryPercent, 483 | truncateString(proc.FullCommand, 30)) 484 | fmt.Println(valueStyle.Render(row)) 485 | } 486 | } 487 | 488 | // Helper functions 489 | 490 | func printTable(rows [][]string) { 491 | for _, row := range rows { 492 | if len(row) >= 2 { 493 | fmt.Printf(" %s %s\n", keyStyle.Render(row[0]), valueStyle.Render(row[1])) 494 | } 495 | } 496 | } 497 | 498 | func formatBytes(bytes uint64) string { 499 | const unit = 1024 500 | if bytes < unit { 501 | return fmt.Sprintf("%d B", bytes) 502 | } 503 | div, exp := int64(unit), 0 504 | for n := bytes / unit; n >= unit; n /= unit { 505 | div *= unit 506 | exp++ 507 | } 508 | return fmt.Sprintf("%.2f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) 509 | } 510 | 511 | func truncateString(s string, maxLen int) string { 512 | if len(s) <= maxLen { 513 | return s 514 | } 515 | return s[:maxLen-3] + "..." 516 | } 517 | 518 | func displayHardwareInfo(hardware *models.SystemHardware) { 519 | fmt.Println(titleStyle.Render("HARDWARE")) 520 | 521 | rows := [][]string{ 522 | {"Kernel:", hardware.Kernel}, 523 | {"Distro:", hardware.Distro}, 524 | {"Hostname:", hardware.Hostname}, 525 | {"Architecture:", hardware.Arch}, 526 | {"CPU Count:", strconv.Itoa(hardware.CPU.Count)}, 527 | {"CPU Model:", hardware.CPU.Model}, 528 | {"Motherboard:", hardware.BIOS.Motherboard}, 529 | {"BIOS Version:", hardware.BIOS.Version}, 530 | {"BIOS Date:", hardware.BIOS.Date}, 531 | } 532 | 533 | printTable(rows) 534 | } 535 | 536 | func displayGPUInfo(gpuInfo *models.GPUInfo) { 537 | fmt.Println(titleStyle.Render("GPU")) 538 | 539 | if len(gpuInfo.GPUs) == 0 { 540 | fmt.Println(valueStyle.Render(" No GPUs detected")) 541 | return 542 | } 543 | 544 | for i, gpu := range gpuInfo.GPUs { 545 | if i > 0 { 546 | fmt.Println() 547 | } 548 | 549 | fmt.Println(keyStyle.Render(fmt.Sprintf("GPU %d:", i+1))) 550 | 551 | rows := [][]string{ 552 | {"Vendor:", gpu.Vendor}, 553 | {"Driver:", gpu.Driver}, 554 | {"Name:", gpu.DisplayName}, 555 | {"Full Name:", gpu.FullName}, 556 | {"PCI ID:", gpu.PciId}, 557 | {"Temperature:", fmt.Sprintf("%.1f°C", gpu.Temperature)}, 558 | } 559 | 560 | printTable(rows) 561 | } 562 | } 563 | 564 | func displayMetaInfo(meta *models.MetaInfo) { 565 | fmt.Println(titleStyle.Render("META METRICS")) 566 | fmt.Println() 567 | 568 | if meta.CPU != nil { 569 | displayCPUInfo(meta.CPU) 570 | fmt.Println() 571 | } 572 | 573 | if meta.Memory != nil { 574 | displayMemoryInfo(meta.Memory) 575 | fmt.Println() 576 | } 577 | 578 | if meta.System != nil { 579 | displaySystemInfo(meta.System) 580 | fmt.Println() 581 | } 582 | 583 | if meta.Hardware != nil { 584 | displayHardwareInfo(meta.Hardware) 585 | fmt.Println() 586 | } 587 | 588 | if meta.GPU != nil { 589 | displayGPUInfo(meta.GPU) 590 | fmt.Println() 591 | } 592 | 593 | if len(meta.Network) > 0 { 594 | displayNetworkInfo(meta.Network) 595 | fmt.Println() 596 | } 597 | 598 | if meta.NetRate != nil { 599 | displayNetworkRates(meta.NetRate) 600 | fmt.Println() 601 | } 602 | 603 | if len(meta.Disk) > 0 || len(meta.DiskMounts) > 0 { 604 | displayDiskInfo(meta.Disk, meta.DiskMounts) 605 | fmt.Println() 606 | } 607 | 608 | if meta.DiskRate != nil { 609 | displayDiskRates(meta.DiskRate) 610 | fmt.Println() 611 | } 612 | 613 | if len(meta.Processes) > 0 { 614 | displayProcesses(meta.Processes) 615 | } 616 | } 617 | 618 | func displayModulesInfo(modules *models.ModulesInfo) { 619 | fmt.Println(titleStyle.Render("AVAILABLE MODULES")) 620 | 621 | for i, module := range modules.Available { 622 | fmt.Printf(" %s%s", valueStyle.Render(module), keyStyle.Render(", ")) 623 | if (i+1)%5 == 0 { 624 | fmt.Println() 625 | } 626 | } 627 | fmt.Println() 628 | } 629 | 630 | func displayGPUTempInfo(gpuTemp *models.GPUTempInfo) { 631 | fmt.Println(titleStyle.Render("GPU TEMPERATURE")) 632 | 633 | rows := [][]string{ 634 | {"Driver:", gpuTemp.Driver}, 635 | {"Hwmon:", gpuTemp.Hwmon}, 636 | {"Temperature:", fmt.Sprintf("%.1f°C", gpuTemp.Temperature)}, 637 | } 638 | 639 | printTable(rows) 640 | } 641 | 642 | func displayNetworkRates(netRates *models.NetworkRateResponse) { 643 | fmt.Println(titleStyle.Render("NETWORK RATES")) 644 | 645 | if len(netRates.Interfaces) == 0 { 646 | fmt.Println(valueStyle.Render(" No network interfaces found")) 647 | return 648 | } 649 | 650 | for i, iface := range netRates.Interfaces { 651 | if i > 0 { 652 | fmt.Println() 653 | } 654 | 655 | fmt.Println(keyStyle.Render(fmt.Sprintf("Interface: %s", iface.Interface))) 656 | 657 | rows := [][]string{ 658 | {"RX Rate:", formatRate(iface.RxRate)}, 659 | {"TX Rate:", formatRate(iface.TxRate)}, 660 | {"RX Total:", formatBytes(iface.RxTotal)}, 661 | {"TX Total:", formatBytes(iface.TxTotal)}, 662 | } 663 | 664 | printTable(rows) 665 | } 666 | 667 | fmt.Printf("\nCursor: %s\n", netRates.Cursor) 668 | } 669 | 670 | func displayDiskRates(diskRates *models.DiskRateResponse) { 671 | fmt.Println(titleStyle.Render("DISK I/O RATES")) 672 | 673 | if len(diskRates.Disks) == 0 { 674 | fmt.Println(valueStyle.Render(" No disk devices found")) 675 | return 676 | } 677 | 678 | for i, disk := range diskRates.Disks { 679 | if i > 0 { 680 | fmt.Println() 681 | } 682 | 683 | fmt.Println(keyStyle.Render(fmt.Sprintf("Device: %s", disk.Device))) 684 | 685 | rows := [][]string{ 686 | {"Read Rate:", formatRate(disk.ReadRate)}, 687 | {"Write Rate:", formatRate(disk.WriteRate)}, 688 | {"Read Total:", formatBytes(disk.ReadTotal)}, 689 | {"Write Total:", formatBytes(disk.WriteTotal)}, 690 | {"Read Count:", fmt.Sprintf("%d", disk.ReadCount)}, 691 | {"Write Count:", fmt.Sprintf("%d", disk.WriteCount)}, 692 | } 693 | 694 | printTable(rows) 695 | } 696 | 697 | fmt.Printf("\nCursor: %s\n", diskRates.Cursor) 698 | } 699 | 700 | func formatRate(bytesPerSecond float64) string { 701 | return fmt.Sprintf("%s/s", formatBytesFloat(bytesPerSecond)) 702 | } 703 | 704 | func formatBytesFloat(bytes float64) string { 705 | const unit = 1024 706 | if bytes < unit { 707 | return fmt.Sprintf("%.2f B", bytes) 708 | } 709 | div, exp := float64(unit), 0 710 | for n := bytes / unit; n >= unit; n /= unit { 711 | div *= unit 712 | exp++ 713 | } 714 | return fmt.Sprintf("%.2f %cB", bytes/div, "KMGTPE"[exp]) 715 | } 716 | 717 | func runNetRateCommand(gopsUtil *gops.GopsUtil) error { 718 | netRateInfo, err := gopsUtil.GetNetworkRates(netRateCursor) 719 | if err != nil { 720 | return fmt.Errorf("failed to get network rates: %w", err) 721 | } 722 | 723 | if jsonOutput { 724 | return outputJSON(netRateInfo) 725 | } 726 | 727 | displayNetworkRates(netRateInfo) 728 | return nil 729 | } 730 | 731 | func runDiskRateCommand(gopsUtil *gops.GopsUtil) error { 732 | diskRateInfo, err := gopsUtil.GetDiskRates(diskRateCursor) 733 | if err != nil { 734 | return fmt.Errorf("failed to get disk rates: %w", err) 735 | } 736 | 737 | if jsonOutput { 738 | return outputJSON(diskRateInfo) 739 | } 740 | 741 | displayDiskRates(diskRateInfo) 742 | return nil 743 | } 744 | 745 | func runTopCommand(gopsUtil *gops.GopsUtil) error { 746 | return runTUI(gopsUtil) 747 | } 748 | -------------------------------------------------------------------------------- /cmd/cli/help.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/AvengeMedia/dgop/gops" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | var helpCmd = &cobra.Command{ 9 | Use: "help", 10 | Short: "Show available commands", 11 | Long: "Display all available dankgop commands and options.", 12 | Run: func(cmd *cobra.Command, args []string) { 13 | printHeader() 14 | rootCmd.Usage() 15 | }, 16 | } 17 | 18 | func runHelpCommand(gopsUtil *gops.GopsUtil) error { 19 | printHeader() 20 | rootCmd.Usage() 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /cmd/cli/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/AvengeMedia/dgop/gops" 9 | "github.com/charmbracelet/lipgloss" 10 | "github.com/charmbracelet/log" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | var ( 15 | Version = "dev" 16 | jsonOutput bool 17 | procSortBy string 18 | procLimit int 19 | disableProcCPU bool 20 | metaModules []string 21 | gpuPciId string 22 | metaGPUPciIds []string 23 | cpuCursor string 24 | procCursor string 25 | netRateCursor string 26 | diskRateCursor string 27 | ) 28 | 29 | var style = lipgloss.NewStyle(). 30 | Bold(true). 31 | Foreground(lipgloss.Color("#7C7C7C")). 32 | MarginLeft(1). 33 | MarginRight(1) 34 | 35 | var titleStyle = lipgloss.NewStyle(). 36 | Bold(true). 37 | Foreground(lipgloss.Color("#FAFAFA")). 38 | Background(lipgloss.Color("#7D56F4")). 39 | PaddingTop(0). 40 | PaddingLeft(1). 41 | PaddingRight(1) 42 | 43 | var keyStyle = lipgloss.NewStyle(). 44 | Bold(true). 45 | Foreground(lipgloss.Color("#FAFAFA")) 46 | 47 | var valueStyle = lipgloss.NewStyle(). 48 | Foreground(lipgloss.Color("#C9C9C9")) 49 | 50 | var headerStyle = lipgloss.NewStyle(). 51 | Bold(true). 52 | Foreground(lipgloss.Color("#8B5FBF")) 53 | 54 | func printHeader() { 55 | header := ` 56 | ██████╗ █████╗ ███╗ ██╗██╗ ██╗ 57 | ██╔══██╗██╔══██╗████╗ ██║██║ ██╔╝ 58 | ██║ ██║███████║██╔██╗ ██║█████╔╝ 59 | ██║ ██║██╔══██║██║╚██╗██║██╔═██╗ 60 | ██████╔╝██║ ██║██║ ╚████║██║ ██╗ 61 | ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝` 62 | fmt.Println(headerStyle.Render(header)) 63 | } 64 | 65 | func init() { 66 | rootCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") 67 | rootCmd.PersistentFlags().BoolVar(&disableProcCPU, "no-cpu", false, "Disable CPU calculation for faster process listing") 68 | 69 | allCmd.Flags().StringVar(&procSortBy, "sort", "cpu", "Sort processes by (cpu, memory, name, pid)") 70 | allCmd.Flags().IntVar(&procLimit, "limit", 0, "Limit number of processes (0 = no limit)") 71 | allCmd.Flags().StringVar(&cpuCursor, "cpu-cursor", "", "CPU cursor from previous request") 72 | allCmd.Flags().StringVar(&procCursor, "proc-cursor", "", "Process cursor from previous request") 73 | 74 | cpuCmd.Flags().StringVar(&cpuCursor, "cursor", "", "Cursor from previous CPU request") 75 | 76 | netRateCmd.Flags().StringVar(&netRateCursor, "cursor", "", "Cursor from previous network rate request") 77 | 78 | diskRateCmd.Flags().StringVar(&diskRateCursor, "cursor", "", "Cursor from previous disk rate request") 79 | 80 | processesCmd.Flags().StringVar(&procSortBy, "sort", "cpu", "Sort processes by (cpu, memory, name, pid)") 81 | processesCmd.Flags().IntVar(&procLimit, "limit", 0, "Limit number of processes (0 = no limit)") 82 | processesCmd.Flags().StringVar(&procCursor, "cursor", "", "Cursor from previous process request") 83 | 84 | metaCmd.Flags().StringSliceVar(&metaModules, "modules", []string{"all"}, "Modules to include (cpu,memory,network,etc)") 85 | metaCmd.Flags().StringVar(&procSortBy, "sort", "cpu", "Sort processes by (cpu, memory, name, pid)") 86 | metaCmd.Flags().IntVar(&procLimit, "limit", 0, "Limit number of processes (0 = no limit)") 87 | metaCmd.Flags().StringSliceVar(&metaGPUPciIds, "gpu-pci-ids", []string{}, "PCI IDs for GPU temperatures (e.g., 10de:2684,1002:164e)") 88 | metaCmd.Flags().StringVar(&cpuCursor, "cpu-cursor", "", "CPU cursor from previous request") 89 | metaCmd.Flags().StringVar(&procCursor, "proc-cursor", "", "Process cursor from previous request") 90 | metaCmd.Flags().StringVar(&netRateCursor, "net-rate-cursor", "", "Network rate cursor from previous request") 91 | metaCmd.Flags().StringVar(&diskRateCursor, "disk-rate-cursor", "", "Disk rate cursor from previous request") 92 | 93 | gpuTempCmd.Flags().StringVar(&gpuPciId, "pci-id", "", "PCI ID of GPU to get temperature (e.g., 10de:2684)") 94 | gpuTempCmd.MarkFlagRequired("pci-id") 95 | } 96 | 97 | var rootCmd = &cobra.Command{ 98 | Use: "dankgop", 99 | Run: func(cmd *cobra.Command, args []string) { 100 | gopsUtil := gops.NewGopsUtil() 101 | runTopCommand(gopsUtil) 102 | }, 103 | } 104 | 105 | func main() { 106 | gopsUtil := gops.NewGopsUtil() 107 | 108 | // Set the gopsUtil in context for commands 109 | rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { 110 | cmd.SetContext(cmd.Context()) 111 | } 112 | 113 | setupCommands(gopsUtil) 114 | 115 | if err := rootCmd.Execute(); err != nil { 116 | log.Error("Command execution failed", "error", err) 117 | os.Exit(1) 118 | } 119 | } 120 | 121 | func setupCommands(gopsUtil *gops.GopsUtil) { 122 | rootCmd.AddCommand(helpCmd) 123 | rootCmd.AddCommand(versionCmd) 124 | rootCmd.AddCommand(allCmd) 125 | rootCmd.AddCommand(cpuCmd) 126 | rootCmd.AddCommand(memoryCmd) 127 | rootCmd.AddCommand(networkCmd) 128 | rootCmd.AddCommand(diskCmd) 129 | rootCmd.AddCommand(processesCmd) 130 | rootCmd.AddCommand(systemCmd) 131 | rootCmd.AddCommand(hardwareCmd) 132 | rootCmd.AddCommand(gpuCmd) 133 | rootCmd.AddCommand(gpuTempCmd) 134 | rootCmd.AddCommand(metaCmd) 135 | rootCmd.AddCommand(modulesCmd) 136 | rootCmd.AddCommand(netRateCmd) 137 | rootCmd.AddCommand(diskRateCmd) 138 | rootCmd.AddCommand(topCmd) 139 | rootCmd.AddCommand(serverCmd) 140 | 141 | // Set gopsUtil for all commands 142 | allCmd.RunE = func(cmd *cobra.Command, args []string) error { 143 | return runAllCommand(gopsUtil) 144 | } 145 | 146 | cpuCmd.RunE = func(cmd *cobra.Command, args []string) error { 147 | return runCpuCommand(gopsUtil) 148 | } 149 | 150 | memoryCmd.RunE = func(cmd *cobra.Command, args []string) error { 151 | return runMemoryCommand(gopsUtil) 152 | } 153 | 154 | networkCmd.RunE = func(cmd *cobra.Command, args []string) error { 155 | return runNetworkCommand(gopsUtil) 156 | } 157 | 158 | netRateCmd.RunE = func(cmd *cobra.Command, args []string) error { 159 | return runNetRateCommand(gopsUtil) 160 | } 161 | 162 | diskRateCmd.RunE = func(cmd *cobra.Command, args []string) error { 163 | return runDiskRateCommand(gopsUtil) 164 | } 165 | 166 | diskCmd.RunE = func(cmd *cobra.Command, args []string) error { 167 | return runDiskCommand(gopsUtil) 168 | } 169 | 170 | processesCmd.RunE = func(cmd *cobra.Command, args []string) error { 171 | return runProcessesCommand(gopsUtil) 172 | } 173 | 174 | systemCmd.RunE = func(cmd *cobra.Command, args []string) error { 175 | return runSystemCommand(gopsUtil) 176 | } 177 | 178 | hardwareCmd.RunE = func(cmd *cobra.Command, args []string) error { 179 | return runHardwareCommand(gopsUtil) 180 | } 181 | 182 | gpuCmd.RunE = func(cmd *cobra.Command, args []string) error { 183 | return runGPUCommand(gopsUtil) 184 | } 185 | 186 | gpuTempCmd.RunE = func(cmd *cobra.Command, args []string) error { 187 | return runGPUTempCommand(gopsUtil) 188 | } 189 | 190 | metaCmd.RunE = func(cmd *cobra.Command, args []string) error { 191 | return runMetaCommand(gopsUtil) 192 | } 193 | 194 | modulesCmd.RunE = func(cmd *cobra.Command, args []string) error { 195 | return runModulesCommand(gopsUtil) 196 | } 197 | 198 | topCmd.RunE = func(cmd *cobra.Command, args []string) error { 199 | return runTopCommand(gopsUtil) 200 | } 201 | 202 | helpCmd.RunE = func(cmd *cobra.Command, args []string) error { 203 | return runHelpCommand(gopsUtil) 204 | } 205 | } 206 | 207 | func outputJSON(data any) error { 208 | jsonData, err := json.Marshal(data) 209 | if err != nil { 210 | return err 211 | } 212 | fmt.Println(string(jsonData)) 213 | return nil 214 | } 215 | 216 | func parseProcessSortBy(sortBy string, cpuDisabled bool) gops.ProcSortBy { 217 | // If CPU is disabled and user chose CPU sort, default to memory 218 | if cpuDisabled && sortBy == "cpu" { 219 | return gops.SortByMemory 220 | } 221 | 222 | switch sortBy { 223 | case "cpu": 224 | return gops.SortByCPU 225 | case "memory": 226 | return gops.SortByMemory 227 | case "name": 228 | return gops.SortByName 229 | case "pid": 230 | return gops.SortByPID 231 | default: 232 | // Default behavior: CPU if enabled, memory if CPU disabled 233 | if cpuDisabled { 234 | return gops.SortByMemory 235 | } 236 | return gops.SortByCPU 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /cmd/cli/server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "net/http" 7 | "net/url" 8 | "os" 9 | "os/signal" 10 | "syscall" 11 | "time" 12 | 13 | gops_handler "github.com/AvengeMedia/dgop/api/gops" 14 | "github.com/AvengeMedia/dgop/api/middleware" 15 | "github.com/AvengeMedia/dgop/api/server" 16 | "github.com/AvengeMedia/dgop/config" 17 | "github.com/AvengeMedia/dgop/errdefs" 18 | "github.com/AvengeMedia/dgop/gops" 19 | "github.com/charmbracelet/log" 20 | "github.com/danielgtaylor/huma/v2" 21 | "github.com/danielgtaylor/huma/v2/adapters/humachi" 22 | "github.com/go-chi/chi/v5" 23 | "github.com/gorilla/schema" 24 | "github.com/spf13/cobra" 25 | "golang.org/x/net/http2" 26 | "golang.org/x/net/http2/h2c" 27 | ) 28 | 29 | var serverCmd = &cobra.Command{ 30 | Use: "server", 31 | Short: "Start the API server", 32 | Long: "Start the REST API server to provide system metrics endpoints.", 33 | RunE: runServerCommand, 34 | } 35 | 36 | // Adding a format for form data 37 | var decoder = schema.NewDecoder() 38 | var urlEncodedFormat = huma.Format{ 39 | Marshal: nil, 40 | Unmarshal: func(data []byte, v any) error { 41 | values, err := url.ParseQuery(string(data)) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | // WARNING: Dirty workaround! 47 | // During validation, Huma first parses the body into []any, map[string]any or equivalent for easy validation, 48 | // before parsing it into the target struct. 49 | // However, gorilla/schema requires a struct for decoding, so we need to map `url.Values` to a 50 | // `map[string]any` if this happens. 51 | // See: https://github.com/danielgtaylor/huma/blob/main/huma.go#L1264 52 | if vPtr, ok := v.(*interface{}); ok { 53 | m := map[string]any{} 54 | for k, v := range values { 55 | if len(v) > 1 { 56 | m[k] = v 57 | } else if len(v) == 1 { 58 | m[k] = v[0] 59 | } 60 | } 61 | *vPtr = m 62 | return nil 63 | } 64 | 65 | // `v` is a struct, try decode normally 66 | return decoder.Decode(v, values) 67 | }, 68 | } 69 | 70 | func NewHumaConfig(title, version string) huma.Config { 71 | schemaPrefix := "#/components/schemas/" 72 | schemasPath := "/schemas" 73 | 74 | registry := huma.NewMapRegistry(schemaPrefix, huma.DefaultSchemaNamer) 75 | 76 | cfg := huma.Config{ 77 | OpenAPI: &huma.OpenAPI{ 78 | OpenAPI: "3.1.0", 79 | Info: &huma.Info{ 80 | Title: title, 81 | Version: version, 82 | }, 83 | Components: &huma.Components{ 84 | Schemas: registry, 85 | }, 86 | }, 87 | OpenAPIPath: "/openapi", 88 | DocsPath: "/docs", 89 | SchemasPath: schemasPath, 90 | Formats: huma.DefaultFormats, 91 | DefaultFormat: "application/json", 92 | } 93 | cfg.Formats["application/x-www-form-urlencoded"] = urlEncodedFormat 94 | cfg.Formats["x-www-form-urlencoded"] = urlEncodedFormat 95 | 96 | return cfg 97 | } 98 | 99 | func runServerCommand(cmd *cobra.Command, args []string) error { 100 | cfg := config.NewConfig() 101 | return startAPI(cfg) 102 | } 103 | 104 | func startAPI(cfg *config.Config) error { 105 | // Create a context with cancellation 106 | ctx, cancel := context.WithCancel(context.Background()) 107 | defer cancel() 108 | 109 | // Set up signal handling 110 | signalCh := make(chan os.Signal, 1) 111 | signal.Notify(signalCh, syscall.SIGINT, syscall.SIGTERM) 112 | go func() { 113 | sig := <-signalCh 114 | slog.Info("Received shutdown signal", "signal", sig) 115 | cancel() // This will propagate cancellation to all derived contexts 116 | }() 117 | 118 | // Implementation 119 | srvImpl := &server.Server{ 120 | Cfg: cfg, 121 | Gops: gops.NewGopsUtil(), 122 | } 123 | 124 | // New chi router 125 | r := chi.NewRouter() 126 | 127 | r.Get("/health", func(w http.ResponseWriter, r *http.Request) { 128 | w.WriteHeader(http.StatusOK) 129 | w.Write([]byte("OK")) 130 | }) 131 | 132 | r.Group(func(r chi.Router) { 133 | r.Use(middleware.Logger) 134 | 135 | // Register huma error function 136 | huma.NewError = errdefs.HumaErrorFunc 137 | 138 | config := NewHumaConfig("DankGop API", "1.0.0") 139 | config.DocsPath = "" 140 | api := humachi.New(r, config) 141 | 142 | // Create middleware 143 | mw := middleware.NewMiddleware(cfg, api) 144 | 145 | api.UseMiddleware(mw.Recoverer) 146 | 147 | r.Get("/docs", func(w http.ResponseWriter, r *http.Request) { 148 | w.Header().Set("Content-Type", "text/html") 149 | w.Write([]byte(` 150 | 151 | 152 | API Reference 153 | 154 | 157 | 158 | 159 | 162 | 163 | 164 | `)) 165 | }) 166 | 167 | // /gops group 168 | gopsGroup := huma.NewGroup(api, "/gops") 169 | gopsGroup.UseModifier(func(op *huma.Operation, next func(*huma.Operation)) { 170 | op.Tags = []string{"Gops"} 171 | next(op) 172 | }) 173 | gops_handler.RegisterHandlers(srvImpl, gopsGroup) 174 | }) 175 | 176 | // Start the server 177 | addr := ":63484" 178 | log.Infof(" Starting DankGop API server on %s", addr) 179 | log.Infof(" API Documentation: http://localhost%s/docs", addr) 180 | log.Infof(" OpenAPI Spec: http://localhost%s/openapi.json", addr) 181 | log.Infof(" Health Check: http://localhost%s/health", addr) 182 | 183 | h2s := &http2.Server{} 184 | 185 | httpServer := &http.Server{ 186 | Addr: addr, 187 | Handler: h2c.NewHandler(r, h2s), 188 | } 189 | 190 | // Start the server in a goroutine 191 | go func() { 192 | if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { 193 | log.Fatalf("Server error: %v", err) 194 | } 195 | }() 196 | 197 | // Wait for context cancellation (from signal handler) 198 | <-ctx.Done() 199 | log.Info("Shutting down server...") 200 | 201 | // Create a shutdown context with timeout 202 | shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) 203 | defer shutdownCancel() 204 | 205 | // Shutdown the HTTP server 206 | if err := httpServer.Shutdown(shutdownCtx); err != nil { 207 | log.Fatalf("Server shutdown error: %v", err) 208 | } 209 | 210 | log.Info("Server gracefully stopped") 211 | return nil 212 | } 213 | -------------------------------------------------------------------------------- /cmd/cli/tui/charts.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | // This file is intentionally left minimal - chart rendering is handled inline in panels -------------------------------------------------------------------------------- /cmd/cli/tui/data.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "github.com/AvengeMedia/dgop/gops" 5 | "github.com/AvengeMedia/dgop/models" 6 | tea "github.com/charmbracelet/bubbletea" 7 | ) 8 | 9 | type fetchDataMsg struct { 10 | metrics *models.SystemMetrics 11 | err error 12 | } 13 | 14 | type fetchNetworkMsg struct { 15 | rates *models.NetworkRateResponse 16 | err error 17 | } 18 | 19 | type fetchDiskMsg struct { 20 | rates *models.DiskRateResponse 21 | err error 22 | } 23 | 24 | type fetchTempMsg struct { 25 | temps []models.TemperatureSensor 26 | err error 27 | } 28 | 29 | func (m *ResponsiveTUIModel) fetchData() tea.Cmd { 30 | return func() tea.Msg { 31 | params := gops.MetaParams{ 32 | SortBy: m.sortBy, 33 | ProcLimit: m.procLimit, 34 | EnableCPU: true, 35 | } 36 | 37 | modules := []string{"cpu", "memory", "system", "network", "disk", "processes"} 38 | metrics, err := m.gops.GetMeta(modules, params) 39 | 40 | if err != nil { 41 | return fetchDataMsg{err: err} 42 | } 43 | 44 | // Get disk mounts separately since they're not included in meta 45 | diskMounts, err := m.gops.GetDiskMounts() 46 | if err != nil { 47 | // Don't fail completely if disk mounts fail, just log and continue 48 | diskMounts = nil 49 | } 50 | 51 | systemMetrics := &models.SystemMetrics{ 52 | CPU: metrics.CPU, 53 | Memory: metrics.Memory, 54 | System: metrics.System, 55 | Network: metrics.Network, 56 | Disk: metrics.Disk, 57 | DiskMounts: diskMounts, 58 | Processes: metrics.Processes, 59 | } 60 | 61 | return fetchDataMsg{metrics: systemMetrics, err: nil} 62 | } 63 | } 64 | 65 | func (m *ResponsiveTUIModel) fetchNetworkData() tea.Cmd { 66 | return func() tea.Msg { 67 | rates, err := m.gops.GetNetworkRates(m.networkCursor) 68 | return fetchNetworkMsg{rates: rates, err: err} 69 | } 70 | } 71 | 72 | func (m *ResponsiveTUIModel) fetchDiskData() tea.Cmd { 73 | return func() tea.Msg { 74 | rates, err := m.gops.GetDiskRates(m.diskCursor) 75 | return fetchDiskMsg{rates: rates, err: err} 76 | } 77 | } 78 | 79 | func (m *ResponsiveTUIModel) fetchTemperatureData() tea.Cmd { 80 | return func() tea.Msg { 81 | temps, err := m.gops.GetSystemTemperatures() 82 | return fetchTempMsg{temps: temps, err: err} 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /cmd/cli/tui/memdisk.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | "github.com/charmbracelet/lipgloss" 8 | ) 9 | 10 | func (m *ResponsiveTUIModel) renderMemDiskPanel(width, height int) string { 11 | style := m.panelStyle(width, height) 12 | 13 | var content []string 14 | 15 | // Memory section 16 | content = append(content, m.titleStyle().Render("MEMORY")) 17 | 18 | if m.metrics != nil && m.metrics.Memory != nil { 19 | mem := m.metrics.Memory 20 | totalGB := float64(mem.Total) / 1024 / 1024 21 | usedGB := float64(mem.Total-mem.Available) / 1024 / 1024 22 | 23 | usedPercent := usedGB / totalGB * 100 24 | barWidth := width - 15 25 | if barWidth < 8 { 26 | barWidth = 8 27 | } 28 | memBar := m.renderProgressBar(mem.Total-mem.Available, mem.Total, barWidth, "memory") 29 | 30 | content = append(content, fmt.Sprintf("%s %.1f%%", memBar, usedPercent)) 31 | content = append(content, fmt.Sprintf("%.1f/%.1fGB", usedGB, totalGB)) 32 | 33 | if mem.SwapTotal > 0 { 34 | swapTotalGB := float64(mem.SwapTotal) / 1024 / 1024 35 | swapUsedGB := float64(mem.SwapTotal-mem.SwapFree) / 1024 / 1024 36 | swapPercent := swapUsedGB / swapTotalGB * 100 37 | swapBar := m.renderProgressBar(mem.SwapTotal-mem.SwapFree, mem.SwapTotal, barWidth, "memory") 38 | 39 | content = append(content, fmt.Sprintf("%s %.1f%%", swapBar, swapPercent)) 40 | content = append(content, fmt.Sprintf("%.1f/%.1fGB Swap", swapUsedGB, swapTotalGB)) 41 | } 42 | } else { 43 | content = append(content, "Loading memory info...") 44 | } 45 | 46 | // Disk section 47 | content = append(content, "") 48 | content = append(content, m.titleStyle().Render("DISK")) 49 | 50 | if m.metrics == nil || len(m.metrics.DiskMounts) == 0 { 51 | content = append(content, "Loading...") 52 | } else { 53 | // Show top 3 disks 54 | disksShown := 0 55 | for _, mount := range m.metrics.DiskMounts { 56 | if disksShown >= 3 { 57 | break 58 | } 59 | 60 | if mount.Device == "tmpfs" || mount.Device == "devtmpfs" || 61 | strings.HasPrefix(mount.Mount, "/dev") || strings.HasPrefix(mount.Mount, "/proc") || 62 | strings.HasPrefix(mount.Mount, "/sys") || strings.HasPrefix(mount.Mount, "/run") { 63 | continue 64 | } 65 | 66 | deviceName := mount.Device 67 | if len(deviceName) > 15 { 68 | deviceName = deviceName[:12] + "..." 69 | } 70 | 71 | // Parse percentage 72 | percentStr := strings.TrimSuffix(mount.Percent, "%") 73 | percent, _ := strconv.ParseFloat(percentStr, 64) 74 | 75 | barWidth := width - 20 76 | if barWidth < 10 { 77 | barWidth = 10 78 | } 79 | 80 | // Show device and mount point clearly 81 | displayName := fmt.Sprintf("%s → %s", deviceName, mount.Mount) 82 | if len(displayName) > width-8 { 83 | // If too long, try shorter device name 84 | shortDevice := deviceName 85 | if len(shortDevice) > 8 { 86 | shortDevice = shortDevice[:8] + "..." 87 | } 88 | displayName = fmt.Sprintf("%s → %s", shortDevice, mount.Mount) 89 | if len(displayName) > width-8 { 90 | displayName = mount.Mount // fallback to just mount point 91 | } 92 | } 93 | content = append(content, displayName) 94 | 95 | // Show usage as "Used/Total" format 96 | usageInfo := fmt.Sprintf("%s/%s", mount.Used, mount.Size) 97 | content = append(content, fmt.Sprintf("%s %s", m.renderProgressBar(uint64(percent*100), 10000, barWidth, "disk"), usageInfo)) 98 | 99 | disksShown++ 100 | } 101 | 102 | // Add disk I/O chart 103 | if len(m.diskHistory) > 1 { 104 | content = append(content, "") 105 | latest := m.diskHistory[len(m.diskHistory)-1] 106 | content = append(content, fmt.Sprintf("R: %s W: %s", m.formatBytes(uint64(latest.readRate))+"/s", m.formatBytes(uint64(latest.writeRate))+"/s")) 107 | } 108 | 109 | // Add sensors if available 110 | if len(m.systemTemperatures) > 0 { 111 | content = append(content, "") 112 | content = append(content, m.titleStyle().Render("SENSORS")) 113 | 114 | // Show a reasonable number of sensors that fit 115 | sensorsToShow := len(m.systemTemperatures) 116 | if sensorsToShow > 6 { // Limit to prevent overcrowding 117 | sensorsToShow = 6 118 | } 119 | 120 | for i := 0; i < sensorsToShow; i++ { 121 | sensor := m.systemTemperatures[i] 122 | // Use full sensor name, don't truncate unnecessarily 123 | name := sensor.Name 124 | if len(name) > 20 { // Only truncate if really long 125 | name = name[:20] 126 | } 127 | 128 | // Color based on temperature 129 | tempStr := fmt.Sprintf("%.0f°C", sensor.Temperature) 130 | color := m.getTemperatureColor(sensor.Temperature) 131 | tempStr = lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(tempStr) 132 | 133 | content = append(content, fmt.Sprintf("%s: %s", name, tempStr)) 134 | } 135 | } 136 | } 137 | 138 | // Ensure content fills allocated height 139 | contentStr := strings.Join(content, "\n") 140 | lines := strings.Split(contentStr, "\n") 141 | innerHeight := height - 2 // subtract borders 142 | for len(lines) < innerHeight { 143 | lines = append(lines, "") 144 | } 145 | if len(lines) > innerHeight { 146 | lines = lines[:innerHeight] 147 | } 148 | 149 | return style.Render(strings.Join(lines, "\n")) 150 | } -------------------------------------------------------------------------------- /cmd/cli/tui/model.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/AvengeMedia/dgop/config" 7 | "github.com/AvengeMedia/dgop/gops" 8 | "github.com/AvengeMedia/dgop/models" 9 | "github.com/charmbracelet/bubbles/table" 10 | "github.com/charmbracelet/bubbles/viewport" 11 | tea "github.com/charmbracelet/bubbletea" 12 | ) 13 | 14 | type NetworkSample struct { 15 | timestamp time.Time 16 | rxBytes uint64 17 | txBytes uint64 18 | rxRate float64 19 | txRate float64 20 | } 21 | 22 | type DiskSample struct { 23 | timestamp time.Time 24 | readBytes uint64 25 | writeBytes uint64 26 | readRate float64 27 | writeRate float64 28 | device string 29 | } 30 | 31 | type tickMsg time.Time 32 | 33 | func tick() tea.Cmd { 34 | return tea.Tick(time.Second, func(t time.Time) tea.Msg { 35 | return tickMsg(t) 36 | }) 37 | } 38 | 39 | type colorUpdateMsg struct{} 40 | 41 | type ResponsiveTUIModel struct { 42 | gops *gops.GopsUtil 43 | colorManager *config.ColorManager 44 | metrics *models.SystemMetrics 45 | width int 46 | height int 47 | err error 48 | lastUpdate time.Time 49 | 50 | processTable table.Model 51 | viewport viewport.Model 52 | 53 | hardware *models.SystemHardware 54 | 55 | networkHistory []NetworkSample 56 | maxNetHistory int 57 | networkCursor string 58 | lastNetworkUpdate time.Time 59 | selectedInterfaceName string 60 | 61 | diskHistory []DiskSample 62 | maxDiskHistory int 63 | diskCursor string 64 | lastDiskUpdate time.Time 65 | 66 | systemTemperatures []models.TemperatureSensor 67 | lastTempUpdate time.Time 68 | 69 | sortBy gops.ProcSortBy 70 | procLimit int 71 | ready bool 72 | showDetails bool 73 | selectedPID int32 74 | 75 | distroLogo []string 76 | distroColor string 77 | 78 | logoTestMode bool 79 | currentLogoIndex int 80 | lastLogoUpdate time.Time 81 | } 82 | 83 | func (m *ResponsiveTUIModel) Cleanup() { 84 | if m.colorManager != nil { 85 | m.colorManager.Close() 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /cmd/cli/tui/panels.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | func (m *ResponsiveTUIModel) renderCPUPanel(width, height int) string { 9 | style := m.panelStyle(width, height) 10 | 11 | var content strings.Builder 12 | 13 | if m.metrics == nil || m.metrics.CPU == nil { 14 | content.WriteString("Loading CPU info...") 15 | // Pad to fill allocated height even when loading 16 | contentStr := content.String() 17 | lines := strings.Split(contentStr, "\n") 18 | innerHeight := height - 2 19 | for len(lines) < innerHeight { 20 | lines = append(lines, "") 21 | } 22 | return style.Render(strings.Join(lines, "\n")) 23 | } 24 | 25 | cpu := m.metrics.CPU 26 | cpuName := cpu.Model 27 | if len(cpuName) > width-10 { 28 | cpuName = cpuName[:width-10] + ".." 29 | } 30 | 31 | // CPU name as title, with right-aligned frequency - align with core layout 32 | freqText := fmt.Sprintf("%.0fMHz", cpu.Frequency) 33 | // Calculate spaces to align with core columns - adjust for proper C/MHz alignment 34 | availableWidth := width - 5 // account for borders+padding, align with cores 35 | spaces := availableWidth - len(cpuName) - len(freqText) 36 | if spaces < 1 { 37 | spaces = 1 38 | } 39 | 40 | titleLine := m.titleStyle().Render(cpuName) 41 | content.WriteString(titleLine + strings.Repeat(" ", spaces) + freqText + "\n") 42 | 43 | // CPU bar with usage and temperature - make bar wider so temp isn't too far left 44 | barWidth := width - 15 // Make bar wider to push temp right 45 | if barWidth < 8 { 46 | barWidth = 8 47 | } 48 | 49 | cpuBar := m.renderProgressBar(uint64(cpu.Usage*100), 10000, barWidth, "cpu") 50 | // Format as fixed-width strings for consistent alignment 51 | usageText := fmt.Sprintf("%3.0f%%", cpu.Usage) // Always 3 chars for percentage (e.g. " 5%" or "100%") 52 | tempText := fmt.Sprintf("%.0f°C", cpu.Temperature) 53 | content.WriteString(fmt.Sprintf("%s %s %s\n", cpuBar, usageText, tempText)) 54 | 55 | // All cores with bars in 3 columns filling 100% width 56 | if len(cpu.CoreUsage) > 0 { 57 | // Each column gets 33% of available width (accounting for borders/padding) 58 | availableWidth := width - 4 // Account for borders/padding 59 | columnWidth := availableWidth / 3 60 | 61 | // Each core needs space for "C00" (3 chars) + bar + "100%" (4 chars) = 7 + bar (no spaces) 62 | coreBarWidth := columnWidth - 8 // More space for wider bars 63 | if coreBarWidth < 6 { 64 | coreBarWidth = 6 65 | } 66 | 67 | for i := 0; i < len(cpu.CoreUsage); i += 3 { 68 | var line strings.Builder 69 | 70 | // First core - format as "C01[bar]5%" with no spaces, add separator 71 | core1 := cpu.CoreUsage[i] 72 | core1Bar := m.renderProgressBar(uint64(core1*100), 10000, coreBarWidth, "cpu") 73 | core1Str := fmt.Sprintf("C%02d%s%3.0f%%", i, core1Bar, core1) // No spaces 74 | line.WriteString(core1Str) 75 | line.WriteString(" ") // Space separator between columns 76 | 77 | // Second core if exists 78 | if i+1 < len(cpu.CoreUsage) { 79 | core2 := cpu.CoreUsage[i+1] 80 | core2Bar := m.renderProgressBar(uint64(core2*100), 10000, coreBarWidth, "cpu") 81 | core2Str := fmt.Sprintf("C%02d%s%3.0f%%", i+1, core2Bar, core2) 82 | line.WriteString(core2Str) 83 | line.WriteString(" ") // Space separator between columns 84 | } 85 | 86 | // Third core if exists 87 | if i+2 < len(cpu.CoreUsage) { 88 | core3 := cpu.CoreUsage[i+2] 89 | core3Bar := m.renderProgressBar(uint64(core3*100), 10000, coreBarWidth, "cpu") 90 | core3Str := fmt.Sprintf("C%02d%s%3.0f%%", i+2, core3Bar, core3) 91 | line.WriteString(core3Str) // No separator after last column 92 | } 93 | 94 | content.WriteString(line.String() + "\n") 95 | } 96 | 97 | // Add load/tasks/threads on a single line under CPU cores 98 | if m.metrics != nil && m.metrics.System != nil { 99 | systemInfo := fmt.Sprintf("Load: %s | Tasks: %d | Threads: %d", 100 | m.metrics.System.LoadAvg, 101 | m.metrics.System.Processes, 102 | m.metrics.System.Threads) 103 | content.WriteString(systemInfo) 104 | } 105 | } 106 | 107 | // Ensure content fills allocated height 108 | contentStr := content.String() 109 | lines := strings.Split(contentStr, "\n") 110 | innerHeight := height - 2 111 | for len(lines) < innerHeight { 112 | lines = append(lines, "") 113 | } 114 | if len(lines) > innerHeight { 115 | lines = lines[:innerHeight] 116 | } 117 | 118 | return style.Render(strings.Join(lines, "\n")) 119 | } 120 | 121 | 122 | -------------------------------------------------------------------------------- /cmd/cli/tui/processes.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/AvengeMedia/dgop/models" 8 | "github.com/charmbracelet/bubbles/table" 9 | ) 10 | 11 | func (m *ResponsiveTUIModel) updateProcessTable() { 12 | if m.metrics == nil || len(m.metrics.Processes) == 0 { 13 | return 14 | } 15 | 16 | var rows []table.Row 17 | selectedIndex := -1 18 | 19 | for i, proc := range m.metrics.Processes { 20 | if m.selectedPID > 0 && proc.PID == m.selectedPID { 21 | selectedIndex = i 22 | } 23 | 24 | // Handle both 4-column and 5-column layouts 25 | columns := m.processTable.Columns() 26 | var row table.Row 27 | 28 | // Format memory to show both percentage and GB/MB 29 | memGB := float64(proc.MemoryKB) / 1024 / 1024 // Convert KB to GB 30 | memMB := memGB * 1024 31 | var memStr string 32 | 33 | // ALWAYS show both percentage and size for debugging 34 | if memGB >= 1.0 { 35 | memStr = fmt.Sprintf("%.1f%% %.1fG", proc.MemoryPercent, memGB) 36 | } else { 37 | memStr = fmt.Sprintf("%.1f%% %.0fM", proc.MemoryPercent, memMB) 38 | } 39 | 40 | if len(columns) == 6 { // 6-column layout (PID, USER, CPU%, MEM%, COMMAND, FULL COMMAND) 41 | commandWidth := columns[4].Width 42 | fullCommandWidth := columns[5].Width 43 | row = table.Row{ 44 | strconv.Itoa(int(proc.PID)), 45 | truncateString(proc.Username, 12), 46 | fmt.Sprintf("%.1f", proc.CPU), 47 | memStr, 48 | truncateString(proc.Command, commandWidth), 49 | truncateString(proc.FullCommand, fullCommandWidth), 50 | } 51 | } else { // 5-column layout (original) 52 | commandWidth := 30 // Default fallback 53 | if len(columns) > 4 { 54 | commandWidth = columns[4].Width 55 | } 56 | row = table.Row{ 57 | strconv.Itoa(int(proc.PID)), 58 | truncateString(proc.Username, 12), 59 | fmt.Sprintf("%.1f", proc.CPU), 60 | memStr, 61 | truncateString(proc.Command, commandWidth), 62 | } 63 | } 64 | rows = append(rows, row) 65 | } 66 | 67 | m.processTable.SetRows(rows) 68 | 69 | if selectedIndex >= 0 { 70 | m.processTable.SetCursor(selectedIndex) 71 | } else if m.selectedPID == -1 { 72 | m.processTable.SetCursor(0) 73 | } 74 | } 75 | 76 | func (m *ResponsiveTUIModel) getSelectedProcess() *models.ProcessInfo { 77 | if m.metrics == nil || len(m.metrics.Processes) == 0 { 78 | return nil 79 | } 80 | 81 | cursor := m.processTable.Cursor() 82 | if cursor >= 0 && cursor < len(m.metrics.Processes) { 83 | return m.metrics.Processes[cursor] 84 | } 85 | 86 | return nil 87 | } 88 | 89 | func (m *ResponsiveTUIModel) updateSelectedPID() { 90 | if selectedProc := m.getSelectedProcess(); selectedProc != nil { 91 | m.selectedPID = selectedProc.PID 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /cmd/cli/tui/styles.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "github.com/AvengeMedia/dgop/models" 5 | "github.com/charmbracelet/lipgloss" 6 | ) 7 | 8 | func (m *ResponsiveTUIModel) getColors() *models.ColorPalette { 9 | if m.colorManager != nil { 10 | return m.colorManager.GetPalette() 11 | } 12 | return models.DefaultColorPalette() 13 | } 14 | 15 | func (m *ResponsiveTUIModel) panelStyle(width, height int) lipgloss.Style { 16 | colors := m.getColors() 17 | return lipgloss.NewStyle(). 18 | Border(lipgloss.NormalBorder()). 19 | BorderForeground(lipgloss.Color(colors.UI.BorderPrimary)). 20 | Padding(0, 1). 21 | Width(width). 22 | MaxHeight(height) 23 | } 24 | 25 | func (m *ResponsiveTUIModel) textStyle() lipgloss.Style { 26 | colors := m.getColors() 27 | return lipgloss.NewStyle(). 28 | Foreground(lipgloss.Color(colors.UI.TextSecondary)) 29 | } 30 | 31 | func (m *ResponsiveTUIModel) boldTextStyle() lipgloss.Style { 32 | colors := m.getColors() 33 | return lipgloss.NewStyle(). 34 | Foreground(lipgloss.Color(colors.UI.TextPrimary)). 35 | Bold(true) 36 | } 37 | 38 | func (m *ResponsiveTUIModel) titleStyle() lipgloss.Style { 39 | colors := m.getColors() 40 | return lipgloss.NewStyle(). 41 | Bold(true). 42 | Foreground(lipgloss.Color(colors.UI.TextAccent)) 43 | } 44 | 45 | func (m *ResponsiveTUIModel) headerStyle() lipgloss.Style { 46 | colors := m.getColors() 47 | return lipgloss.NewStyle(). 48 | Foreground(lipgloss.Color(colors.UI.HeaderText)). 49 | Background(lipgloss.Color(colors.UI.HeaderBackground)). 50 | Bold(true). 51 | Width(m.width). 52 | Padding(0, 2) 53 | } 54 | 55 | func (m *ResponsiveTUIModel) footerStyle() lipgloss.Style { 56 | colors := m.getColors() 57 | return lipgloss.NewStyle(). 58 | Foreground(lipgloss.Color(colors.UI.FooterText)). 59 | Background(lipgloss.Color(colors.UI.FooterBackground)). 60 | Width(m.width). 61 | Padding(0, 2) 62 | } 63 | 64 | func (m *ResponsiveTUIModel) getProgressBarColor(usage float64, colorType string) string { 65 | colors := m.getColors() 66 | 67 | switch colorType { 68 | case "memory": 69 | if usage > 80 { 70 | return colors.ProgressBars.MemoryHigh 71 | } else if usage > 60 { 72 | return colors.ProgressBars.MemoryMedium 73 | } 74 | return colors.ProgressBars.MemoryLow 75 | case "disk": 76 | if usage > 90 { 77 | return colors.ProgressBars.DiskHigh 78 | } else if usage > 70 { 79 | return colors.ProgressBars.DiskMedium 80 | } 81 | return colors.ProgressBars.DiskLow 82 | case "cpu": 83 | if usage > 80 { 84 | return colors.ProgressBars.CPUHigh 85 | } else if usage > 60 { 86 | return colors.ProgressBars.CPUMedium 87 | } 88 | return colors.ProgressBars.CPULow 89 | default: 90 | return colors.ProgressBars.MemoryLow 91 | } 92 | } 93 | 94 | func (m *ResponsiveTUIModel) getTemperatureColor(temp float64) string { 95 | colors := m.getColors() 96 | 97 | if temp > 85 { 98 | return colors.Temperature.Danger 99 | } else if temp > 70 { 100 | return colors.Temperature.Hot 101 | } else if temp > 50 { 102 | return colors.Temperature.Warm 103 | } 104 | return colors.Temperature.Cold 105 | } 106 | 107 | func (m *ResponsiveTUIModel) getNetworkColors() (string, string) { 108 | colors := m.getColors() 109 | return colors.Charts.NetworkDownload, colors.Charts.NetworkUpload 110 | } -------------------------------------------------------------------------------- /cmd/cli/tui_runner.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/AvengeMedia/dgop/cmd/cli/tui" 5 | "github.com/AvengeMedia/dgop/gops" 6 | tea "github.com/charmbracelet/bubbletea" 7 | ) 8 | 9 | func runTUI(gopsUtil *gops.GopsUtil) error { 10 | tui.Version = Version 11 | model := tui.NewResponsiveTUIModel(gopsUtil) 12 | defer model.Cleanup() 13 | 14 | p := tea.NewProgram( 15 | model, 16 | tea.WithAltScreen(), 17 | ) 18 | 19 | _, err := p.Run() 20 | return err 21 | } 22 | -------------------------------------------------------------------------------- /config/colors.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "sync" 9 | 10 | "github.com/AvengeMedia/dgop/models" 11 | "github.com/fsnotify/fsnotify" 12 | ) 13 | 14 | type ColorManager struct { 15 | mu sync.RWMutex 16 | palette *models.ColorPalette 17 | watcher *fsnotify.Watcher 18 | filePath string 19 | notify chan struct{} 20 | } 21 | 22 | func NewColorManager() (*ColorManager, error) { 23 | configDir, err := getConfigDir() 24 | if err != nil { 25 | return nil, fmt.Errorf("failed to get config directory: %w", err) 26 | } 27 | 28 | if err := ensureConfigDir(configDir); err != nil { 29 | return nil, fmt.Errorf("failed to create config directory: %w", err) 30 | } 31 | 32 | filePath := filepath.Join(configDir, "colors.json") 33 | 34 | cm := &ColorManager{ 35 | palette: models.DefaultColorPalette(), 36 | filePath: filePath, 37 | notify: make(chan struct{}, 1), 38 | } 39 | 40 | if err := cm.loadOrCreateConfigFile(); err != nil { 41 | return nil, fmt.Errorf("failed to initialize config file: %w", err) 42 | } 43 | 44 | if err := cm.startWatching(); err != nil { 45 | return nil, fmt.Errorf("failed to start file watching: %w", err) 46 | } 47 | 48 | return cm, nil 49 | } 50 | 51 | func (cm *ColorManager) GetPalette() *models.ColorPalette { 52 | cm.mu.RLock() 53 | defer cm.mu.RUnlock() 54 | 55 | paletteCopy := *cm.palette 56 | return &paletteCopy 57 | } 58 | 59 | func (cm *ColorManager) Close() error { 60 | if cm.watcher != nil { 61 | return cm.watcher.Close() 62 | } 63 | return nil 64 | } 65 | 66 | func (cm *ColorManager) ColorChanges() <-chan struct{} { 67 | return cm.notify 68 | } 69 | 70 | func (cm *ColorManager) notifyColorChange() { 71 | select { 72 | case cm.notify <- struct{}{}: 73 | default: 74 | } 75 | } 76 | 77 | func (cm *ColorManager) loadOrCreateConfigFile() error { 78 | if _, err := os.Stat(cm.filePath); os.IsNotExist(err) { 79 | return cm.createDefaultConfigFile() 80 | } 81 | 82 | return cm.loadConfigFile() 83 | } 84 | 85 | func (cm *ColorManager) createDefaultConfigFile() error { 86 | data, err := json.MarshalIndent(cm.palette, "", " ") 87 | if err != nil { 88 | return fmt.Errorf("failed to marshal default palette: %w", err) 89 | } 90 | 91 | if err := os.WriteFile(cm.filePath, data, 0644); err != nil { 92 | return fmt.Errorf("failed to write config file: %w", err) 93 | } 94 | 95 | return nil 96 | } 97 | 98 | func (cm *ColorManager) loadConfigFile() error { 99 | data, err := os.ReadFile(cm.filePath) 100 | if err != nil { 101 | return fmt.Errorf("failed to read config file: %w", err) 102 | } 103 | 104 | var palette models.ColorPalette 105 | if err := json.Unmarshal(data, &palette); err != nil { 106 | return err 107 | } 108 | 109 | cm.mu.Lock() 110 | cm.palette = &palette 111 | cm.mu.Unlock() 112 | 113 | cm.notifyColorChange() 114 | return nil 115 | } 116 | 117 | func (cm *ColorManager) startWatching() error { 118 | watcher, err := fsnotify.NewWatcher() 119 | if err != nil { 120 | return fmt.Errorf("failed to create watcher: %w", err) 121 | } 122 | 123 | cm.watcher = watcher 124 | 125 | go func() { 126 | for { 127 | select { 128 | case event, ok := <-watcher.Events: 129 | if !ok { 130 | return 131 | } 132 | if event.Has(fsnotify.Write) && event.Name == cm.filePath { 133 | if err := cm.loadConfigFile(); err != nil { 134 | cm.mu.Lock() 135 | cm.palette = models.DefaultColorPalette() 136 | cm.mu.Unlock() 137 | cm.notifyColorChange() 138 | } 139 | } 140 | case err, ok := <-watcher.Errors: 141 | if !ok { 142 | return 143 | } 144 | _ = err 145 | } 146 | } 147 | }() 148 | 149 | return watcher.Add(cm.filePath) 150 | } 151 | 152 | func getConfigDir() (string, error) { 153 | homeDir, err := os.UserHomeDir() 154 | if err != nil { 155 | return "", err 156 | } 157 | return filepath.Join(homeDir, ".config", "dgop"), nil 158 | } 159 | 160 | func ensureConfigDir(dir string) error { 161 | return os.MkdirAll(dir, 0755) 162 | } -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/caarlos0/env/v11" 7 | ) 8 | 9 | type Config struct { 10 | ApiPort string `env:"API_PORT" envDefault:":63484"` // Default port for the API server 11 | } 12 | 13 | // Parse environment variables into a Config struct 14 | func NewConfig() *Config { 15 | cfg := Config{} 16 | if err := env.Parse(&cfg); err != nil { 17 | log.Fatal("Error parsing environment", "err", err) 18 | } 19 | 20 | return &cfg 21 | } 22 | -------------------------------------------------------------------------------- /errdefs/errors.go: -------------------------------------------------------------------------------- 1 | package errdefs 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/danielgtaylor/huma/v2" 7 | ) 8 | 9 | // Huma response error 10 | type ResponseError struct { 11 | Status int `json:"status"` 12 | Message string `json:"message"` 13 | Details []string `json:"details,omitempty"` 14 | } 15 | 16 | func (e *ResponseError) Error() string { 17 | return e.Message 18 | } 19 | 20 | func (e *ResponseError) GetStatus() int { 21 | return e.Status 22 | } 23 | 24 | var HumaErrorFunc = func(status int, message string, errs ...error) huma.StatusError { 25 | details := make([]string, len(errs)) 26 | for i, err := range errs { 27 | details[i] = err.Error() 28 | } 29 | return &ResponseError{ 30 | Status: status, 31 | Message: message, 32 | Details: details, 33 | } 34 | } 35 | 36 | type ErrorType int 37 | 38 | // Errors 39 | var ( 40 | // Permissions 41 | ErrInvalidInput = NewCustomError(ErrTypeInvalidInput, "") 42 | ErrNotFound = NewCustomError(ErrTypeNotFound, "") 43 | ) 44 | 45 | // More dynamic errors 46 | const ( 47 | ErrTypeInvalidInput ErrorType = iota 48 | ErrTypeNotFound 49 | ) 50 | 51 | var errorTypeStrings = map[ErrorType]string{ 52 | ErrTypeInvalidInput: "ErrInvalidInput", 53 | ErrTypeNotFound: "ErrNotFound", 54 | } 55 | 56 | func (e ErrorType) String() string { 57 | if s, ok := errorTypeStrings[e]; ok { 58 | return s 59 | } 60 | return "ErrUnknown" 61 | } 62 | 63 | type CustomError struct { 64 | Type ErrorType 65 | Message string 66 | } 67 | 68 | func (e *CustomError) Error() string { 69 | return fmt.Sprintf("%s: %s", e.Type.String(), e.Message) 70 | } 71 | 72 | func (e *CustomError) Is(target error) bool { 73 | t, ok := target.(*CustomError) 74 | if !ok { 75 | return false 76 | } 77 | return e.Type == t.Type 78 | } 79 | 80 | func NewCustomError(t ErrorType, message string) *CustomError { 81 | return &CustomError{ 82 | Type: t, 83 | Message: message, 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1755027561, 6 | "narHash": "sha256-IVft239Bc8p8Dtvf7UAACMG5P3ZV+3/aO28gXpGtMXI=", 7 | "owner": "NixOS", 8 | "repo": "nixpkgs", 9 | "rev": "005433b926e16227259a1843015b5b2b7f7d1fc3", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "NixOS", 14 | "ref": "nixos-unstable", 15 | "repo": "nixpkgs", 16 | "type": "github" 17 | } 18 | }, 19 | "root": { 20 | "inputs": { 21 | "nixpkgs": "nixpkgs" 22 | } 23 | } 24 | }, 25 | "root": "root", 26 | "version": 7 27 | } 28 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "API & CLI for System Resources"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 | }; 7 | 8 | outputs = 9 | { self, nixpkgs }: 10 | let 11 | supportedSystems = [ 12 | "x86_64-linux" 13 | "aarch64-linux" 14 | "x86_64-darwin" 15 | "aarch64-darwin" 16 | ]; 17 | 18 | forAllSystems = 19 | f: 20 | builtins.listToAttrs ( 21 | map (system: { 22 | name = system; 23 | value = f system; 24 | }) supportedSystems 25 | ); 26 | 27 | in 28 | { 29 | packages = forAllSystems ( 30 | system: 31 | let 32 | pkgs = import nixpkgs { inherit system; }; 33 | lib = pkgs.lib; 34 | in 35 | { 36 | dgop = pkgs.buildGoModule (finalAttrs: { 37 | pname = "dgop"; 38 | version = "0.0.9"; 39 | src = ./.; 40 | vendorHash = "sha256-+3o/Kg5ROSgp8IZfvU71JvbEgaiLasx5IAkjq27faLQ="; 41 | 42 | ldflags = [ 43 | "-s" 44 | "-w" 45 | "-X main.Version=${finalAttrs.version}" 46 | "-X main.buildTime=1970-01-01_00:00:00" 47 | "-X main.Commit=${finalAttrs.version}" 48 | ]; 49 | 50 | nativeBuildInputs = [ pkgs.makeWrapper ]; 51 | 52 | installPhase = '' 53 | mkdir -p $out/bin 54 | cp $GOPATH/bin/cli $out/bin/dgop 55 | wrapProgram $out/bin/dgop --prefix PATH : "${lib.makeBinPath [ pkgs.pciutils ]}" 56 | ''; 57 | 58 | meta = { 59 | description = "API & CLI for System Resources"; 60 | homepage = "https://github.com/AvengeMedia/dgop"; 61 | mainProgram = "dgop"; 62 | binaryNativeCode = true; 63 | license = lib.licenses.mit; 64 | platforms = lib.platforms.unix; 65 | maintainers = with lib.maintainers; [ lonerOrz ]; 66 | }; 67 | }); 68 | 69 | default = self.packages.${system}.dgop; 70 | } 71 | ); 72 | }; 73 | } 74 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/AvengeMedia/dgop 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.6 6 | 7 | // toolchain go1.24.6 8 | 9 | require ( 10 | github.com/caarlos0/env/v11 v11.3.1 11 | github.com/charmbracelet/bubbles v0.21.0 12 | github.com/charmbracelet/bubbletea v1.3.6 13 | github.com/charmbracelet/lipgloss v1.1.0 14 | github.com/charmbracelet/log v0.4.2 15 | github.com/danielgtaylor/huma/v2 v2.34.1 16 | github.com/go-chi/chi/v5 v5.2.2 17 | github.com/gorilla/schema v1.4.1 18 | github.com/shirou/gopsutil/v4 v4.25.7 19 | github.com/spf13/cobra v1.9.1 20 | github.com/stretchr/testify v1.10.0 21 | golang.org/x/net v0.43.0 22 | ) 23 | 24 | require ( 25 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 26 | github.com/charmbracelet/colorprofile v0.3.1 // indirect 27 | github.com/charmbracelet/x/ansi v0.9.3 // indirect 28 | github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 29 | github.com/charmbracelet/x/term v0.2.1 // indirect 30 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 31 | github.com/ebitengine/purego v0.8.4 // indirect 32 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 33 | github.com/fsnotify/fsnotify v1.9.0 // indirect 34 | github.com/go-logfmt/logfmt v0.6.0 // indirect 35 | github.com/go-ole/go-ole v1.2.6 // indirect 36 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 37 | github.com/kr/text v0.1.0 // indirect 38 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 39 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect 40 | github.com/mattn/go-isatty v0.0.20 // indirect 41 | github.com/mattn/go-localereader v0.0.1 // indirect 42 | github.com/mattn/go-runewidth v0.0.16 // indirect 43 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 44 | github.com/muesli/cancelreader v0.2.2 // indirect 45 | github.com/muesli/termenv v0.16.0 // indirect 46 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 47 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect 48 | github.com/rivo/uniseg v0.4.7 // indirect 49 | github.com/rogpeppe/go-internal v1.14.1 // indirect 50 | github.com/spf13/pflag v1.0.6 // indirect 51 | github.com/tklauser/go-sysconf v0.3.15 // indirect 52 | github.com/tklauser/numcpus v0.10.0 // indirect 53 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 54 | github.com/yusufpapurcu/wmi v1.2.4 // indirect 55 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect 56 | golang.org/x/sync v0.16.0 // indirect 57 | golang.org/x/sys v0.35.0 // indirect 58 | golang.org/x/text v0.28.0 // indirect 59 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect 60 | gopkg.in/yaml.v3 v3.0.1 // indirect 61 | ) 62 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 2 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 3 | github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= 4 | github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 5 | github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= 6 | github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= 7 | github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= 8 | github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 9 | github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= 10 | github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= 11 | github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= 12 | github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= 13 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 14 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 15 | github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= 16 | github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= 17 | github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= 18 | github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 19 | github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= 20 | github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 21 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= 22 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 23 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 24 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 25 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 26 | github.com/danielgtaylor/huma/v2 v2.34.1 h1:EmOJAbzEGfy0wAq/QMQ1YKfEMBEfE94xdBRLPBP0gwQ= 27 | github.com/danielgtaylor/huma/v2 v2.34.1/go.mod h1:ynwJgLk8iGVgoaipi5tgwIQ5yoFNmiu+QdhU7CEEmhk= 28 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 29 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 30 | github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= 31 | github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= 32 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 33 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 34 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 35 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 36 | github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= 37 | github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= 38 | github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 39 | github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 40 | github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= 41 | github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= 42 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 43 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 44 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 45 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 46 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 47 | github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= 48 | github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= 49 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 50 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 51 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 52 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 53 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 54 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 55 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 56 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 57 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 58 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= 59 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= 60 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 61 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 62 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 63 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 64 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 65 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 66 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 67 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 68 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 69 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 70 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 71 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 72 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 73 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 74 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= 75 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= 76 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 77 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 78 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 79 | github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 80 | github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 81 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 82 | github.com/shirou/gopsutil/v4 v4.25.7 h1:bNb2JuqKuAu3tRlPv5piSmBZyMfecwQ+t/ILq+1JqVM= 83 | github.com/shirou/gopsutil/v4 v4.25.7/go.mod h1:XV/egmwJtd3ZQjBpJVY5kndsiOO4IRqy9TQnmm6VP7U= 84 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 85 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 86 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 87 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 88 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 89 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 90 | github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= 91 | github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= 92 | github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= 93 | github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= 94 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 95 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 96 | github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= 97 | github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= 98 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= 99 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= 100 | golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= 101 | golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= 102 | golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 103 | golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 104 | golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 105 | golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 106 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 107 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 108 | golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= 109 | golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 110 | golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= 111 | golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 112 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 113 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 114 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 115 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 116 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 117 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 118 | -------------------------------------------------------------------------------- /gops/cpu.go: -------------------------------------------------------------------------------- 1 | package gops 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "os" 7 | "path/filepath" 8 | "strconv" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | "github.com/AvengeMedia/dgop/models" 14 | "github.com/shirou/gopsutil/v4/cpu" 15 | "github.com/shirou/gopsutil/v4/sensors" 16 | ) 17 | 18 | type CPUTracker struct { 19 | lastTotal []float64 20 | lastCores [][]float64 21 | lastUpdate time.Time 22 | 23 | // Cache for expensive operations 24 | cpuModel string 25 | cpuFreq float64 26 | cpuCount int 27 | modelCached bool 28 | 29 | // Temperature cache 30 | tempPath string 31 | tempLastRead time.Time 32 | tempValue float64 33 | 34 | mu sync.RWMutex 35 | } 36 | 37 | var cpuTracker = &CPUTracker{} 38 | 39 | func (self *GopsUtil) GetCPUInfo() (*models.CPUInfo, error) { 40 | return self.GetCPUInfoWithCursor("") 41 | } 42 | 43 | func (self *GopsUtil) GetCPUInfoWithCursor(cursor string) (*models.CPUInfo, error) { 44 | cpuInfo := models.CPUInfo{} 45 | 46 | cpuTracker.mu.Lock() 47 | defer cpuTracker.mu.Unlock() 48 | 49 | if !cpuTracker.modelCached { 50 | cpuTracker.cpuCount, _ = cpu.Counts(true) 51 | info, err := cpu.Info() 52 | if err == nil && len(info) > 0 { 53 | cpuTracker.cpuModel = info[0].ModelName 54 | cpuTracker.cpuFreq = info[0].Mhz 55 | } 56 | cpuTracker.modelCached = true 57 | } 58 | 59 | // Get current CPU frequency (more accurate than cached base frequency) 60 | currentFreq := getCurrentCPUFreq() 61 | if currentFreq > 0 { 62 | cpuInfo.Frequency = currentFreq 63 | } else { 64 | cpuInfo.Frequency = cpuTracker.cpuFreq 65 | } 66 | 67 | cpuInfo.Count = cpuTracker.cpuCount 68 | cpuInfo.Model = cpuTracker.cpuModel 69 | 70 | now := time.Now() 71 | if now.Sub(cpuTracker.tempLastRead) > 5*time.Second { 72 | cpuTracker.tempValue = getCPUTemperatureCached() 73 | cpuTracker.tempLastRead = now 74 | } 75 | cpuInfo.Temperature = cpuTracker.tempValue 76 | 77 | times, err := cpu.Times(false) 78 | if err == nil && len(times) > 0 { 79 | t := times[0] 80 | cpuInfo.Total = []float64{ 81 | t.User, t.Nice, t.System, 82 | t.Idle, t.Iowait, t.Irq, 83 | t.Softirq, t.Steal, 84 | } 85 | } 86 | 87 | perCore, err := cpu.Times(true) 88 | if err == nil { 89 | cpuInfo.Cores = make([][]float64, len(perCore)) 90 | for i, c := range perCore { 91 | cpuInfo.Cores[i] = []float64{ 92 | c.User, c.Nice, c.System, 93 | c.Idle, c.Iowait, c.Irq, 94 | c.Softirq, c.Steal, 95 | } 96 | } 97 | } 98 | 99 | currentTime := now.UnixMilli() 100 | 101 | var cursorData models.CPUCursorData 102 | if cursor != "" { 103 | jsonBytes, err := base64.RawURLEncoding.DecodeString(cursor) 104 | if err == nil { 105 | json.Unmarshal(jsonBytes, &cursorData) 106 | } 107 | } 108 | 109 | if len(cursorData.Total) > 0 && len(cpuInfo.Total) > 0 && cursorData.Timestamp > 0 { 110 | timeDiff := float64(currentTime-cursorData.Timestamp) / 1000.0 111 | if timeDiff > 0 { 112 | cpuInfo.Usage = calculateCPUPercentage(cursorData.Total, cpuInfo.Total) 113 | 114 | if len(cursorData.Cores) > 0 && len(cpuInfo.Cores) > 0 { 115 | cpuInfo.CoreUsage = make([]float64, len(cpuInfo.Cores)) 116 | for i := 0; i < len(cpuInfo.Cores) && i < len(cursorData.Cores); i++ { 117 | cpuInfo.CoreUsage[i] = calculateCPUPercentage(cursorData.Cores[i], cpuInfo.Cores[i]) 118 | } 119 | } 120 | } 121 | } else { 122 | cpuPercent, err := cpu.Percent(100*time.Millisecond, false) 123 | if err == nil && len(cpuPercent) > 0 { 124 | cpuInfo.Usage = cpuPercent[0] 125 | } 126 | 127 | corePercent, err := cpu.Percent(100*time.Millisecond, true) 128 | if err == nil { 129 | cpuInfo.CoreUsage = corePercent 130 | } 131 | } 132 | 133 | newCursor := models.CPUCursorData{ 134 | Total: cpuInfo.Total, 135 | Cores: cpuInfo.Cores, 136 | Timestamp: currentTime, 137 | } 138 | cursorBytes, _ := json.Marshal(newCursor) 139 | cpuInfo.Cursor = base64.RawURLEncoding.EncodeToString(cursorBytes) 140 | 141 | return &cpuInfo, nil 142 | } 143 | 144 | func getCPUTemperatureCached() float64 { 145 | // Try gopsutil sensors first (preferred method) 146 | temps, err := sensors.SensorsTemperatures() 147 | if err == nil { 148 | for _, temp := range temps { 149 | // Look for CPU temperature sensors 150 | if strings.Contains(temp.SensorKey, "coretemp_core_0") || 151 | strings.Contains(temp.SensorKey, "k10temp_tdie") || 152 | strings.Contains(temp.SensorKey, "cpu_thermal") || 153 | strings.Contains(temp.SensorKey, "package_id_0") { 154 | return temp.Temperature 155 | } 156 | } 157 | } 158 | 159 | // Fallback to hwmon if gopsutil doesn't work 160 | if cpuTracker.tempPath != "" { 161 | tempBytes, err := os.ReadFile(cpuTracker.tempPath) 162 | if err == nil { 163 | temp, err := strconv.Atoi(strings.TrimSpace(string(tempBytes))) 164 | if err == nil { 165 | return float64(temp) / 1000.0 166 | } 167 | } 168 | } 169 | 170 | hwmonPath := "/sys/class/hwmon" 171 | entries, err := os.ReadDir(hwmonPath) 172 | if err != nil { 173 | return 0 174 | } 175 | 176 | for _, entry := range entries { 177 | namePath := filepath.Join(hwmonPath, entry.Name(), "name") 178 | nameBytes, err := os.ReadFile(namePath) 179 | if err != nil { 180 | continue 181 | } 182 | 183 | name := strings.TrimSpace(string(nameBytes)) 184 | if strings.Contains(name, "coretemp") || strings.Contains(name, "k10temp") || 185 | strings.Contains(name, "k8temp") || strings.Contains(name, "cpu_thermal") || strings.Contains(name, "zenpower") { 186 | tempPath := filepath.Join(hwmonPath, entry.Name(), "temp1_input") 187 | tempBytes, err := os.ReadFile(tempPath) 188 | if err == nil { 189 | temp, err := strconv.Atoi(strings.TrimSpace(string(tempBytes))) 190 | if err == nil { 191 | cpuTracker.tempPath = tempPath 192 | return float64(temp) / 1000.0 193 | } 194 | } 195 | } 196 | } 197 | 198 | return 0 199 | } 200 | 201 | func getCurrentCPUFreq() float64 { 202 | // Try to read current frequency from /proc/cpuinfo 203 | cpuinfoBytes, err := os.ReadFile("/proc/cpuinfo") 204 | if err != nil { 205 | return 0 206 | } 207 | 208 | lines := strings.Split(string(cpuinfoBytes), "\n") 209 | for _, line := range lines { 210 | if strings.HasPrefix(line, "cpu MHz") { 211 | parts := strings.Split(line, ":") 212 | if len(parts) == 2 { 213 | freq, err := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64) 214 | if err == nil { 215 | return freq 216 | } 217 | } 218 | } 219 | } 220 | 221 | // Try scaling_cur_freq as fallback 222 | freqBytes, err := os.ReadFile("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq") 223 | if err == nil { 224 | freq, err := strconv.Atoi(strings.TrimSpace(string(freqBytes))) 225 | if err == nil { 226 | return float64(freq) / 1000.0 // Convert from kHz to MHz 227 | } 228 | } 229 | 230 | return 0 231 | } 232 | 233 | func calculateCPUPercentage(prev, curr []float64) float64 { 234 | if len(prev) < 8 || len(curr) < 8 { 235 | return 0 236 | } 237 | 238 | prevUser, prevNice, prevSystem := prev[0], prev[1], prev[2] 239 | prevIdle, prevIowait := prev[3], prev[4] 240 | prevIrq, prevSoftirq, prevSteal := prev[5], prev[6], prev[7] 241 | 242 | currUser, currNice, currSystem := curr[0], curr[1], curr[2] 243 | currIdle, currIowait := curr[3], curr[4] 244 | currIrq, currSoftirq, currSteal := curr[5], curr[6], curr[7] 245 | 246 | prevTotal := prevUser + prevNice + prevSystem + prevIdle + prevIowait + prevIrq + prevSoftirq + prevSteal 247 | currTotal := currUser + currNice + currSystem + currIdle + currIowait + currIrq + currSoftirq + currSteal 248 | 249 | prevBusy := prevTotal - prevIdle - prevIowait 250 | currBusy := currTotal - currIdle - currIowait 251 | 252 | if currBusy <= prevBusy { 253 | return 0 254 | } 255 | if currTotal <= prevTotal { 256 | return 100 257 | } 258 | 259 | usage := (currBusy - prevBusy) / (currTotal - prevTotal) * 100.0 260 | 261 | if usage < 0 { 262 | return 0 263 | } 264 | if usage > 100 { 265 | return 100 266 | } 267 | 268 | return usage 269 | } 270 | -------------------------------------------------------------------------------- /gops/disk.go: -------------------------------------------------------------------------------- 1 | package gops 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/AvengeMedia/dgop/models" 8 | "github.com/shirou/gopsutil/v4/disk" 9 | ) 10 | 11 | func (self *GopsUtil) GetDiskInfo() ([]*models.DiskInfo, error) { 12 | diskIO, err := disk.IOCounters() 13 | res := make([]*models.DiskInfo, 0) 14 | if err == nil { 15 | for name, d := range diskIO { 16 | // Filter to match bash script patterns 17 | if matchesDiskDevice(name) { 18 | res = append(res, &models.DiskInfo{ 19 | Name: name, 20 | Read: d.ReadBytes / 512, // Convert to sectors 21 | Write: d.WriteBytes / 512, // Convert to sectors 22 | }) 23 | } 24 | } 25 | } 26 | return res, nil 27 | } 28 | 29 | func (self *GopsUtil) GetDiskMounts() ([]*models.DiskMountInfo, error) { 30 | partitions, err := disk.Partitions(false) 31 | var metrics []*models.DiskMountInfo 32 | if err == nil { 33 | for _, p := range partitions { 34 | // Skip tmpfs and devtmpfs 35 | if p.Fstype == "tmpfs" || p.Fstype == "devtmpfs" { 36 | continue 37 | } 38 | 39 | usage, err := disk.Usage(p.Mountpoint) 40 | if err != nil { 41 | continue 42 | } 43 | 44 | metrics = append(metrics, &models.DiskMountInfo{ 45 | Device: p.Device, 46 | Mount: p.Mountpoint, 47 | FSType: p.Fstype, 48 | Size: formatBytes(usage.Total), 49 | Used: formatBytes(usage.Used), 50 | Avail: formatBytes(usage.Free), 51 | Percent: fmt.Sprintf("%.0f%%", usage.UsedPercent), 52 | }) 53 | } 54 | } 55 | 56 | return metrics, nil 57 | } 58 | 59 | func matchesDiskDevice(name string) bool { 60 | patterns := []string{"sd", "nvme", "vd", "dm-", "mmcblk"} 61 | for _, pattern := range patterns { 62 | if strings.HasPrefix(name, pattern) { 63 | return true 64 | } 65 | } 66 | return false 67 | } 68 | 69 | func formatBytes(bytes uint64) string { 70 | const unit = 1024 71 | if bytes < unit { 72 | return fmt.Sprintf("%dB", bytes) 73 | } 74 | div, exp := uint64(unit), 0 75 | for n := bytes / unit; n >= unit; n /= unit { 76 | div *= unit 77 | exp++ 78 | } 79 | return fmt.Sprintf("%.1f%c", float64(bytes)/float64(div), "KMGTPE"[exp]) 80 | } 81 | -------------------------------------------------------------------------------- /gops/diskrate.go: -------------------------------------------------------------------------------- 1 | package gops 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "time" 7 | 8 | "github.com/AvengeMedia/dgop/models" 9 | "github.com/shirou/gopsutil/v4/disk" 10 | ) 11 | 12 | type DiskRateCursor struct { 13 | Timestamp time.Time `json:"timestamp"` 14 | IOStats map[string]disk.IOCountersStat `json:"iostats"` 15 | } 16 | 17 | func (self *GopsUtil) GetDiskRates(cursorStr string) (*models.DiskRateResponse, error) { 18 | // Get current disk stats 19 | diskIO, err := disk.IOCounters() 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | currentStats := make(map[string]disk.IOCountersStat) 25 | for name, stats := range diskIO { 26 | currentStats[name] = stats 27 | } 28 | 29 | currentTime := time.Now() 30 | disks := make([]*models.DiskRateInfo, 0) 31 | 32 | // If we have a cursor, calculate rates 33 | if cursorStr != "" { 34 | cursor, err := parseDiskRateCursor(cursorStr) 35 | if err == nil { 36 | timeDiff := currentTime.Sub(cursor.Timestamp).Seconds() 37 | if timeDiff > 0 { 38 | for name, current := range currentStats { 39 | if prev, exists := cursor.IOStats[name]; exists { 40 | readRate := float64(current.ReadBytes-prev.ReadBytes) / timeDiff 41 | writeRate := float64(current.WriteBytes-prev.WriteBytes) / timeDiff 42 | 43 | disks = append(disks, &models.DiskRateInfo{ 44 | Device: name, 45 | ReadRate: readRate, 46 | WriteRate: writeRate, 47 | ReadTotal: current.ReadBytes, 48 | WriteTotal: current.WriteBytes, 49 | ReadCount: current.ReadCount, 50 | WriteCount: current.WriteCount, 51 | }) 52 | } 53 | } 54 | } 55 | } 56 | } 57 | 58 | // If no cursor or no rates calculated, return zero rates 59 | if len(disks) == 0 { 60 | for name, current := range currentStats { 61 | disks = append(disks, &models.DiskRateInfo{ 62 | Device: name, 63 | ReadRate: 0, 64 | WriteRate: 0, 65 | ReadTotal: current.ReadBytes, 66 | WriteTotal: current.WriteBytes, 67 | ReadCount: current.ReadCount, 68 | WriteCount: current.WriteCount, 69 | }) 70 | } 71 | } 72 | 73 | // Create new cursor 74 | newCursor := DiskRateCursor{ 75 | Timestamp: currentTime, 76 | IOStats: currentStats, 77 | } 78 | 79 | newCursorStr, err := encodeDiskRateCursor(newCursor) 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | return &models.DiskRateResponse{ 85 | Disks: disks, 86 | Cursor: newCursorStr, 87 | }, nil 88 | } 89 | 90 | func encodeDiskRateCursor(cursor DiskRateCursor) (string, error) { 91 | jsonData, err := json.Marshal(cursor) 92 | if err != nil { 93 | return "", err 94 | } 95 | return base64.StdEncoding.EncodeToString(jsonData), nil 96 | } 97 | 98 | func parseDiskRateCursor(cursorStr string) (DiskRateCursor, error) { 99 | var cursor DiskRateCursor 100 | 101 | jsonData, err := base64.StdEncoding.DecodeString(cursorStr) 102 | if err != nil { 103 | return cursor, err 104 | } 105 | 106 | err = json.Unmarshal(jsonData, &cursor) 107 | return cursor, err 108 | } 109 | -------------------------------------------------------------------------------- /gops/gops.go: -------------------------------------------------------------------------------- 1 | package gops 2 | 3 | import ( 4 | "github.com/AvengeMedia/dgop/internal/log" 5 | "github.com/AvengeMedia/dgop/models" 6 | "github.com/shirou/gopsutil/v4/sensors" 7 | ) 8 | 9 | type GopsUtil struct{} 10 | 11 | func NewGopsUtil() *GopsUtil { 12 | return &GopsUtil{} 13 | } 14 | 15 | func (self *GopsUtil) GetAllMetrics(procSortBy ProcSortBy, procLimit int, enableProcessCPU bool) (*models.SystemMetrics, error) { 16 | return self.GetAllMetricsWithCursors(procSortBy, procLimit, enableProcessCPU, "", "") 17 | } 18 | 19 | func (self *GopsUtil) GetAllMetricsWithCursors(procSortBy ProcSortBy, procLimit int, enableProcessCPU bool, cpuCursor string, procCursor string) (*models.SystemMetrics, error) { 20 | cpuInfo, err := self.GetCPUInfoWithCursor(cpuCursor) 21 | if err != nil { 22 | log.Errorf("Failed to get CPU info: %v", err) 23 | } 24 | 25 | memInfo, err := self.GetMemoryInfo() 26 | if err != nil { 27 | log.Errorf("Failed to get memory info: %v", err) 28 | } 29 | 30 | networkInfo, err := self.GetNetworkInfo() 31 | if err != nil { 32 | log.Errorf("Failed to get network info: %v", err) 33 | } 34 | 35 | diskInfo, err := self.GetDiskInfo() 36 | if err != nil { 37 | log.Errorf("Failed to get disk info: %v", err) 38 | } 39 | 40 | diskMounts, err := self.GetDiskMounts() 41 | if err != nil { 42 | log.Errorf("Failed to get disk mounts: %v", err) 43 | } 44 | 45 | processResult, err := self.GetProcessesWithCursor(procSortBy, procLimit, enableProcessCPU, procCursor) 46 | if err != nil { 47 | log.Errorf("Failed to get processes: %v", err) 48 | } 49 | 50 | systemInfo, err := self.GetSystemInfo() 51 | if err != nil { 52 | log.Errorf("Failed to get system info: %v", err) 53 | } 54 | 55 | var processes []*models.ProcessInfo 56 | if processResult != nil { 57 | processes = processResult.Processes 58 | } 59 | 60 | return &models.SystemMetrics{ 61 | Memory: memInfo, 62 | CPU: cpuInfo, 63 | Network: networkInfo, 64 | Disk: diskInfo, 65 | Processes: processes, 66 | System: systemInfo, 67 | DiskMounts: diskMounts, 68 | }, nil 69 | } 70 | 71 | // GetSystemTemperatures returns system temperature sensors 72 | func (self *GopsUtil) GetSystemTemperatures() ([]models.TemperatureSensor, error) { 73 | temps, err := sensors.SensorsTemperatures() 74 | if err != nil { 75 | return nil, err 76 | } 77 | 78 | var sensors []models.TemperatureSensor 79 | for _, temp := range temps { 80 | sensor := models.TemperatureSensor{ 81 | Name: temp.SensorKey, 82 | Temperature: temp.Temperature, 83 | High: temp.High, 84 | Critical: temp.Critical, 85 | } 86 | sensors = append(sensors, sensor) 87 | } 88 | 89 | return sensors, nil 90 | } 91 | -------------------------------------------------------------------------------- /gops/hardware.go: -------------------------------------------------------------------------------- 1 | package gops 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "regexp" 9 | "sort" 10 | "strconv" 11 | "strings" 12 | 13 | "github.com/AvengeMedia/dgop/models" 14 | "github.com/shirou/gopsutil/v4/host" 15 | ) 16 | 17 | func (self *GopsUtil) GetSystemHardware() (*models.SystemHardware, error) { 18 | info := &models.SystemHardware{} 19 | 20 | // Get CPU info from existing CPU API 21 | cpuInfo, err := self.GetCPUInfo() 22 | if err == nil { 23 | info.CPU = models.CPUBasic{ 24 | Count: cpuInfo.Count, 25 | Model: cpuInfo.Model, 26 | } 27 | } else { 28 | info.CPU = models.CPUBasic{ 29 | Count: 0, 30 | Model: "Unknown", 31 | } 32 | } 33 | 34 | // Get BIOS info 35 | biosInfo := getBIOSInfo() 36 | info.BIOS = biosInfo 37 | 38 | // Get system info using gopsutil 39 | hostInfo, err := host.Info() 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | info.Kernel = hostInfo.KernelVersion 45 | info.Hostname = hostInfo.Hostname 46 | info.Arch = hostInfo.KernelArch 47 | // Use manual distro detection for better results 48 | info.Distro = getDistroName() 49 | 50 | return info, nil 51 | } 52 | 53 | func (self *GopsUtil) GetGPUInfo() (*models.GPUInfo, error) { 54 | gpus, err := detectGPUs() 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | return &models.GPUInfo{GPUs: gpus}, nil 60 | } 61 | 62 | func (self *GopsUtil) GetGPUInfoWithTemp(pciIds []string) (*models.GPUInfo, error) { 63 | gpus, err := detectGPUs() 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | // If PCI IDs are specified, get temperatures for those GPUs 69 | if len(pciIds) > 0 { 70 | for i, gpu := range gpus { 71 | for _, pciId := range pciIds { 72 | if gpu.PciId == pciId { 73 | // Get temperature for this specific GPU 74 | if tempInfo, err := self.GetGPUTemp(pciId); err == nil { 75 | gpus[i].Temperature = tempInfo.Temperature 76 | gpus[i].Hwmon = tempInfo.Hwmon 77 | } 78 | break 79 | } 80 | } 81 | } 82 | } 83 | 84 | return &models.GPUInfo{GPUs: gpus}, nil 85 | } 86 | 87 | func (self *GopsUtil) GetGPUTemp(pciId string) (*models.GPUTempInfo, error) { 88 | if pciId == "" { 89 | return nil, fmt.Errorf("pciId is required") 90 | } 91 | 92 | // Find the GPU by PCI ID 93 | gpuEntries, err := detectGPUEntries() 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | var targetGPU *gpuEntry 99 | for _, gpu := range gpuEntries { 100 | _, gpuPciId := parseGPUInfo(gpu.RawLine) 101 | if gpuPciId == pciId { 102 | targetGPU = &gpu 103 | break 104 | } 105 | } 106 | 107 | if targetGPU == nil { 108 | return nil, fmt.Errorf("GPU with PCI ID %s not found", pciId) 109 | } 110 | 111 | // Auto-detect temperature method based on driver 112 | var temperature float64 113 | var hwmon string 114 | 115 | if targetGPU.Driver == "nvidia" { 116 | temperature, hwmon = getNvidiaTemperature() 117 | } else { 118 | temperature, hwmon = getHwmonTemperature(pciId) 119 | } 120 | 121 | return &models.GPUTempInfo{ 122 | Driver: targetGPU.Driver, 123 | Hwmon: hwmon, 124 | Temperature: temperature, 125 | }, nil 126 | } 127 | 128 | func getBIOSInfo() models.BIOSInfo { 129 | dmip := "/sys/class/dmi/id" 130 | if _, err := os.Stat(dmip); os.IsNotExist(err) { 131 | dmip = "/sys/devices/virtual/dmi/id" 132 | } 133 | 134 | biosInfo := models.BIOSInfo{} 135 | 136 | // Read motherboard vendor 137 | if vendor, err := readFile(filepath.Join(dmip, "board_vendor")); err == nil { 138 | biosInfo.Vendor = strings.TrimSpace(vendor) 139 | } else { 140 | biosInfo.Vendor = "Unknown" 141 | } 142 | 143 | // Read motherboard name 144 | var boardName string 145 | if name, err := readFile(filepath.Join(dmip, "board_name")); err == nil { 146 | boardName = strings.TrimSpace(name) 147 | } 148 | 149 | // Combine vendor and board name 150 | if biosInfo.Vendor != "Unknown" && boardName != "" { 151 | biosInfo.Motherboard = biosInfo.Vendor + " " + boardName 152 | } else if boardName != "" { 153 | biosInfo.Motherboard = boardName 154 | } else { 155 | biosInfo.Motherboard = "Unknown" 156 | } 157 | 158 | // Read BIOS version 159 | if version, err := readFile(filepath.Join(dmip, "bios_version")); err == nil { 160 | biosInfo.Version = strings.TrimSpace(version) 161 | } else { 162 | biosInfo.Version = "Unknown" 163 | } 164 | 165 | // Read BIOS date 166 | if date, err := readFile(filepath.Join(dmip, "bios_date")); err == nil { 167 | biosInfo.Date = strings.TrimSpace(date) 168 | } 169 | 170 | return biosInfo 171 | } 172 | 173 | func getDistroName() string { 174 | content, err := readFile("/etc/os-release") 175 | if err != nil { 176 | return "Unknown" 177 | } 178 | 179 | lines := strings.Split(content, "\n") 180 | for _, line := range lines { 181 | if strings.HasPrefix(line, "PRETTY_NAME=") { 182 | // Remove PRETTY_NAME= and quotes 183 | distro := strings.TrimPrefix(line, "PRETTY_NAME=") 184 | distro = strings.Trim(distro, "\"") 185 | return distro 186 | } 187 | } 188 | 189 | return "Unknown" 190 | } 191 | 192 | type gpuEntry struct { 193 | Priority int 194 | Driver string 195 | Vendor string 196 | RawLine string 197 | } 198 | 199 | func detectGPUEntries() ([]gpuEntry, error) { 200 | cmd := exec.Command("lspci", "-nnD") 201 | output, err := cmd.Output() 202 | if err != nil { 203 | return nil, err 204 | } 205 | 206 | var gpuEntries []gpuEntry 207 | vgaRegex := regexp.MustCompile(`(?i) VGA| 3D| 2D| Display`) 208 | 209 | lines := strings.Split(string(output), "\n") 210 | for _, line := range lines { 211 | if vgaRegex.MatchString(line) { 212 | parts := strings.Fields(line) 213 | if len(parts) == 0 { 214 | continue 215 | } 216 | 217 | bdf := parts[0] 218 | driver := getGPUDriver(bdf) 219 | vendor := inferVendor(driver, line) 220 | priority := getPriority(driver, bdf) 221 | 222 | gpuEntries = append(gpuEntries, gpuEntry{ 223 | Priority: priority, 224 | Driver: driver, 225 | Vendor: vendor, 226 | RawLine: line, 227 | }) 228 | } 229 | } 230 | 231 | // Sort by priority (descending), then by driver name 232 | sort.Slice(gpuEntries, func(i, j int) bool { 233 | if gpuEntries[i].Priority != gpuEntries[j].Priority { 234 | return gpuEntries[i].Priority > gpuEntries[j].Priority 235 | } 236 | return gpuEntries[i].Driver < gpuEntries[j].Driver 237 | }) 238 | 239 | var gpus []models.GPU 240 | for _, entry := range gpuEntries { 241 | displayName, pciId := parseGPUInfo(entry.RawLine) 242 | fullName := buildFullName(entry.Vendor, displayName) 243 | 244 | gpus = append(gpus, models.GPU{ 245 | Driver: entry.Driver, 246 | Vendor: entry.Vendor, 247 | DisplayName: displayName, 248 | FullName: fullName, 249 | PciId: pciId, 250 | RawLine: entry.RawLine, 251 | Temperature: 0, // TODO: Add GPU temperature detection 252 | Hwmon: "unknown", // TODO: Add hwmon path detection 253 | }) 254 | } 255 | 256 | return gpuEntries, nil 257 | } 258 | 259 | func detectGPUs() ([]models.GPU, error) { 260 | gpuEntries, err := detectGPUEntries() 261 | if err != nil { 262 | return nil, err 263 | } 264 | 265 | var gpus []models.GPU 266 | for _, entry := range gpuEntries { 267 | displayName, pciId := parseGPUInfo(entry.RawLine) 268 | fullName := buildFullName(entry.Vendor, displayName) 269 | 270 | gpus = append(gpus, models.GPU{ 271 | Driver: entry.Driver, 272 | Vendor: entry.Vendor, 273 | DisplayName: displayName, 274 | FullName: fullName, 275 | PciId: pciId, 276 | RawLine: entry.RawLine, 277 | Temperature: 0, // TODO: Add GPU temperature detection 278 | Hwmon: "unknown", // TODO: Add hwmon path detection 279 | }) 280 | } 281 | 282 | return gpus, nil 283 | } 284 | 285 | func getGPUDriver(bdf string) string { 286 | driverPath := filepath.Join("/sys/bus/pci/devices", bdf, "driver") 287 | if link, err := os.Readlink(driverPath); err == nil { 288 | return filepath.Base(link) 289 | } 290 | return "" 291 | } 292 | 293 | func inferVendor(driver, line string) string { 294 | // Check driver first 295 | switch driver { 296 | case "nvidia", "nouveau": 297 | return "NVIDIA" 298 | case "amdgpu", "radeon": 299 | return "AMD" 300 | case "i915", "xe": 301 | return "Intel" 302 | } 303 | 304 | // Check line content 305 | lineLower := strings.ToLower(line) 306 | if strings.Contains(lineLower, "nvidia") { 307 | return "NVIDIA" 308 | } 309 | if strings.Contains(lineLower, "amd") || strings.Contains(lineLower, "ati") { 310 | return "AMD" 311 | } 312 | if strings.Contains(lineLower, "intel") { 313 | return "Intel" 314 | } 315 | 316 | return "Unknown" 317 | } 318 | 319 | func getPriority(driver, bdf string) int { 320 | switch driver { 321 | case "nvidia": 322 | return 3 323 | case "amdgpu", "radeon": 324 | // Check if it's the primary GPU (device 00) 325 | parts := strings.Split(bdf, ":") 326 | if len(parts) >= 3 { 327 | deviceFunc := parts[2] 328 | if strings.HasPrefix(deviceFunc, "00.") { 329 | return 1 330 | } 331 | } 332 | return 2 333 | case "i915", "xe": 334 | return 0 335 | default: 336 | return 0 337 | } 338 | } 339 | 340 | func parseGPUInfo(rawLine string) (displayName, pciId string) { 341 | if rawLine == "" { 342 | return "Unknown", "" 343 | } 344 | 345 | // Extract PCI ID [vvvv:dddd] 346 | pciRegex := regexp.MustCompile(`\[([0-9a-f]{4}:[0-9a-f]{4})\]`) 347 | if match := pciRegex.FindStringSubmatch(rawLine); len(match) > 1 { 348 | pciId = match[1] 349 | } 350 | 351 | // Remove BDF and class prefix 352 | s := regexp.MustCompile(`^[^:]+: `).ReplaceAllString(rawLine, "") 353 | // Remove PCI ID [vvvv:dddd] and everything after 354 | s = regexp.MustCompile(`\[[0-9a-f]{4}:[0-9a-f]{4}\].*$`).ReplaceAllString(s, "") 355 | 356 | // Try to extract text after last ']' 357 | afterBracketRegex := regexp.MustCompile(`\]\s*([^\[]+)$`) 358 | if match := afterBracketRegex.FindStringSubmatch(s); len(match) > 1 && strings.TrimSpace(match[1]) != "" { 359 | displayName = strings.TrimSpace(match[1]) 360 | } else { 361 | // Try to get last bracketed text 362 | lastBracketRegex := regexp.MustCompile(`\[([^\]]+)\]([^\[]*$)`) 363 | if match := lastBracketRegex.FindStringSubmatch(s); len(match) > 1 { 364 | displayName = match[1] 365 | } else { 366 | displayName = s 367 | } 368 | } 369 | 370 | // Remove vendor prefixes 371 | displayName = removeVendorPrefixes(displayName) 372 | 373 | if displayName == "" { 374 | displayName = "Unknown" 375 | } 376 | 377 | return displayName, pciId 378 | } 379 | 380 | func removeVendorPrefixes(name string) string { 381 | prefixes := []string{ 382 | "NVIDIA Corporation ", 383 | "NVIDIA ", 384 | "Advanced Micro Devices, Inc. ", 385 | "AMD/ATI ", 386 | "AMD ", 387 | "ATI ", 388 | "Intel Corporation ", 389 | "Intel ", 390 | } 391 | 392 | result := name 393 | for _, prefix := range prefixes { 394 | if strings.HasPrefix(strings.ToLower(result), strings.ToLower(prefix)) { 395 | result = result[len(prefix):] 396 | break 397 | } 398 | } 399 | 400 | return strings.TrimSpace(result) 401 | } 402 | 403 | func buildFullName(vendor, displayName string) string { 404 | if displayName == "Unknown" { 405 | return displayName 406 | } 407 | 408 | switch vendor { 409 | case "NVIDIA": 410 | return "NVIDIA " + displayName 411 | case "AMD": 412 | return "AMD " + displayName 413 | case "Intel": 414 | return "Intel " + displayName 415 | default: 416 | return displayName 417 | } 418 | } 419 | 420 | func getNvidiaTemperature() (float64, string) { 421 | // Use nvidia-smi to get GPU temperature 422 | cmd := exec.Command("nvidia-smi", "--query-gpu=temperature.gpu", "--format=csv,noheader,nounits") 423 | output, err := cmd.Output() 424 | if err != nil { 425 | return 0, "unknown" 426 | } 427 | 428 | tempStr := strings.TrimSpace(string(output)) 429 | lines := strings.Split(tempStr, "\n") 430 | if len(lines) > 0 && lines[0] != "" { 431 | if temp, err := strconv.ParseFloat(lines[0], 64); err == nil { 432 | return temp, "nvidia" 433 | } 434 | } 435 | 436 | return 0, "unknown" 437 | } 438 | 439 | func getHwmonTemperature(pciId string) (float64, string) { 440 | // Convert PCI ID format to search for DRM cards 441 | // Look for /sys/class/drm/card* that match our PCI device 442 | drmCards, err := filepath.Glob("/sys/class/drm/card*") 443 | if err != nil { 444 | return 0, "unknown" 445 | } 446 | 447 | for _, card := range drmCards { 448 | // Check if this card's device driver matches our target 449 | devicePath := filepath.Join(card, "device") 450 | 451 | // Check if this device matches our PCI ID 452 | vendorFile := filepath.Join(devicePath, "vendor") 453 | deviceFile := filepath.Join(devicePath, "device") 454 | 455 | vendorBytes, err1 := os.ReadFile(vendorFile) 456 | deviceBytes, err2 := os.ReadFile(deviceFile) 457 | 458 | if err1 != nil || err2 != nil { 459 | continue 460 | } 461 | 462 | vendorId := strings.TrimSpace(string(vendorBytes)) 463 | deviceId := strings.TrimSpace(string(deviceBytes)) 464 | 465 | // Remove 0x prefix if present 466 | vendorId = strings.TrimPrefix(vendorId, "0x") 467 | deviceId = strings.TrimPrefix(deviceId, "0x") 468 | 469 | // Construct the PCI ID in the format vvvv:dddd 470 | cardPciId := fmt.Sprintf("%s:%s", vendorId, deviceId) 471 | 472 | // Only proceed if this card matches our target PCI ID 473 | if cardPciId != pciId { 474 | continue 475 | } 476 | 477 | driverPath := filepath.Join(devicePath, "driver") 478 | if _, err := os.Stat(driverPath); os.IsNotExist(err) { 479 | continue 480 | } 481 | 482 | // Look for hwmon directory under this device 483 | hwmonGlob := filepath.Join(devicePath, "hwmon", "hwmon*") 484 | hwmonDirs, err := filepath.Glob(hwmonGlob) 485 | if err != nil { 486 | continue 487 | } 488 | 489 | for _, hwmonDir := range hwmonDirs { 490 | tempFile := filepath.Join(hwmonDir, "temp1_input") 491 | if _, err := os.Stat(tempFile); os.IsNotExist(err) { 492 | continue 493 | } 494 | 495 | tempBytes, err := os.ReadFile(tempFile) 496 | if err != nil { 497 | continue 498 | } 499 | 500 | tempStr := strings.TrimSpace(string(tempBytes)) 501 | if tempInt, err := strconv.Atoi(tempStr); err == nil { 502 | hwmonName := filepath.Base(hwmonDir) 503 | return float64(tempInt) / 1000.0, hwmonName 504 | } 505 | } 506 | } 507 | 508 | return 0, "unknown" 509 | } 510 | 511 | func readFile(path string) (string, error) { 512 | data, err := os.ReadFile(path) 513 | if err != nil { 514 | return "", err 515 | } 516 | return string(data), nil 517 | } 518 | -------------------------------------------------------------------------------- /gops/memory.go: -------------------------------------------------------------------------------- 1 | package gops 2 | 3 | import ( 4 | "github.com/AvengeMedia/dgop/models" 5 | "github.com/shirou/gopsutil/v4/mem" 6 | ) 7 | 8 | func (self *GopsUtil) GetMemoryInfo() (*models.MemoryInfo, error) { 9 | v, err := mem.VirtualMemory() 10 | if err != nil { 11 | return nil, err 12 | } 13 | 14 | return &models.MemoryInfo{ 15 | Total: v.Total / 1024, 16 | Free: v.Free / 1024, 17 | Available: v.Available / 1024, 18 | Buffers: v.Buffers / 1024, 19 | Cached: v.Cached / 1024, 20 | Shared: v.Shared / 1024, 21 | SwapTotal: v.SwapTotal / 1024, 22 | SwapFree: v.SwapFree / 1024, 23 | }, nil 24 | } 25 | -------------------------------------------------------------------------------- /gops/meta.go: -------------------------------------------------------------------------------- 1 | package gops 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/AvengeMedia/dgop/models" 8 | ) 9 | 10 | var availableModules = []string{ 11 | "cpu", 12 | "memory", 13 | "network", 14 | "net-rate", 15 | "disk", 16 | "disk-rate", 17 | "diskmounts", 18 | "processes", 19 | "system", 20 | "hardware", 21 | "gpu", 22 | "gpu-temp", 23 | } 24 | 25 | func (self *GopsUtil) GetModules() (*models.ModulesInfo, error) { 26 | return &models.ModulesInfo{ 27 | Available: availableModules, 28 | }, nil 29 | } 30 | 31 | type MetaParams struct { 32 | SortBy ProcSortBy 33 | ProcLimit int 34 | EnableCPU bool 35 | GPUPciIds []string 36 | CPUCursor string 37 | ProcCursor string 38 | NetRateCursor string 39 | DiskRateCursor string 40 | } 41 | 42 | func (self *GopsUtil) GetMeta(modules []string, params MetaParams) (*models.MetaInfo, error) { 43 | meta := &models.MetaInfo{} 44 | 45 | for _, module := range modules { 46 | switch strings.ToLower(module) { 47 | case "all": 48 | // Load all modules 49 | return self.loadAllModules(params) 50 | case "cpu": 51 | if cpu, err := self.GetCPUInfoWithCursor(params.CPUCursor); err == nil { 52 | meta.CPU = cpu 53 | } 54 | case "memory": 55 | if mem, err := self.GetMemoryInfo(); err == nil { 56 | meta.Memory = mem 57 | } 58 | case "network": 59 | if net, err := self.GetNetworkInfo(); err == nil { 60 | meta.Network = net 61 | } 62 | case "net-rate": 63 | if netRate, err := self.GetNetworkRates(params.NetRateCursor); err == nil { 64 | meta.NetRate = netRate 65 | } 66 | case "disk": 67 | if disk, err := self.GetDiskInfo(); err == nil { 68 | meta.Disk = disk 69 | } 70 | case "disk-rate": 71 | if diskRate, err := self.GetDiskRates(params.DiskRateCursor); err == nil { 72 | meta.DiskRate = diskRate 73 | } 74 | case "diskmounts": 75 | if mounts, err := self.GetDiskMounts(); err == nil { 76 | meta.DiskMounts = mounts 77 | } 78 | case "processes": 79 | if result, err := self.GetProcessesWithCursor(params.SortBy, params.ProcLimit, params.EnableCPU, params.ProcCursor); err == nil { 80 | meta.Processes = result.Processes 81 | } 82 | case "system": 83 | if sys, err := self.GetSystemInfo(); err == nil { 84 | meta.System = sys 85 | } 86 | case "hardware": 87 | if hw, err := self.GetSystemHardware(); err == nil { 88 | meta.Hardware = hw 89 | } 90 | case "gpu": 91 | // GPU module with optional temperature 92 | if gpu, err := self.GetGPUInfoWithTemp(params.GPUPciIds); err == nil { 93 | meta.GPU = gpu 94 | } 95 | case "gpu-temp": 96 | // GPU temperature only module 97 | if gpu, err := self.GetGPUInfoWithTemp(params.GPUPciIds); err == nil { 98 | meta.GPU = gpu 99 | } 100 | default: 101 | return nil, fmt.Errorf("unknown module: %s", module) 102 | } 103 | } 104 | 105 | return meta, nil 106 | } 107 | 108 | func (self *GopsUtil) loadAllModules(params MetaParams) (*models.MetaInfo, error) { 109 | meta := &models.MetaInfo{} 110 | 111 | // Load all modules (ignore errors for individual modules) 112 | if cpu, err := self.GetCPUInfoWithCursor(params.CPUCursor); err == nil { 113 | meta.CPU = cpu 114 | } 115 | 116 | if mem, err := self.GetMemoryInfo(); err == nil { 117 | meta.Memory = mem 118 | } 119 | 120 | if net, err := self.GetNetworkInfo(); err == nil { 121 | meta.Network = net 122 | } 123 | 124 | if netRate, err := self.GetNetworkRates(params.NetRateCursor); err == nil { 125 | meta.NetRate = netRate 126 | } 127 | 128 | if disk, err := self.GetDiskInfo(); err == nil { 129 | meta.Disk = disk 130 | } 131 | 132 | if diskRate, err := self.GetDiskRates(params.DiskRateCursor); err == nil { 133 | meta.DiskRate = diskRate 134 | } 135 | 136 | if mounts, err := self.GetDiskMounts(); err == nil { 137 | meta.DiskMounts = mounts 138 | } 139 | 140 | if result, err := self.GetProcessesWithCursor(params.SortBy, params.ProcLimit, params.EnableCPU, params.ProcCursor); err == nil { 141 | meta.Processes = result.Processes 142 | } 143 | 144 | if sys, err := self.GetSystemInfo(); err == nil { 145 | meta.System = sys 146 | } 147 | 148 | if hw, err := self.GetSystemHardware(); err == nil { 149 | meta.Hardware = hw 150 | } 151 | 152 | if gpu, err := self.GetGPUInfoWithTemp(params.GPUPciIds); err == nil { 153 | meta.GPU = gpu 154 | } 155 | 156 | return meta, nil 157 | } 158 | -------------------------------------------------------------------------------- /gops/net.go: -------------------------------------------------------------------------------- 1 | package gops 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/AvengeMedia/dgop/models" 7 | "github.com/shirou/gopsutil/v4/net" 8 | ) 9 | 10 | func (self *GopsUtil) GetNetworkInfo() ([]*models.NetworkInfo, error) { 11 | netIO, err := net.IOCounters(true) 12 | res := make([]*models.NetworkInfo, 0) 13 | if err == nil { 14 | for _, n := range netIO { 15 | // Filter to match bash script (wlan, wlo, wlp, eth, eno, enp, ens, lxc) 16 | if matchesNetworkInterface(n.Name) { 17 | res = append(res, &models.NetworkInfo{ 18 | Name: n.Name, 19 | Rx: n.BytesRecv, 20 | Tx: n.BytesSent, 21 | }) 22 | } 23 | } 24 | } 25 | return res, nil 26 | } 27 | 28 | func matchesNetworkInterface(name string) bool { 29 | prefixes := []string{"wlan", "wlo", "wlp", "eth", "eno", "enp", "ens", "lxc"} 30 | for _, prefix := range prefixes { 31 | if strings.HasPrefix(name, prefix) { 32 | return true 33 | } 34 | } 35 | return false 36 | } 37 | -------------------------------------------------------------------------------- /gops/netrate.go: -------------------------------------------------------------------------------- 1 | package gops 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "time" 7 | 8 | "github.com/AvengeMedia/dgop/models" 9 | "github.com/shirou/gopsutil/v4/net" 10 | ) 11 | 12 | type NetworkRateCursor struct { 13 | Timestamp time.Time `json:"timestamp"` 14 | IOStats map[string]net.IOCountersStat `json:"iostats"` 15 | } 16 | 17 | func (self *GopsUtil) GetNetworkRates(cursorStr string) (*models.NetworkRateResponse, error) { 18 | // Get current network stats 19 | netIO, err := net.IOCounters(true) 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | currentStats := make(map[string]net.IOCountersStat) 25 | for _, n := range netIO { 26 | if matchesNetworkInterface(n.Name) { 27 | currentStats[n.Name] = n 28 | } 29 | } 30 | 31 | currentTime := time.Now() 32 | interfaces := make([]*models.NetworkRateInfo, 0) 33 | 34 | // If we have a cursor, calculate rates 35 | if cursorStr != "" { 36 | cursor, err := parseNetworkRateCursor(cursorStr) 37 | if err == nil { 38 | timeDiff := currentTime.Sub(cursor.Timestamp).Seconds() 39 | if timeDiff > 0 { 40 | for name, current := range currentStats { 41 | if prev, exists := cursor.IOStats[name]; exists { 42 | rxRate := float64(current.BytesRecv-prev.BytesRecv) / timeDiff 43 | txRate := float64(current.BytesSent-prev.BytesSent) / timeDiff 44 | 45 | interfaces = append(interfaces, &models.NetworkRateInfo{ 46 | Interface: name, 47 | RxRate: rxRate, 48 | TxRate: txRate, 49 | RxTotal: current.BytesRecv, 50 | TxTotal: current.BytesSent, 51 | }) 52 | } 53 | } 54 | } 55 | } 56 | } 57 | 58 | // If no cursor or no rates calculated, return zero rates 59 | if len(interfaces) == 0 { 60 | for name, current := range currentStats { 61 | interfaces = append(interfaces, &models.NetworkRateInfo{ 62 | Interface: name, 63 | RxRate: 0, 64 | TxRate: 0, 65 | RxTotal: current.BytesRecv, 66 | TxTotal: current.BytesSent, 67 | }) 68 | } 69 | } 70 | 71 | // Create new cursor 72 | newCursor := NetworkRateCursor{ 73 | Timestamp: currentTime, 74 | IOStats: currentStats, 75 | } 76 | 77 | newCursorStr, err := encodeNetworkRateCursor(newCursor) 78 | if err != nil { 79 | return nil, err 80 | } 81 | 82 | return &models.NetworkRateResponse{ 83 | Interfaces: interfaces, 84 | Cursor: newCursorStr, 85 | }, nil 86 | } 87 | 88 | func encodeNetworkRateCursor(cursor NetworkRateCursor) (string, error) { 89 | jsonData, err := json.Marshal(cursor) 90 | if err != nil { 91 | return "", err 92 | } 93 | return base64.StdEncoding.EncodeToString(jsonData), nil 94 | } 95 | 96 | func parseNetworkRateCursor(cursorStr string) (NetworkRateCursor, error) { 97 | var cursor NetworkRateCursor 98 | 99 | jsonData, err := base64.StdEncoding.DecodeString(cursorStr) 100 | if err != nil { 101 | return cursor, err 102 | } 103 | 104 | err = json.Unmarshal(jsonData, &cursor) 105 | return cursor, err 106 | } 107 | -------------------------------------------------------------------------------- /gops/processes.go: -------------------------------------------------------------------------------- 1 | package gops 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "reflect" 7 | "runtime" 8 | "sort" 9 | "time" 10 | 11 | "github.com/AvengeMedia/dgop/models" 12 | "github.com/danielgtaylor/huma/v2" 13 | "github.com/shirou/gopsutil/v4/mem" 14 | "github.com/shirou/gopsutil/v4/process" 15 | ) 16 | 17 | func (self *GopsUtil) GetProcesses(sortBy ProcSortBy, limit int, enableCPU bool) (*models.ProcessListResponse, error) { 18 | return self.GetProcessesWithCursor(sortBy, limit, enableCPU, "") 19 | } 20 | 21 | func (self *GopsUtil) GetProcessesWithCursor(sortBy ProcSortBy, limit int, enableCPU bool, cursor string) (*models.ProcessListResponse, error) { 22 | procs, err := process.Processes() 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | procList := make([]*models.ProcessInfo, 0) 28 | totalMem, _ := mem.VirtualMemory() 29 | currentTime := time.Now().UnixMilli() 30 | 31 | // Decode cursor string into cursor data map 32 | cursorMap := make(map[int32]*models.ProcessCursorData) 33 | if cursor != "" { 34 | jsonBytes, err := base64.RawURLEncoding.DecodeString(cursor) 35 | if err == nil { 36 | var cursors []models.ProcessCursorData 37 | if json.Unmarshal(jsonBytes, &cursors) == nil { 38 | for i := range cursors { 39 | cursorMap[cursors[i].PID] = &cursors[i] 40 | } 41 | } 42 | } 43 | } 44 | 45 | // CPU measurement setup - only if enabled and no cursor data provided 46 | if enableCPU && len(cursorMap) == 0 { 47 | // First pass: Initialize CPU measurement for all processes 48 | for _, p := range procs { 49 | p.CPUPercent() // Initialize 50 | } 51 | 52 | // Wait for measurement period (1 second for more accurate readings) 53 | time.Sleep(1000 * time.Millisecond) 54 | } 55 | 56 | for _, p := range procs { 57 | // Get process info 58 | name, _ := p.Name() 59 | cmdline, _ := p.Cmdline() 60 | ppid, _ := p.Ppid() 61 | memInfo, _ := p.MemoryInfo() 62 | times, _ := p.Times() 63 | username, _ := p.Username() 64 | 65 | // Calculate current CPU time in seconds (gopsutil already converts to seconds) 66 | currentCPUTime := float64(0) 67 | if times != nil { 68 | currentCPUTime = times.User + times.System 69 | } 70 | 71 | // Get CPU percentage only if enabled 72 | cpuPercent := 0.0 73 | if enableCPU { 74 | if cursorData, hasCursor := cursorMap[p.Pid]; hasCursor { 75 | // Use cursor data to calculate CPU percentage per core 76 | cpuPercent = calculateProcessCPUPercentageWithCursor(cursorData, currentCPUTime, currentTime) 77 | } else { 78 | // Fallback to gopsutil measurement (normalize to per-CPU like htop) 79 | rawCpuPercent, _ := p.CPUPercent() 80 | cpuPercent = rawCpuPercent / float64(runtime.NumCPU()) 81 | } 82 | } 83 | 84 | // Calculate memory percentage and KB 85 | memKB := uint64(0) 86 | memPercent := float32(0) 87 | if memInfo != nil { 88 | memKB = memInfo.RSS / 1024 89 | memPercent = float32(memInfo.RSS) / float32(totalMem.Total) * 100 90 | } 91 | 92 | procList = append(procList, &models.ProcessInfo{ 93 | PID: p.Pid, 94 | PPID: ppid, 95 | CPU: cpuPercent, 96 | PTicks: currentCPUTime, 97 | MemoryPercent: memPercent, 98 | MemoryKB: memKB, 99 | PSSKB: memKB, 100 | PSSPercent: memPercent, 101 | Username: username, 102 | Command: name, 103 | FullCommand: cmdline, 104 | }) 105 | } 106 | 107 | // Sort processes 108 | switch sortBy { 109 | case SortByCPU: 110 | sort.Slice(procList, func(i, j int) bool { 111 | return procList[i].CPU > procList[j].CPU 112 | }) 113 | case SortByMemory: 114 | sort.Slice(procList, func(i, j int) bool { 115 | return procList[i].MemoryPercent > procList[j].MemoryPercent 116 | }) 117 | case SortByName: 118 | sort.Slice(procList, func(i, j int) bool { 119 | return procList[i].Command < procList[j].Command 120 | }) 121 | case SortByPID: 122 | sort.Slice(procList, func(i, j int) bool { 123 | return procList[i].PID < procList[j].PID 124 | }) 125 | default: 126 | sort.Slice(procList, func(i, j int) bool { 127 | return procList[i].CPU > procList[j].CPU 128 | }) 129 | } 130 | 131 | // Limit to MaxProcs 132 | if limit > 0 && len(procList) > limit { 133 | procList = procList[:limit] 134 | } 135 | 136 | // Create cursor data for all processes 137 | cursorList := make([]models.ProcessCursorData, 0, len(procList)) 138 | for _, proc := range procList { 139 | cursorList = append(cursorList, models.ProcessCursorData{ 140 | PID: proc.PID, 141 | Ticks: proc.PTicks, 142 | Timestamp: currentTime, 143 | }) 144 | } 145 | 146 | // Encode cursor list as single base64 string 147 | cursorBytes, _ := json.Marshal(cursorList) 148 | cursorStr := base64.RawURLEncoding.EncodeToString(cursorBytes) 149 | 150 | return &models.ProcessListResponse{ 151 | Processes: procList, 152 | Cursor: cursorStr, 153 | }, nil 154 | } 155 | 156 | type ProcSortBy string 157 | 158 | const ( 159 | SortByCPU ProcSortBy = "cpu" 160 | SortByMemory ProcSortBy = "memory" 161 | SortByName ProcSortBy = "name" 162 | SortByPID ProcSortBy = "pid" 163 | ) 164 | 165 | // Register enum in OpenAPI specification 166 | // https://github.com/danielgtaylor/huma/issues/621 167 | func (u ProcSortBy) Schema(r huma.Registry) *huma.Schema { 168 | if r.Map()["ProcSortBy"] == nil { 169 | schemaRef := r.Schema(reflect.TypeOf(""), true, "ProcSortBy") 170 | schemaRef.Title = "ProcSortBy" 171 | schemaRef.Enum = append(schemaRef.Enum, []any{ 172 | string(SortByCPU), 173 | string(SortByMemory), 174 | string(SortByName), 175 | string(SortByPID), 176 | }...) 177 | r.Map()["ProcSortBy"] = schemaRef 178 | } 179 | return &huma.Schema{Ref: "#/components/schemas/ProcSortBy"} 180 | } 181 | 182 | func calculateProcessCPUPercentageWithCursor(cursor *models.ProcessCursorData, currentCPUTime float64, currentTime int64) float64 { 183 | if cursor.Timestamp == 0 || currentCPUTime <= cursor.Ticks { 184 | return 0 185 | } 186 | 187 | cpuTimeDiff := currentCPUTime - cursor.Ticks 188 | wallTimeDiff := float64(currentTime-cursor.Timestamp) / 1000.0 189 | 190 | if wallTimeDiff <= 0 { 191 | return 0 192 | } 193 | 194 | cpuPercent := (cpuTimeDiff / wallTimeDiff) * 100.0 195 | 196 | if cpuPercent > 100.0 { 197 | cpuPercent = 100.0 198 | } 199 | if cpuPercent < 0 { 200 | cpuPercent = 0 201 | } 202 | 203 | return cpuPercent 204 | } 205 | -------------------------------------------------------------------------------- /gops/system.go: -------------------------------------------------------------------------------- 1 | package gops 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/AvengeMedia/dgop/models" 8 | "github.com/shirou/gopsutil/v4/host" 9 | "github.com/shirou/gopsutil/v4/load" 10 | "github.com/shirou/gopsutil/v4/process" 11 | ) 12 | 13 | func (self *GopsUtil) GetSystemInfo() (*models.SystemInfo, error) { 14 | // System info 15 | loadAvg, _ := load.Avg() 16 | procs, _ := process.Pids() 17 | bootTime, _ := host.BootTime() 18 | 19 | // Count threads (approximation - gopsutil doesn't expose this directly) 20 | threadCount := 0 21 | for _, p := range procs { 22 | proc, err := process.NewProcess(p) 23 | if err == nil { 24 | threads, _ := proc.NumThreads() 25 | threadCount += int(threads) 26 | } 27 | } 28 | 29 | return &models.SystemInfo{ 30 | LoadAvg: fmt.Sprintf("%.2f %.2f %.2f", loadAvg.Load1, loadAvg.Load5, loadAvg.Load15), 31 | Processes: len(procs), 32 | Threads: threadCount, 33 | BootTime: time.Unix(int64(bootTime), 0).Format("2006-01-02 15:04:05"), 34 | }, nil 35 | } 36 | -------------------------------------------------------------------------------- /internal/log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "os" 5 | "sync" 6 | 7 | "github.com/charmbracelet/lipgloss" 8 | cblog "github.com/charmbracelet/log" 9 | ) 10 | 11 | // Logger embeds the Charm Logger and adds Printf/Fatalf 12 | type Logger struct{ *cblog.Logger } 13 | 14 | // Printf routes goose/info-style logs through Infof. 15 | func (l *Logger) Printf(format string, v ...interface{}) { l.Infof(format, v...) } 16 | 17 | // Fatalf keeps goose’s contract of exiting the program. 18 | func (l *Logger) Fatalf(format string, v ...interface{}) { l.Fatalf(format, v...) } 19 | 20 | var ( 21 | logger *Logger 22 | initLogger sync.Once 23 | ) 24 | 25 | // GetLogger returns a logger instance 26 | func GetLogger() *Logger { 27 | initLogger.Do(func() { 28 | styles := cblog.DefaultStyles() 29 | styles.Levels[cblog.FatalLevel] = lipgloss.NewStyle().SetString("FATAL") 30 | styles.Levels[cblog.ErrorLevel] = lipgloss.NewStyle().SetString("ERROR") 31 | styles.Levels[cblog.WarnLevel] = lipgloss.NewStyle().SetString("WARN") 32 | styles.Levels[cblog.InfoLevel] = lipgloss.NewStyle().SetString("INFO") 33 | 34 | base := cblog.New(os.Stderr) 35 | base.SetStyles(styles) 36 | base.SetReportTimestamp(false) 37 | 38 | logger = &Logger{base} 39 | }) 40 | return logger 41 | } 42 | 43 | // * Convenience wrappers 44 | 45 | func Info(msg interface{}, keyvals ...interface{}) { GetLogger().Logger.Info(msg, keyvals...) } 46 | func Infof(format string, v ...interface{}) { GetLogger().Logger.Infof(format, v...) } 47 | func Warn(msg interface{}, keyvals ...interface{}) { GetLogger().Logger.Warn(msg, keyvals...) } 48 | func Warnf(format string, v ...interface{}) { GetLogger().Logger.Warnf(format, v...) } 49 | func Error(msg interface{}, keyvals ...interface{}) { GetLogger().Logger.Error(msg, keyvals...) } 50 | func Errorf(format string, v ...interface{}) { GetLogger().Logger.Errorf(format, v...) } 51 | func Fatal(msg interface{}, keyvals ...interface{}) { GetLogger().Logger.Fatal(msg, keyvals...) } 52 | func Fatalf(format string, v ...interface{}) { GetLogger().Logger.Fatalf(format, v...) } 53 | -------------------------------------------------------------------------------- /models/colors.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type ColorPalette struct { 4 | UI UIColors `json:"ui"` 5 | Charts ChartColors `json:"charts"` 6 | ProgressBars ProgressBarColors `json:"progress_bars"` 7 | Temperature TemperatureColors `json:"temperature"` 8 | Status StatusColors `json:"status"` 9 | } 10 | 11 | type UIColors struct { 12 | BorderPrimary string `json:"border_primary"` 13 | BorderSecondary string `json:"border_secondary"` 14 | HeaderBackground string `json:"header_background"` 15 | HeaderText string `json:"header_text"` 16 | FooterBackground string `json:"footer_background"` 17 | FooterText string `json:"footer_text"` 18 | TextPrimary string `json:"text_primary"` 19 | TextSecondary string `json:"text_secondary"` 20 | TextAccent string `json:"text_accent"` 21 | SelectionBackground string `json:"selection_background"` 22 | SelectionText string `json:"selection_text"` 23 | } 24 | 25 | type ChartColors struct { 26 | NetworkDownload string `json:"network_download"` 27 | NetworkUpload string `json:"network_upload"` 28 | NetworkLine string `json:"network_line"` 29 | CPUCoreLow string `json:"cpu_core_low"` 30 | CPUCoreMedium string `json:"cpu_core_medium"` 31 | CPUCoreHigh string `json:"cpu_core_high"` 32 | DiskRead string `json:"disk_read"` 33 | DiskWrite string `json:"disk_write"` 34 | } 35 | 36 | type ProgressBarColors struct { 37 | MemoryLow string `json:"memory_low"` 38 | MemoryMedium string `json:"memory_medium"` 39 | MemoryHigh string `json:"memory_high"` 40 | DiskLow string `json:"disk_low"` 41 | DiskMedium string `json:"disk_medium"` 42 | DiskHigh string `json:"disk_high"` 43 | CPULow string `json:"cpu_low"` 44 | CPUMedium string `json:"cpu_medium"` 45 | CPUHigh string `json:"cpu_high"` 46 | ProgressBackground string `json:"progress_background"` 47 | } 48 | 49 | type TemperatureColors struct { 50 | Cold string `json:"cold"` 51 | Warm string `json:"warm"` 52 | Hot string `json:"hot"` 53 | Danger string `json:"danger"` 54 | } 55 | 56 | type StatusColors struct { 57 | Success string `json:"success"` 58 | Warning string `json:"warning"` 59 | Error string `json:"error"` 60 | Info string `json:"info"` 61 | } 62 | 63 | // DefaultColorPalette returns the default color palette matching current dgop colors 64 | func DefaultColorPalette() *ColorPalette { 65 | return &ColorPalette{ 66 | UI: UIColors{ 67 | BorderPrimary: "#8B5FBF", 68 | BorderSecondary: "#6B46C1", 69 | HeaderBackground: "#7D56F4", 70 | HeaderText: "#FAFAFA", 71 | FooterBackground: "#2A2A2A", 72 | FooterText: "#7C7C7C", 73 | TextPrimary: "#FAFAFA", 74 | TextSecondary: "#C9C9C9", 75 | TextAccent: "#8B5FBF", 76 | SelectionBackground: "#7D56F4", 77 | SelectionText: "#FAFAFA", 78 | }, 79 | Charts: ChartColors{ 80 | NetworkDownload: "#A855F7", 81 | NetworkUpload: "#8B5FBF", 82 | NetworkLine: "#6B46C1", 83 | CPUCoreLow: "#8B5FBF", 84 | CPUCoreMedium: "#A855F7", 85 | CPUCoreHigh: "#D946EF", 86 | DiskRead: "#A855F7", 87 | DiskWrite: "#8B5FBF", 88 | }, 89 | ProgressBars: ProgressBarColors{ 90 | MemoryLow: "#8B5FBF", 91 | MemoryMedium: "#A855F7", 92 | MemoryHigh: "#D946EF", 93 | DiskLow: "#8B5FBF", 94 | DiskMedium: "#A855F7", 95 | DiskHigh: "#D946EF", 96 | CPULow: "#8B5FBF", 97 | CPUMedium: "#A855F7", 98 | CPUHigh: "#D946EF", 99 | ProgressBackground: "#404040", 100 | }, 101 | Temperature: TemperatureColors{ 102 | Cold: "#8B5FBF", 103 | Warm: "#A855F7", 104 | Hot: "#D946EF", 105 | Danger: "#FF6B6B", 106 | }, 107 | Status: StatusColors{ 108 | Success: "#22C55E", 109 | Warning: "#F59E0B", 110 | Error: "#EF4444", 111 | Info: "#3B82F6", 112 | }, 113 | } 114 | } -------------------------------------------------------------------------------- /models/cpu.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type CPUInfo struct { 4 | Count int `json:"count"` 5 | Model string `json:"model"` 6 | Frequency float64 `json:"frequency"` 7 | Temperature float64 `json:"temperature"` 8 | Usage float64 `json:"usage"` 9 | CoreUsage []float64 `json:"coreUsage"` 10 | Total []float64 `json:"total"` 11 | Cores [][]float64 `json:"cores"` 12 | Cursor string `json:"cursor,omitempty"` 13 | } 14 | 15 | type CPUCursorData struct { 16 | Total []float64 `json:"total"` 17 | Cores [][]float64 `json:"cores"` 18 | Timestamp int64 `json:"timestamp"` 19 | } 20 | -------------------------------------------------------------------------------- /models/disk.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type DiskInfo struct { 4 | Name string `json:"name"` 5 | Read uint64 `json:"read"` 6 | Write uint64 `json:"write"` 7 | } 8 | 9 | type DiskMountInfo struct { 10 | Device string `json:"device"` 11 | Mount string `json:"mount"` 12 | FSType string `json:"fstype"` 13 | Size string `json:"size"` 14 | Used string `json:"used"` 15 | Avail string `json:"avail"` 16 | Percent string `json:"percent"` 17 | } 18 | 19 | type DiskRateInfo struct { 20 | Device string `json:"device"` 21 | ReadRate float64 `json:"readrate"` 22 | WriteRate float64 `json:"writerate"` 23 | ReadTotal uint64 `json:"readtotal"` 24 | WriteTotal uint64 `json:"writetotal"` 25 | ReadCount uint64 `json:"readcount"` 26 | WriteCount uint64 `json:"writecount"` 27 | } 28 | 29 | type DiskRateResponse struct { 30 | Disks []*DiskRateInfo `json:"disks"` 31 | Cursor string `json:"cursor"` 32 | } 33 | -------------------------------------------------------------------------------- /models/hardware.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type BIOSInfo struct { 4 | Vendor string `json:"vendor"` 5 | Version string `json:"version"` 6 | Date string `json:"date"` 7 | Motherboard string `json:"motherboard"` 8 | } 9 | 10 | type SystemHardware struct { 11 | Kernel string `json:"kernel"` 12 | Distro string `json:"distro"` 13 | Hostname string `json:"hostname"` 14 | Arch string `json:"arch"` 15 | CPU CPUBasic `json:"cpu"` 16 | BIOS BIOSInfo `json:"bios"` 17 | } 18 | 19 | type CPUBasic struct { 20 | Count int `json:"count"` 21 | Model string `json:"model"` 22 | } 23 | 24 | type GPU struct { 25 | Driver string `json:"driver"` 26 | Vendor string `json:"vendor"` 27 | DisplayName string `json:"displayName"` 28 | FullName string `json:"fullName"` 29 | PciId string `json:"pciId"` 30 | RawLine string `json:"rawLine"` 31 | Temperature float64 `json:"temperature"` 32 | Hwmon string `json:"hwmon"` 33 | } 34 | 35 | type GPUInfo struct { 36 | GPUs []GPU `json:"gpus"` 37 | } 38 | 39 | type GPUTempInfo struct { 40 | Driver string `json:"driver"` 41 | Hwmon string `json:"hwmon"` 42 | Temperature float64 `json:"temperature"` 43 | } 44 | 45 | type GPUTempsInfo struct { 46 | GPUTemps []GPUTempInfo `json:"gputemps"` 47 | } 48 | -------------------------------------------------------------------------------- /models/memory.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type MemoryInfo struct { 4 | Total uint64 `json:"total"` 5 | Free uint64 `json:"free"` 6 | Available uint64 `json:"available"` 7 | Buffers uint64 `json:"buffers"` 8 | Cached uint64 `json:"cached"` 9 | Shared uint64 `json:"shared"` 10 | SwapTotal uint64 `json:"swaptotal"` 11 | SwapFree uint64 `json:"swapfree"` 12 | } 13 | -------------------------------------------------------------------------------- /models/meta.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type SystemMetrics struct { 4 | Memory *MemoryInfo `json:"memory"` 5 | CPU *CPUInfo `json:"cpu"` 6 | Network []*NetworkInfo `json:"network"` 7 | Disk []*DiskInfo `json:"disk"` 8 | Processes []*ProcessInfo `json:"processes"` 9 | System *SystemInfo `json:"system"` 10 | DiskMounts []*DiskMountInfo `json:"diskmounts"` 11 | } 12 | 13 | type SystemInfo struct { 14 | LoadAvg string `json:"loadavg"` 15 | Processes int `json:"processes"` 16 | Threads int `json:"threads"` 17 | BootTime string `json:"boottime"` 18 | } 19 | 20 | type MetaInfo struct { 21 | CPU *CPUInfo `json:"cpu,omitempty"` 22 | Memory *MemoryInfo `json:"memory,omitempty"` 23 | Network []*NetworkInfo `json:"network,omitempty"` 24 | NetRate *NetworkRateResponse `json:"netrate,omitempty"` 25 | Disk []*DiskInfo `json:"disk,omitempty"` 26 | DiskRate *DiskRateResponse `json:"diskrate,omitempty"` 27 | DiskMounts []*DiskMountInfo `json:"diskmounts,omitempty"` 28 | Processes []*ProcessInfo `json:"processes,omitempty"` 29 | System *SystemInfo `json:"system,omitempty"` 30 | Hardware *SystemHardware `json:"hardware,omitempty"` 31 | GPU *GPUInfo `json:"gpu,omitempty"` 32 | } 33 | 34 | type ModulesInfo struct { 35 | Available []string `json:"available"` 36 | } 37 | -------------------------------------------------------------------------------- /models/network.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type NetworkInfo struct { 4 | Name string `json:"name"` 5 | Rx uint64 `json:"rx"` 6 | Tx uint64 `json:"tx"` 7 | } 8 | 9 | type NetworkRateInfo struct { 10 | Interface string `json:"interface"` 11 | RxRate float64 `json:"rxrate"` 12 | TxRate float64 `json:"txrate"` 13 | RxTotal uint64 `json:"rxtotal"` 14 | TxTotal uint64 `json:"txtotal"` 15 | } 16 | 17 | type NetworkRateResponse struct { 18 | Interfaces []*NetworkRateInfo `json:"interfaces"` 19 | Cursor string `json:"cursor"` 20 | } 21 | -------------------------------------------------------------------------------- /models/process.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type ProcessInfo struct { 4 | PID int32 `json:"pid"` 5 | PPID int32 `json:"ppid"` 6 | CPU float64 `json:"cpu"` 7 | PTicks float64 `json:"pticks"` 8 | MemoryPercent float32 `json:"memoryPercent"` 9 | MemoryKB uint64 `json:"memoryKB"` 10 | PSSKB uint64 `json:"pssKB"` 11 | PSSPercent float32 `json:"pssPercent"` 12 | Username string `json:"username"` 13 | Command string `json:"command"` 14 | FullCommand string `json:"fullCommand"` 15 | } 16 | 17 | type ProcessCursorData struct { 18 | PID int32 `json:"pid"` 19 | Ticks float64 `json:"ticks"` 20 | Timestamp int64 `json:"timestamp"` 21 | } 22 | 23 | type ProcessListResponse struct { 24 | Processes []*ProcessInfo `json:"processes"` 25 | Cursor string `json:"cursor,omitempty"` 26 | } 27 | -------------------------------------------------------------------------------- /models/temperature.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type TemperatureSensor struct { 4 | Name string `json:"name"` 5 | Temperature float64 `json:"temperature"` 6 | High float64 `json:"high"` 7 | Critical float64 `json:"critical"` 8 | } 9 | -------------------------------------------------------------------------------- /utils/net_headers.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "net/http" 4 | 5 | func GetIPAddress(r *http.Request) string { 6 | IPAddress := r.Header.Get("CF-Connecting-IP") 7 | if IPAddress == "" { 8 | IPAddress = r.Header.Get("X-Real-Ip") 9 | } 10 | if IPAddress == "" { 11 | IPAddress = r.Header.Get("X-Forwarded-For") 12 | } 13 | if IPAddress == "" { 14 | IPAddress = r.RemoteAddr 15 | } 16 | return IPAddress 17 | } 18 | -------------------------------------------------------------------------------- /utils/net_headers_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestGetIPAddressFromHeader(t *testing.T) { 12 | ip := "123.45.67.89" 13 | 14 | // 4 methods of getting IP Address, CF-Connecting-IP preferred, X-Real-Ip, then X-Forwarded-For, then RemoteAddr 15 | 16 | request, _ := http.NewRequest(http.MethodPost, "appditto.com", bytes.NewReader([]byte(""))) 17 | request.Header.Set("CF-Connecting-IP", ip) 18 | request.Header.Set("X-Real-Ip", "not-the-ip") 19 | request.Header.Set("X-Forwarded-For", "not-the-ip") 20 | assert.Equal(t, ip, GetIPAddress(request)) 21 | 22 | request, _ = http.NewRequest(http.MethodPost, "appditto.com", bytes.NewReader([]byte(""))) 23 | request.Header.Set("X-Real-Ip", ip) 24 | request.Header.Set("X-Forwarded-For", "not-the-ip") 25 | 26 | assert.Equal(t, ip, GetIPAddress(request)) 27 | 28 | request, _ = http.NewRequest(http.MethodPost, "appditto.com", bytes.NewReader([]byte(""))) 29 | request.Header.Set("X-Forwarded-For", ip) 30 | assert.Equal(t, ip, GetIPAddress(request)) 31 | 32 | request, _ = http.NewRequest(http.MethodPost, "appditto.com", bytes.NewReader([]byte(""))) 33 | request.RemoteAddr = ip 34 | assert.Equal(t, ip, GetIPAddress(request)) 35 | } 36 | --------------------------------------------------------------------------------