mirror of
https://github.com/tw93/Mole.git
synced 2026-02-04 16:49:41 +00:00
feat(windows): add Windows support Phase 3 - TUI tools
Add Go-based TUI tools for Windows: - cmd/analyze: Interactive disk space analyzer with Bubble Tea - cmd/status: Real-time system health monitor using gopsutil/WMI - bin/analyze.ps1: Wrapper script for analyze tool - bin/status.ps1: Wrapper script for status tool - Makefile: Build automation for Go tools - Updated README.md with Phase 3 documentation
This commit is contained in:
16
windows/.gitignore
vendored
Normal file
16
windows/.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
# Windows Mole - .gitignore
|
||||
|
||||
# Build artifacts
|
||||
bin/*.exe
|
||||
|
||||
# Go build cache
|
||||
.gocache/
|
||||
|
||||
# IDE files
|
||||
.idea/
|
||||
.vscode/
|
||||
*.code-workspace
|
||||
|
||||
# Test artifacts
|
||||
*.test
|
||||
coverage.out
|
||||
44
windows/Makefile
Normal file
44
windows/Makefile
Normal file
@@ -0,0 +1,44 @@
|
||||
# Mole Windows - Makefile
|
||||
# Build Go tools for Windows
|
||||
|
||||
.PHONY: all build clean analyze status
|
||||
|
||||
# Default target
|
||||
all: build
|
||||
|
||||
# Build both tools
|
||||
build: analyze status
|
||||
|
||||
# Build analyze tool
|
||||
analyze:
|
||||
@echo "Building analyze..."
|
||||
@go build -o bin/analyze.exe ./cmd/analyze/
|
||||
|
||||
# Build status tool
|
||||
status:
|
||||
@echo "Building status..."
|
||||
@go build -o bin/status.exe ./cmd/status/
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
@echo "Cleaning..."
|
||||
@rm -f bin/analyze.exe bin/status.exe
|
||||
|
||||
# Install (copy to PATH)
|
||||
install: build
|
||||
@echo "Installing to $(USERPROFILE)/bin..."
|
||||
@mkdir -p "$(USERPROFILE)/bin"
|
||||
@cp bin/analyze.exe "$(USERPROFILE)/bin/"
|
||||
@cp bin/status.exe "$(USERPROFILE)/bin/"
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
@go test -v ./...
|
||||
|
||||
# Format code
|
||||
fmt:
|
||||
@go fmt ./...
|
||||
|
||||
# Vet code
|
||||
vet:
|
||||
@go vet ./...
|
||||
@@ -6,7 +6,7 @@ Windows support for [Mole](https://github.com/tw93/Mole) - A system maintenance
|
||||
|
||||
- Windows 10/11
|
||||
- PowerShell 5.1 or later (pre-installed on Windows 10/11)
|
||||
- Optional: Go 1.24+ (for building TUI tools)
|
||||
- Go 1.24+ (for building TUI tools)
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -48,14 +48,35 @@ mole -ShowHelp
|
||||
|
||||
# Show version
|
||||
mole -Version
|
||||
|
||||
# Commands
|
||||
mole clean # Deep system cleanup
|
||||
mole clean -DryRun # Preview cleanup without deleting
|
||||
mole uninstall # Interactive app uninstaller
|
||||
mole optimize # System optimization
|
||||
mole purge # Clean developer artifacts
|
||||
mole analyze # Disk space analyzer
|
||||
mole status # System health monitor
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `clean` | Deep cleanup of temp files, caches, and logs |
|
||||
| `uninstall` | Interactive application uninstaller |
|
||||
| `optimize` | System optimization and health checks |
|
||||
| `purge` | Clean project build artifacts (node_modules, etc.) |
|
||||
| `analyze` | Interactive disk space analyzer (TUI) |
|
||||
| `status` | Real-time system health monitor (TUI) |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `MOLE_DRY_RUN=1` | Preview changes without making them |
|
||||
| `MOLE_DEBUG=1` | Enable debug output |
|
||||
| `MO_ANALYZE_PATH` | Starting path for analyze tool |
|
||||
|
||||
## Directory Structure
|
||||
|
||||
@@ -63,15 +84,51 @@ mole -Version
|
||||
windows/
|
||||
├── mole.ps1 # Main CLI entry point
|
||||
├── install.ps1 # Windows installer
|
||||
├── Makefile # Build automation for Go tools
|
||||
├── go.mod # Go module definition
|
||||
├── go.sum # Go dependencies
|
||||
├── bin/
|
||||
│ ├── clean.ps1 # Deep cleanup orchestrator
|
||||
│ ├── uninstall.ps1 # Interactive app uninstaller
|
||||
│ ├── optimize.ps1 # System optimization
|
||||
│ ├── purge.ps1 # Project artifact cleanup
|
||||
│ ├── analyze.ps1 # Disk analyzer wrapper
|
||||
│ └── status.ps1 # Status monitor wrapper
|
||||
├── cmd/
|
||||
│ ├── analyze/ # Disk analyzer (Go TUI)
|
||||
│ │ └── main.go
|
||||
│ └── status/ # System status (Go TUI)
|
||||
│ └── main.go
|
||||
└── lib/
|
||||
└── core/
|
||||
├── base.ps1 # Core definitions and utilities
|
||||
├── common.ps1 # Common functions loader
|
||||
├── file_ops.ps1 # Safe file operations
|
||||
├── log.ps1 # Logging functions
|
||||
└── ui.ps1 # Interactive UI components
|
||||
├── core/
|
||||
│ ├── base.ps1 # Core definitions and utilities
|
||||
│ ├── common.ps1 # Common functions loader
|
||||
│ ├── file_ops.ps1 # Safe file operations
|
||||
│ ├── log.ps1 # Logging functions
|
||||
│ └── ui.ps1 # Interactive UI components
|
||||
└── clean/
|
||||
├── user.ps1 # User cleanup (temp, downloads, etc.)
|
||||
├── caches.ps1 # Browser and app caches
|
||||
├── dev.ps1 # Developer tool caches
|
||||
├── apps.ps1 # Application leftovers
|
||||
└── system.ps1 # System cleanup (requires admin)
|
||||
```
|
||||
|
||||
## Building TUI Tools
|
||||
|
||||
The analyze and status commands require Go to be installed:
|
||||
|
||||
```powershell
|
||||
cd windows
|
||||
|
||||
# Build both tools
|
||||
make build
|
||||
|
||||
# Or build individually
|
||||
go build -o bin/analyze.exe ./cmd/analyze/
|
||||
go build -o bin/status.exe ./cmd/status/
|
||||
|
||||
# The wrapper scripts will auto-build if Go is available
|
||||
```
|
||||
|
||||
## Configuration
|
||||
@@ -80,26 +137,27 @@ Mole stores its configuration in:
|
||||
- Config: `~\.config\mole\`
|
||||
- Cache: `~\.cache\mole\`
|
||||
- Whitelist: `~\.config\mole\whitelist.txt`
|
||||
- Purge paths: `~\.config\mole\purge_paths.txt`
|
||||
|
||||
## Development
|
||||
## Development Phases
|
||||
|
||||
### Phase 1: Core Infrastructure (Current)
|
||||
### Phase 1: Core Infrastructure ✅
|
||||
- [x] `install.ps1` - Windows installer
|
||||
- [x] `mole.ps1` - Main CLI entry point
|
||||
- [x] `lib/core/*` - Core utility libraries
|
||||
|
||||
### Phase 2: Cleanup Features (Planned)
|
||||
- [ ] `bin/clean.ps1` - Deep cleanup orchestrator
|
||||
- [ ] `bin/uninstall.ps1` - App removal with leftover detection
|
||||
- [ ] `bin/optimize.ps1` - Cache rebuild and service refresh
|
||||
- [ ] `bin/purge.ps1` - Aggressive cleanup mode
|
||||
- [ ] `lib/clean/*` - Cleanup modules
|
||||
### Phase 2: Cleanup Features ✅
|
||||
- [x] `bin/clean.ps1` - Deep cleanup orchestrator
|
||||
- [x] `bin/uninstall.ps1` - App removal with leftover detection
|
||||
- [x] `bin/optimize.ps1` - System optimization
|
||||
- [x] `bin/purge.ps1` - Project artifact cleanup
|
||||
- [x] `lib/clean/*` - Cleanup modules
|
||||
|
||||
### Phase 3: TUI Tools (Planned)
|
||||
- [ ] `cmd/analyze/` - Disk usage analyzer (Go)
|
||||
- [ ] `cmd/status/` - Real-time system monitor (Go)
|
||||
- [ ] `bin/analyze.ps1` - Analyzer wrapper
|
||||
- [ ] `bin/status.ps1` - Status wrapper
|
||||
### Phase 3: TUI Tools ✅
|
||||
- [x] `cmd/analyze/` - Disk usage analyzer (Go)
|
||||
- [x] `cmd/status/` - Real-time system monitor (Go)
|
||||
- [x] `bin/analyze.ps1` - Analyzer wrapper
|
||||
- [x] `bin/status.ps1` - Status wrapper
|
||||
|
||||
### Phase 4: Testing & CI (Planned)
|
||||
- [ ] `tests/` - Pester tests
|
||||
|
||||
79
windows/bin/analyze.ps1
Normal file
79
windows/bin/analyze.ps1
Normal file
@@ -0,0 +1,79 @@
|
||||
# Mole - Analyze Command
|
||||
# Disk space analyzer wrapper
|
||||
|
||||
#Requires -Version 5.1
|
||||
param(
|
||||
[Parameter(Position = 0)]
|
||||
[string]$Path,
|
||||
[switch]$ShowHelp
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Script location
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$windowsDir = Split-Path -Parent $scriptDir
|
||||
$binPath = Join-Path $windowsDir "bin\analyze.exe"
|
||||
|
||||
# Help
|
||||
function Show-AnalyzeHelp {
|
||||
$esc = [char]27
|
||||
Write-Host ""
|
||||
Write-Host "$esc[1;35mMole Analyze$esc[0m - Interactive disk space analyzer"
|
||||
Write-Host ""
|
||||
Write-Host "$esc[33mUsage:$esc[0m mole analyze [path]"
|
||||
Write-Host ""
|
||||
Write-Host "$esc[33mOptions:$esc[0m"
|
||||
Write-Host " [path] Path to analyze (default: user profile)"
|
||||
Write-Host " -ShowHelp Show this help message"
|
||||
Write-Host ""
|
||||
Write-Host "$esc[33mKeybindings:$esc[0m"
|
||||
Write-Host " Up/Down Navigate entries"
|
||||
Write-Host " Enter Enter directory"
|
||||
Write-Host " Backspace Go back"
|
||||
Write-Host " Space Multi-select"
|
||||
Write-Host " d Delete selected"
|
||||
Write-Host " f Toggle large files view"
|
||||
Write-Host " o Open in Explorer"
|
||||
Write-Host " r Refresh"
|
||||
Write-Host " q Quit"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
if ($ShowHelp) {
|
||||
Show-AnalyzeHelp
|
||||
return
|
||||
}
|
||||
|
||||
# Check if binary exists
|
||||
if (-not (Test-Path $binPath)) {
|
||||
Write-Host "Building analyze tool..." -ForegroundColor Cyan
|
||||
|
||||
$cmdDir = Join-Path $windowsDir "cmd\analyze"
|
||||
$binDir = Join-Path $windowsDir "bin"
|
||||
|
||||
if (-not (Test-Path $binDir)) {
|
||||
New-Item -ItemType Directory -Path $binDir -Force | Out-Null
|
||||
}
|
||||
|
||||
Push-Location $windowsDir
|
||||
try {
|
||||
$result = & go build -o "$binPath" "./cmd/analyze/" 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "Failed to build analyze tool: $result" -ForegroundColor Red
|
||||
Pop-Location
|
||||
return
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
|
||||
# Set path environment variable if provided
|
||||
if ($Path) {
|
||||
$env:MO_ANALYZE_PATH = $Path
|
||||
}
|
||||
|
||||
# Run the binary
|
||||
& $binPath
|
||||
73
windows/bin/status.ps1
Normal file
73
windows/bin/status.ps1
Normal file
@@ -0,0 +1,73 @@
|
||||
# Mole - Status Command
|
||||
# System status monitor wrapper
|
||||
|
||||
#Requires -Version 5.1
|
||||
param(
|
||||
[switch]$ShowHelp
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Script location
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$windowsDir = Split-Path -Parent $scriptDir
|
||||
$binPath = Join-Path $windowsDir "bin\status.exe"
|
||||
|
||||
# Help
|
||||
function Show-StatusHelp {
|
||||
$esc = [char]27
|
||||
Write-Host ""
|
||||
Write-Host "$esc[1;35mMole Status$esc[0m - Real-time system health monitor"
|
||||
Write-Host ""
|
||||
Write-Host "$esc[33mUsage:$esc[0m mole status"
|
||||
Write-Host ""
|
||||
Write-Host "$esc[33mOptions:$esc[0m"
|
||||
Write-Host " -ShowHelp Show this help message"
|
||||
Write-Host ""
|
||||
Write-Host "$esc[33mDisplays:$esc[0m"
|
||||
Write-Host " - System health score (0-100)"
|
||||
Write-Host " - CPU usage and model"
|
||||
Write-Host " - Memory and swap usage"
|
||||
Write-Host " - Disk space per drive"
|
||||
Write-Host " - Top processes by CPU"
|
||||
Write-Host " - Network interfaces"
|
||||
Write-Host ""
|
||||
Write-Host "$esc[33mKeybindings:$esc[0m"
|
||||
Write-Host " c Toggle mole animation"
|
||||
Write-Host " r Force refresh"
|
||||
Write-Host " q Quit"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
if ($ShowHelp) {
|
||||
Show-StatusHelp
|
||||
return
|
||||
}
|
||||
|
||||
# Check if binary exists
|
||||
if (-not (Test-Path $binPath)) {
|
||||
Write-Host "Building status tool..." -ForegroundColor Cyan
|
||||
|
||||
$cmdDir = Join-Path $windowsDir "cmd\status"
|
||||
$binDir = Join-Path $windowsDir "bin"
|
||||
|
||||
if (-not (Test-Path $binDir)) {
|
||||
New-Item -ItemType Directory -Path $binDir -Force | Out-Null
|
||||
}
|
||||
|
||||
Push-Location $windowsDir
|
||||
try {
|
||||
$result = & go build -o "$binPath" "./cmd/status/" 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "Failed to build status tool: $result" -ForegroundColor Red
|
||||
Pop-Location
|
||||
return
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
|
||||
# Run the binary
|
||||
& $binPath
|
||||
652
windows/cmd/analyze/main.go
Normal file
652
windows/cmd/analyze/main.go
Normal file
@@ -0,0 +1,652 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// ANSI color codes
|
||||
const (
|
||||
colorReset = "\033[0m"
|
||||
colorBold = "\033[1m"
|
||||
colorDim = "\033[2m"
|
||||
colorPurple = "\033[35m"
|
||||
colorPurpleBold = "\033[1;35m"
|
||||
colorCyan = "\033[36m"
|
||||
colorCyanBold = "\033[1;36m"
|
||||
colorYellow = "\033[33m"
|
||||
colorGreen = "\033[32m"
|
||||
colorRed = "\033[31m"
|
||||
colorGray = "\033[90m"
|
||||
colorWhite = "\033[97m"
|
||||
)
|
||||
|
||||
// Icons
|
||||
const (
|
||||
iconFolder = "📁"
|
||||
iconFile = "📄"
|
||||
iconDisk = "💾"
|
||||
iconClean = "🧹"
|
||||
iconTrash = "🗑️"
|
||||
iconBack = "⬅️"
|
||||
iconSelected = "✓"
|
||||
iconArrow = "➤"
|
||||
)
|
||||
|
||||
// Cleanable directory patterns
|
||||
var cleanablePatterns = map[string]bool{
|
||||
"node_modules": true,
|
||||
"vendor": true,
|
||||
".venv": true,
|
||||
"venv": true,
|
||||
"__pycache__": true,
|
||||
".pytest_cache": true,
|
||||
"target": true,
|
||||
"build": true,
|
||||
"dist": true,
|
||||
".next": true,
|
||||
".nuxt": true,
|
||||
".turbo": true,
|
||||
".parcel-cache": true,
|
||||
"bin": true,
|
||||
"obj": true,
|
||||
".gradle": true,
|
||||
".idea": true,
|
||||
".vs": true,
|
||||
}
|
||||
|
||||
// Skip patterns for scanning
|
||||
var skipPatterns = map[string]bool{
|
||||
"$Recycle.Bin": true,
|
||||
"System Volume Information": true,
|
||||
"Windows": true,
|
||||
"Program Files": true,
|
||||
"Program Files (x86)": true,
|
||||
"ProgramData": true,
|
||||
"Recovery": true,
|
||||
"Config.Msi": true,
|
||||
}
|
||||
|
||||
// Entry types
|
||||
type dirEntry struct {
|
||||
Name string
|
||||
Path string
|
||||
Size int64
|
||||
IsDir bool
|
||||
LastAccess time.Time
|
||||
IsCleanable bool
|
||||
}
|
||||
|
||||
type fileEntry struct {
|
||||
Name string
|
||||
Path string
|
||||
Size int64
|
||||
}
|
||||
|
||||
type historyEntry struct {
|
||||
Path string
|
||||
Entries []dirEntry
|
||||
LargeFiles []fileEntry
|
||||
TotalSize int64
|
||||
Selected int
|
||||
}
|
||||
|
||||
// Model for Bubble Tea
|
||||
type model struct {
|
||||
path string
|
||||
entries []dirEntry
|
||||
largeFiles []fileEntry
|
||||
history []historyEntry
|
||||
selected int
|
||||
totalSize int64
|
||||
scanning bool
|
||||
showLargeFiles bool
|
||||
multiSelected map[string]bool
|
||||
deleteConfirm bool
|
||||
deleteTarget string
|
||||
scanProgress int64
|
||||
scanTotal int64
|
||||
width int
|
||||
height int
|
||||
err error
|
||||
cache map[string]historyEntry
|
||||
}
|
||||
|
||||
// Messages
|
||||
type scanCompleteMsg struct {
|
||||
entries []dirEntry
|
||||
largeFiles []fileEntry
|
||||
totalSize int64
|
||||
}
|
||||
|
||||
type scanProgressMsg struct {
|
||||
current int64
|
||||
total int64
|
||||
}
|
||||
|
||||
type scanErrorMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type deleteCompleteMsg struct {
|
||||
path string
|
||||
err error
|
||||
}
|
||||
|
||||
func newModel(startPath string) model {
|
||||
return model{
|
||||
path: startPath,
|
||||
entries: []dirEntry{},
|
||||
largeFiles: []fileEntry{},
|
||||
history: []historyEntry{},
|
||||
selected: 0,
|
||||
scanning: true,
|
||||
multiSelected: make(map[string]bool),
|
||||
cache: make(map[string]historyEntry),
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
return m.scanPath(m.path)
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
return m.handleKeyPress(msg)
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
return m, nil
|
||||
case scanCompleteMsg:
|
||||
m.entries = msg.entries
|
||||
m.largeFiles = msg.largeFiles
|
||||
m.totalSize = msg.totalSize
|
||||
m.scanning = false
|
||||
m.selected = 0
|
||||
// Cache result
|
||||
m.cache[m.path] = historyEntry{
|
||||
Path: m.path,
|
||||
Entries: msg.entries,
|
||||
LargeFiles: msg.largeFiles,
|
||||
TotalSize: msg.totalSize,
|
||||
}
|
||||
return m, nil
|
||||
case scanProgressMsg:
|
||||
m.scanProgress = msg.current
|
||||
m.scanTotal = msg.total
|
||||
return m, nil
|
||||
case scanErrorMsg:
|
||||
m.err = msg.err
|
||||
m.scanning = false
|
||||
return m, nil
|
||||
case deleteCompleteMsg:
|
||||
m.deleteConfirm = false
|
||||
m.deleteTarget = ""
|
||||
if msg.err != nil {
|
||||
m.err = msg.err
|
||||
} else {
|
||||
// Rescan after delete
|
||||
m.scanning = true
|
||||
delete(m.cache, m.path)
|
||||
return m, m.scanPath(m.path)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
// Handle delete confirmation
|
||||
if m.deleteConfirm {
|
||||
switch msg.String() {
|
||||
case "y", "Y":
|
||||
target := m.deleteTarget
|
||||
m.deleteConfirm = false
|
||||
return m, m.deletePath(target)
|
||||
case "n", "N", "esc":
|
||||
m.deleteConfirm = false
|
||||
m.deleteTarget = ""
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch msg.String() {
|
||||
case "q", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "up", "k":
|
||||
if m.selected > 0 {
|
||||
m.selected--
|
||||
}
|
||||
case "down", "j":
|
||||
if m.selected < len(m.entries)-1 {
|
||||
m.selected++
|
||||
}
|
||||
case "enter", "right", "l":
|
||||
if !m.scanning && len(m.entries) > 0 {
|
||||
entry := m.entries[m.selected]
|
||||
if entry.IsDir {
|
||||
// Save current state to history
|
||||
m.history = append(m.history, historyEntry{
|
||||
Path: m.path,
|
||||
Entries: m.entries,
|
||||
LargeFiles: m.largeFiles,
|
||||
TotalSize: m.totalSize,
|
||||
Selected: m.selected,
|
||||
})
|
||||
m.path = entry.Path
|
||||
m.selected = 0
|
||||
m.multiSelected = make(map[string]bool)
|
||||
|
||||
// Check cache
|
||||
if cached, ok := m.cache[entry.Path]; ok {
|
||||
m.entries = cached.Entries
|
||||
m.largeFiles = cached.LargeFiles
|
||||
m.totalSize = cached.TotalSize
|
||||
return m, nil
|
||||
}
|
||||
|
||||
m.scanning = true
|
||||
return m, m.scanPath(entry.Path)
|
||||
}
|
||||
}
|
||||
case "left", "h", "backspace":
|
||||
if len(m.history) > 0 {
|
||||
last := m.history[len(m.history)-1]
|
||||
m.history = m.history[:len(m.history)-1]
|
||||
m.path = last.Path
|
||||
m.entries = last.Entries
|
||||
m.largeFiles = last.LargeFiles
|
||||
m.totalSize = last.TotalSize
|
||||
m.selected = last.Selected
|
||||
m.multiSelected = make(map[string]bool)
|
||||
m.scanning = false
|
||||
}
|
||||
case "space":
|
||||
if len(m.entries) > 0 {
|
||||
entry := m.entries[m.selected]
|
||||
if m.multiSelected[entry.Path] {
|
||||
delete(m.multiSelected, entry.Path)
|
||||
} else {
|
||||
m.multiSelected[entry.Path] = true
|
||||
}
|
||||
}
|
||||
case "d", "delete":
|
||||
if len(m.entries) > 0 {
|
||||
entry := m.entries[m.selected]
|
||||
m.deleteConfirm = true
|
||||
m.deleteTarget = entry.Path
|
||||
}
|
||||
case "D":
|
||||
// Delete all selected
|
||||
if len(m.multiSelected) > 0 {
|
||||
m.deleteConfirm = true
|
||||
m.deleteTarget = fmt.Sprintf("%d items", len(m.multiSelected))
|
||||
}
|
||||
case "f":
|
||||
m.showLargeFiles = !m.showLargeFiles
|
||||
case "r":
|
||||
// Refresh
|
||||
delete(m.cache, m.path)
|
||||
m.scanning = true
|
||||
return m, m.scanPath(m.path)
|
||||
case "o":
|
||||
// Open in Explorer
|
||||
if len(m.entries) > 0 {
|
||||
entry := m.entries[m.selected]
|
||||
openInExplorer(entry.Path)
|
||||
}
|
||||
case "g":
|
||||
m.selected = 0
|
||||
case "G":
|
||||
m.selected = len(m.entries) - 1
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
var b strings.Builder
|
||||
|
||||
// Header
|
||||
b.WriteString(fmt.Sprintf("%s%s Mole Disk Analyzer %s\n", colorPurpleBold, iconDisk, colorReset))
|
||||
b.WriteString(fmt.Sprintf("%s%s%s\n", colorGray, m.path, colorReset))
|
||||
b.WriteString("\n")
|
||||
|
||||
// Show delete confirmation
|
||||
if m.deleteConfirm {
|
||||
b.WriteString(fmt.Sprintf("%s%s Delete %s? (y/n)%s\n", colorRed, iconTrash, m.deleteTarget, colorReset))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Scanning indicator
|
||||
if m.scanning {
|
||||
b.WriteString(fmt.Sprintf("%s⠋ Scanning...%s\n", colorCyan, colorReset))
|
||||
if m.scanTotal > 0 {
|
||||
b.WriteString(fmt.Sprintf("%s %d / %d items%s\n", colorGray, m.scanProgress, m.scanTotal, colorReset))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Error display
|
||||
if m.err != nil {
|
||||
b.WriteString(fmt.Sprintf("%sError: %v%s\n", colorRed, m.err, colorReset))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Total size
|
||||
b.WriteString(fmt.Sprintf(" Total: %s%s%s\n", colorYellow, formatBytes(m.totalSize), colorReset))
|
||||
b.WriteString("\n")
|
||||
|
||||
// Large files toggle
|
||||
if m.showLargeFiles && len(m.largeFiles) > 0 {
|
||||
b.WriteString(fmt.Sprintf("%s%s Large Files (>100MB):%s\n", colorCyanBold, iconFile, colorReset))
|
||||
for i, f := range m.largeFiles {
|
||||
if i >= 10 {
|
||||
b.WriteString(fmt.Sprintf(" %s... and %d more%s\n", colorGray, len(m.largeFiles)-10, colorReset))
|
||||
break
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" %s%s%s %s\n", colorYellow, formatBytes(f.Size), colorReset, truncatePath(f.Path, 60)))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Directory entries
|
||||
visibleEntries := m.height - 12
|
||||
if visibleEntries < 5 {
|
||||
visibleEntries = 20
|
||||
}
|
||||
|
||||
start := 0
|
||||
if m.selected >= visibleEntries {
|
||||
start = m.selected - visibleEntries + 1
|
||||
}
|
||||
|
||||
for i := start; i < len(m.entries) && i < start+visibleEntries; i++ {
|
||||
entry := m.entries[i]
|
||||
prefix := " "
|
||||
|
||||
// Selection indicator
|
||||
if i == m.selected {
|
||||
prefix = fmt.Sprintf("%s%s%s ", colorCyan, iconArrow, colorReset)
|
||||
} else if m.multiSelected[entry.Path] {
|
||||
prefix = fmt.Sprintf("%s%s%s ", colorGreen, iconSelected, colorReset)
|
||||
}
|
||||
|
||||
// Icon
|
||||
icon := iconFile
|
||||
if entry.IsDir {
|
||||
icon = iconFolder
|
||||
}
|
||||
if entry.IsCleanable {
|
||||
icon = iconClean
|
||||
}
|
||||
|
||||
// Size and percentage
|
||||
pct := float64(0)
|
||||
if m.totalSize > 0 {
|
||||
pct = float64(entry.Size) / float64(m.totalSize) * 100
|
||||
}
|
||||
|
||||
// Bar
|
||||
barWidth := 20
|
||||
filled := int(pct / 100 * float64(barWidth))
|
||||
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
|
||||
|
||||
// Color based on selection
|
||||
nameColor := colorReset
|
||||
if i == m.selected {
|
||||
nameColor = colorCyanBold
|
||||
}
|
||||
|
||||
b.WriteString(fmt.Sprintf("%s%s %s%8s%s %s%s%s %s%.1f%%%s %s\n",
|
||||
prefix,
|
||||
icon,
|
||||
colorYellow, formatBytes(entry.Size), colorReset,
|
||||
colorGray, bar, colorReset,
|
||||
colorDim, pct, colorReset,
|
||||
nameColor+entry.Name+colorReset,
|
||||
))
|
||||
}
|
||||
|
||||
// Footer with keybindings
|
||||
b.WriteString("\n")
|
||||
b.WriteString(fmt.Sprintf("%s↑↓%s navigate %s↵%s enter %s←%s back %sf%s files %sd%s delete %sr%s refresh %sq%s quit%s\n",
|
||||
colorCyan, colorReset,
|
||||
colorCyan, colorReset,
|
||||
colorCyan, colorReset,
|
||||
colorCyan, colorReset,
|
||||
colorCyan, colorReset,
|
||||
colorCyan, colorReset,
|
||||
colorCyan, colorReset,
|
||||
colorReset,
|
||||
))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// scanPath scans a directory and returns entries
|
||||
func (m model) scanPath(path string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
entries, largeFiles, totalSize, err := scanDirectory(path)
|
||||
if err != nil {
|
||||
return scanErrorMsg{err: err}
|
||||
}
|
||||
return scanCompleteMsg{
|
||||
entries: entries,
|
||||
largeFiles: largeFiles,
|
||||
totalSize: totalSize,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deletePath deletes a file or directory
|
||||
func (m model) deletePath(path string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := os.RemoveAll(path)
|
||||
return deleteCompleteMsg{path: path, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
// scanDirectory scans a directory concurrently
|
||||
func scanDirectory(path string) ([]dirEntry, []fileEntry, int64, error) {
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return nil, nil, 0, err
|
||||
}
|
||||
|
||||
var (
|
||||
dirEntries []dirEntry
|
||||
largeFiles []fileEntry
|
||||
totalSize int64
|
||||
mu sync.Mutex
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
|
||||
numWorkers := runtime.NumCPU() * 2
|
||||
if numWorkers > 32 {
|
||||
numWorkers = 32
|
||||
}
|
||||
|
||||
sem := make(chan struct{}, numWorkers)
|
||||
var processedCount int64
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
entryPath := filepath.Join(path, name)
|
||||
|
||||
// Skip system directories
|
||||
if skipPatterns[name] {
|
||||
continue
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
|
||||
go func(name, entryPath string, isDir bool) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
|
||||
var size int64
|
||||
var lastAccess time.Time
|
||||
var isCleanable bool
|
||||
|
||||
if isDir {
|
||||
size = calculateDirSize(entryPath)
|
||||
isCleanable = cleanablePatterns[name]
|
||||
} else {
|
||||
info, err := os.Stat(entryPath)
|
||||
if err == nil {
|
||||
size = info.Size()
|
||||
lastAccess = info.ModTime()
|
||||
}
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
dirEntries = append(dirEntries, dirEntry{
|
||||
Name: name,
|
||||
Path: entryPath,
|
||||
Size: size,
|
||||
IsDir: isDir,
|
||||
LastAccess: lastAccess,
|
||||
IsCleanable: isCleanable,
|
||||
})
|
||||
|
||||
totalSize += size
|
||||
|
||||
// Track large files
|
||||
if !isDir && size >= 100*1024*1024 {
|
||||
largeFiles = append(largeFiles, fileEntry{
|
||||
Name: name,
|
||||
Path: entryPath,
|
||||
Size: size,
|
||||
})
|
||||
}
|
||||
|
||||
atomic.AddInt64(&processedCount, 1)
|
||||
}(name, entryPath, entry.IsDir())
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Sort by size descending
|
||||
sort.Slice(dirEntries, func(i, j int) bool {
|
||||
return dirEntries[i].Size > dirEntries[j].Size
|
||||
})
|
||||
|
||||
sort.Slice(largeFiles, func(i, j int) bool {
|
||||
return largeFiles[i].Size > largeFiles[j].Size
|
||||
})
|
||||
|
||||
return dirEntries, largeFiles, totalSize, nil
|
||||
}
|
||||
|
||||
// calculateDirSize calculates the size of a directory
|
||||
func calculateDirSize(path string) int64 {
|
||||
var size int64
|
||||
|
||||
filepath.Walk(path, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil // Skip errors
|
||||
}
|
||||
if !info.IsDir() {
|
||||
size += info.Size()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
return size
|
||||
}
|
||||
|
||||
// formatBytes formats bytes to human readable string
|
||||
func formatBytes(bytes int64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
// truncatePath truncates a path to fit in maxLen
|
||||
func truncatePath(path string, maxLen int) string {
|
||||
if len(path) <= maxLen {
|
||||
return path
|
||||
}
|
||||
return "..." + path[len(path)-maxLen+3:]
|
||||
}
|
||||
|
||||
// openInExplorer opens a path in Windows Explorer
|
||||
func openInExplorer(path string) {
|
||||
// Use explorer.exe to open the path
|
||||
cmd := fmt.Sprintf("explorer.exe /select,\"%s\"", path)
|
||||
go func() {
|
||||
_ = runCommand("cmd", "/c", cmd)
|
||||
}()
|
||||
}
|
||||
|
||||
// runCommand runs a command and returns the output
|
||||
func runCommand(name string, args ...string) error {
|
||||
cmd := fmt.Sprintf("%s %s", name, strings.Join(args, " "))
|
||||
_ = cmd
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
var startPath string
|
||||
|
||||
flag.StringVar(&startPath, "path", "", "Path to analyze")
|
||||
flag.Parse()
|
||||
|
||||
// Check environment variable
|
||||
if startPath == "" {
|
||||
startPath = os.Getenv("MO_ANALYZE_PATH")
|
||||
}
|
||||
|
||||
// Use command line argument
|
||||
if startPath == "" && len(flag.Args()) > 0 {
|
||||
startPath = flag.Args()[0]
|
||||
}
|
||||
|
||||
// Default to user profile
|
||||
if startPath == "" {
|
||||
startPath = os.Getenv("USERPROFILE")
|
||||
}
|
||||
|
||||
// Resolve to absolute path
|
||||
absPath, err := filepath.Abs(startPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Check if path exists
|
||||
if _, err := os.Stat(absPath); os.IsNotExist(err) {
|
||||
fmt.Fprintf(os.Stderr, "Error: Path does not exist: %s\n", absPath)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
p := tea.NewProgram(newModel(absPath), tea.WithAltScreen())
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
674
windows/cmd/status/main.go
Normal file
674
windows/cmd/status/main.go
Normal file
@@ -0,0 +1,674 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/shirou/gopsutil/v3/cpu"
|
||||
"github.com/shirou/gopsutil/v3/disk"
|
||||
"github.com/shirou/gopsutil/v3/host"
|
||||
"github.com/shirou/gopsutil/v3/mem"
|
||||
"github.com/shirou/gopsutil/v3/net"
|
||||
"github.com/shirou/gopsutil/v3/process"
|
||||
)
|
||||
|
||||
// Styles
|
||||
var (
|
||||
titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#C79FD7")).Bold(true)
|
||||
headerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#87CEEB")).Bold(true)
|
||||
labelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888"))
|
||||
valueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF"))
|
||||
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#A5D6A7"))
|
||||
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F"))
|
||||
dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5F5F")).Bold(true)
|
||||
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#666666"))
|
||||
cardStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("#444444")).Padding(0, 1)
|
||||
)
|
||||
|
||||
// Metrics snapshot
|
||||
type MetricsSnapshot struct {
|
||||
CollectedAt time.Time
|
||||
HealthScore int
|
||||
HealthMessage string
|
||||
|
||||
// Hardware
|
||||
Hostname string
|
||||
OS string
|
||||
Platform string
|
||||
Uptime time.Duration
|
||||
|
||||
// CPU
|
||||
CPUModel string
|
||||
CPUCores int
|
||||
CPUPercent float64
|
||||
CPUPerCore []float64
|
||||
|
||||
// Memory
|
||||
MemTotal uint64
|
||||
MemUsed uint64
|
||||
MemPercent float64
|
||||
SwapTotal uint64
|
||||
SwapUsed uint64
|
||||
SwapPercent float64
|
||||
|
||||
// Disk
|
||||
Disks []DiskInfo
|
||||
|
||||
// Network
|
||||
Networks []NetworkInfo
|
||||
|
||||
// Processes
|
||||
TopProcesses []ProcessInfo
|
||||
}
|
||||
|
||||
type DiskInfo struct {
|
||||
Device string
|
||||
Mountpoint string
|
||||
Total uint64
|
||||
Used uint64
|
||||
Free uint64
|
||||
UsedPercent float64
|
||||
Fstype string
|
||||
}
|
||||
|
||||
type NetworkInfo struct {
|
||||
Name string
|
||||
BytesSent uint64
|
||||
BytesRecv uint64
|
||||
PacketsSent uint64
|
||||
PacketsRecv uint64
|
||||
}
|
||||
|
||||
type ProcessInfo struct {
|
||||
PID int32
|
||||
Name string
|
||||
CPU float64
|
||||
Memory float32
|
||||
}
|
||||
|
||||
// Collector
|
||||
type Collector struct {
|
||||
prevNet map[string]net.IOCountersStat
|
||||
prevNetTime time.Time
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewCollector() *Collector {
|
||||
return &Collector{
|
||||
prevNet: make(map[string]net.IOCountersStat),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Collector) Collect() MetricsSnapshot {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var (
|
||||
snapshot MetricsSnapshot
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
)
|
||||
|
||||
snapshot.CollectedAt = time.Now()
|
||||
|
||||
// Host info
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if info, err := host.InfoWithContext(ctx); err == nil {
|
||||
mu.Lock()
|
||||
snapshot.Hostname = info.Hostname
|
||||
snapshot.OS = info.OS
|
||||
snapshot.Platform = fmt.Sprintf("%s %s", info.Platform, info.PlatformVersion)
|
||||
snapshot.Uptime = time.Duration(info.Uptime) * time.Second
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// CPU info
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if cpuInfo, err := cpu.InfoWithContext(ctx); err == nil && len(cpuInfo) > 0 {
|
||||
mu.Lock()
|
||||
snapshot.CPUModel = cpuInfo[0].ModelName
|
||||
snapshot.CPUCores = runtime.NumCPU()
|
||||
mu.Unlock()
|
||||
}
|
||||
if percent, err := cpu.PercentWithContext(ctx, 500*time.Millisecond, false); err == nil && len(percent) > 0 {
|
||||
mu.Lock()
|
||||
snapshot.CPUPercent = percent[0]
|
||||
mu.Unlock()
|
||||
}
|
||||
if perCore, err := cpu.PercentWithContext(ctx, 500*time.Millisecond, true); err == nil {
|
||||
mu.Lock()
|
||||
snapshot.CPUPerCore = perCore
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// Memory
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if memInfo, err := mem.VirtualMemoryWithContext(ctx); err == nil {
|
||||
mu.Lock()
|
||||
snapshot.MemTotal = memInfo.Total
|
||||
snapshot.MemUsed = memInfo.Used
|
||||
snapshot.MemPercent = memInfo.UsedPercent
|
||||
mu.Unlock()
|
||||
}
|
||||
if swapInfo, err := mem.SwapMemoryWithContext(ctx); err == nil {
|
||||
mu.Lock()
|
||||
snapshot.SwapTotal = swapInfo.Total
|
||||
snapshot.SwapUsed = swapInfo.Used
|
||||
snapshot.SwapPercent = swapInfo.UsedPercent
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// Disk
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if partitions, err := disk.PartitionsWithContext(ctx, false); err == nil {
|
||||
var disks []DiskInfo
|
||||
for _, p := range partitions {
|
||||
// Skip non-physical drives
|
||||
if !strings.HasPrefix(p.Device, "C:") &&
|
||||
!strings.HasPrefix(p.Device, "D:") &&
|
||||
!strings.HasPrefix(p.Device, "E:") &&
|
||||
!strings.HasPrefix(p.Device, "F:") {
|
||||
continue
|
||||
}
|
||||
if usage, err := disk.UsageWithContext(ctx, p.Mountpoint); err == nil {
|
||||
disks = append(disks, DiskInfo{
|
||||
Device: p.Device,
|
||||
Mountpoint: p.Mountpoint,
|
||||
Total: usage.Total,
|
||||
Used: usage.Used,
|
||||
Free: usage.Free,
|
||||
UsedPercent: usage.UsedPercent,
|
||||
Fstype: p.Fstype,
|
||||
})
|
||||
}
|
||||
}
|
||||
mu.Lock()
|
||||
snapshot.Disks = disks
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// Network
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if netIO, err := net.IOCountersWithContext(ctx, true); err == nil {
|
||||
var networks []NetworkInfo
|
||||
for _, io := range netIO {
|
||||
// Skip loopback and inactive interfaces
|
||||
if io.Name == "Loopback Pseudo-Interface 1" || (io.BytesSent == 0 && io.BytesRecv == 0) {
|
||||
continue
|
||||
}
|
||||
networks = append(networks, NetworkInfo{
|
||||
Name: io.Name,
|
||||
BytesSent: io.BytesSent,
|
||||
BytesRecv: io.BytesRecv,
|
||||
PacketsSent: io.PacketsSent,
|
||||
PacketsRecv: io.PacketsRecv,
|
||||
})
|
||||
}
|
||||
mu.Lock()
|
||||
snapshot.Networks = networks
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// Top Processes
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
procs, err := process.ProcessesWithContext(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var procInfos []ProcessInfo
|
||||
for _, p := range procs {
|
||||
name, err := p.NameWithContext(ctx)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
cpuPercent, _ := p.CPUPercentWithContext(ctx)
|
||||
memPercent, _ := p.MemoryPercentWithContext(ctx)
|
||||
|
||||
if cpuPercent > 0.1 || memPercent > 0.1 {
|
||||
procInfos = append(procInfos, ProcessInfo{
|
||||
PID: p.Pid,
|
||||
Name: name,
|
||||
CPU: cpuPercent,
|
||||
Memory: memPercent,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by CPU usage
|
||||
for i := 0; i < len(procInfos)-1; i++ {
|
||||
for j := i + 1; j < len(procInfos); j++ {
|
||||
if procInfos[j].CPU > procInfos[i].CPU {
|
||||
procInfos[i], procInfos[j] = procInfos[j], procInfos[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Take top 5
|
||||
if len(procInfos) > 5 {
|
||||
procInfos = procInfos[:5]
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
snapshot.TopProcesses = procInfos
|
||||
mu.Unlock()
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Calculate health score
|
||||
snapshot.HealthScore, snapshot.HealthMessage = calculateHealthScore(snapshot)
|
||||
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func calculateHealthScore(s MetricsSnapshot) (int, string) {
|
||||
score := 100
|
||||
var issues []string
|
||||
|
||||
// CPU penalty (30% weight)
|
||||
if s.CPUPercent > 90 {
|
||||
score -= 30
|
||||
issues = append(issues, "High CPU")
|
||||
} else if s.CPUPercent > 70 {
|
||||
score -= 15
|
||||
issues = append(issues, "Elevated CPU")
|
||||
}
|
||||
|
||||
// Memory penalty (25% weight)
|
||||
if s.MemPercent > 90 {
|
||||
score -= 25
|
||||
issues = append(issues, "High Memory")
|
||||
} else if s.MemPercent > 80 {
|
||||
score -= 12
|
||||
issues = append(issues, "Elevated Memory")
|
||||
}
|
||||
|
||||
// Disk penalty (20% weight)
|
||||
for _, d := range s.Disks {
|
||||
if d.UsedPercent > 95 {
|
||||
score -= 20
|
||||
issues = append(issues, fmt.Sprintf("Disk %s Critical", d.Device))
|
||||
break
|
||||
} else if d.UsedPercent > 85 {
|
||||
score -= 10
|
||||
issues = append(issues, fmt.Sprintf("Disk %s Low", d.Device))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Swap penalty (10% weight)
|
||||
if s.SwapPercent > 80 {
|
||||
score -= 10
|
||||
issues = append(issues, "High Swap")
|
||||
}
|
||||
|
||||
if score < 0 {
|
||||
score = 0
|
||||
}
|
||||
|
||||
msg := "Excellent"
|
||||
if len(issues) > 0 {
|
||||
msg = strings.Join(issues, ", ")
|
||||
} else if score >= 90 {
|
||||
msg = "Excellent"
|
||||
} else if score >= 70 {
|
||||
msg = "Good"
|
||||
} else if score >= 50 {
|
||||
msg = "Fair"
|
||||
} else {
|
||||
msg = "Poor"
|
||||
}
|
||||
|
||||
return score, msg
|
||||
}
|
||||
|
||||
// Model for Bubble Tea
|
||||
type model struct {
|
||||
collector *Collector
|
||||
metrics MetricsSnapshot
|
||||
animFrame int
|
||||
catHidden bool
|
||||
ready bool
|
||||
collecting bool
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
// Messages
|
||||
type tickMsg time.Time
|
||||
type metricsMsg MetricsSnapshot
|
||||
|
||||
func newModel() model {
|
||||
return model{
|
||||
collector: NewCollector(),
|
||||
animFrame: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
m.collectMetrics(),
|
||||
tickCmd(),
|
||||
)
|
||||
}
|
||||
|
||||
func tickCmd() tea.Cmd {
|
||||
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
|
||||
return tickMsg(t)
|
||||
})
|
||||
}
|
||||
|
||||
func (m model) collectMetrics() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return metricsMsg(m.collector.Collect())
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "q", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "c":
|
||||
m.catHidden = !m.catHidden
|
||||
case "r":
|
||||
m.collecting = true
|
||||
return m, m.collectMetrics()
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
case tickMsg:
|
||||
m.animFrame++
|
||||
if m.animFrame%2 == 0 && !m.collecting {
|
||||
return m, tea.Batch(
|
||||
m.collectMetrics(),
|
||||
tickCmd(),
|
||||
)
|
||||
}
|
||||
return m, tickCmd()
|
||||
case metricsMsg:
|
||||
m.metrics = MetricsSnapshot(msg)
|
||||
m.ready = true
|
||||
m.collecting = false
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
if !m.ready {
|
||||
return "\n Loading system metrics..."
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
// Header with mole animation
|
||||
moleFrame := getMoleFrame(m.animFrame, m.catHidden)
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(titleStyle.Render(" 🐹 Mole System Status"))
|
||||
b.WriteString(" ")
|
||||
b.WriteString(moleFrame)
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Health score
|
||||
healthColor := okStyle
|
||||
if m.metrics.HealthScore < 50 {
|
||||
healthColor = dangerStyle
|
||||
} else if m.metrics.HealthScore < 70 {
|
||||
healthColor = warnStyle
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" Health: %s %s\n\n",
|
||||
healthColor.Render(fmt.Sprintf("%d%%", m.metrics.HealthScore)),
|
||||
dimStyle.Render(m.metrics.HealthMessage),
|
||||
))
|
||||
|
||||
// System info
|
||||
b.WriteString(headerStyle.Render(" 📍 System"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Host:"), valueStyle.Render(m.metrics.Hostname)))
|
||||
b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("OS:"), valueStyle.Render(m.metrics.Platform)))
|
||||
b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Uptime:"), valueStyle.Render(formatDuration(m.metrics.Uptime))))
|
||||
b.WriteString("\n")
|
||||
|
||||
// CPU
|
||||
b.WriteString(headerStyle.Render(" ⚡ CPU"))
|
||||
b.WriteString("\n")
|
||||
cpuColor := getPercentColor(m.metrics.CPUPercent)
|
||||
b.WriteString(fmt.Sprintf(" %s %s\n", labelStyle.Render("Model:"), valueStyle.Render(truncateString(m.metrics.CPUModel, 50))))
|
||||
b.WriteString(fmt.Sprintf(" %s %s (%d cores)\n",
|
||||
labelStyle.Render("Usage:"),
|
||||
cpuColor.Render(fmt.Sprintf("%.1f%%", m.metrics.CPUPercent)),
|
||||
m.metrics.CPUCores,
|
||||
))
|
||||
b.WriteString(fmt.Sprintf(" %s\n", renderProgressBar(m.metrics.CPUPercent, 30)))
|
||||
b.WriteString("\n")
|
||||
|
||||
// Memory
|
||||
b.WriteString(headerStyle.Render(" 🧠 Memory"))
|
||||
b.WriteString("\n")
|
||||
memColor := getPercentColor(m.metrics.MemPercent)
|
||||
b.WriteString(fmt.Sprintf(" %s %s / %s %s\n",
|
||||
labelStyle.Render("RAM:"),
|
||||
memColor.Render(formatBytes(m.metrics.MemUsed)),
|
||||
valueStyle.Render(formatBytes(m.metrics.MemTotal)),
|
||||
memColor.Render(fmt.Sprintf("(%.1f%%)", m.metrics.MemPercent)),
|
||||
))
|
||||
b.WriteString(fmt.Sprintf(" %s\n", renderProgressBar(m.metrics.MemPercent, 30)))
|
||||
if m.metrics.SwapTotal > 0 {
|
||||
b.WriteString(fmt.Sprintf(" %s %s / %s\n",
|
||||
labelStyle.Render("Swap:"),
|
||||
valueStyle.Render(formatBytes(m.metrics.SwapUsed)),
|
||||
valueStyle.Render(formatBytes(m.metrics.SwapTotal)),
|
||||
))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// Disk
|
||||
b.WriteString(headerStyle.Render(" 💾 Disks"))
|
||||
b.WriteString("\n")
|
||||
for _, d := range m.metrics.Disks {
|
||||
diskColor := getPercentColor(d.UsedPercent)
|
||||
b.WriteString(fmt.Sprintf(" %s %s / %s %s\n",
|
||||
labelStyle.Render(d.Device),
|
||||
diskColor.Render(formatBytes(d.Used)),
|
||||
valueStyle.Render(formatBytes(d.Total)),
|
||||
diskColor.Render(fmt.Sprintf("(%.1f%%)", d.UsedPercent)),
|
||||
))
|
||||
b.WriteString(fmt.Sprintf(" %s\n", renderProgressBar(d.UsedPercent, 30)))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// Top Processes
|
||||
if len(m.metrics.TopProcesses) > 0 {
|
||||
b.WriteString(headerStyle.Render(" 📊 Top Processes"))
|
||||
b.WriteString("\n")
|
||||
for _, p := range m.metrics.TopProcesses {
|
||||
b.WriteString(fmt.Sprintf(" %s %s (CPU: %.1f%%, Mem: %.1f%%)\n",
|
||||
dimStyle.Render(fmt.Sprintf("[%d]", p.PID)),
|
||||
valueStyle.Render(truncateString(p.Name, 20)),
|
||||
p.CPU,
|
||||
p.Memory,
|
||||
))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Network
|
||||
if len(m.metrics.Networks) > 0 {
|
||||
b.WriteString(headerStyle.Render(" 🌐 Network"))
|
||||
b.WriteString("\n")
|
||||
for i, n := range m.metrics.Networks {
|
||||
if i >= 3 {
|
||||
break
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" %s ↑%s ↓%s\n",
|
||||
labelStyle.Render(truncateString(n.Name, 20)+":"),
|
||||
valueStyle.Render(formatBytes(n.BytesSent)),
|
||||
valueStyle.Render(formatBytes(n.BytesRecv)),
|
||||
))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Footer
|
||||
b.WriteString(dimStyle.Render(" [q] quit [r] refresh [c] toggle mole"))
|
||||
b.WriteString("\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func getMoleFrame(frame int, hidden bool) string {
|
||||
if hidden {
|
||||
return ""
|
||||
}
|
||||
frames := []string{
|
||||
"🐹",
|
||||
"🐹.",
|
||||
"🐹..",
|
||||
"🐹...",
|
||||
}
|
||||
return frames[frame%len(frames)]
|
||||
}
|
||||
|
||||
func renderProgressBar(percent float64, width int) string {
|
||||
filled := int(percent / 100 * float64(width))
|
||||
if filled > width {
|
||||
filled = width
|
||||
}
|
||||
if filled < 0 {
|
||||
filled = 0
|
||||
}
|
||||
|
||||
color := okStyle
|
||||
if percent > 85 {
|
||||
color = dangerStyle
|
||||
} else if percent > 70 {
|
||||
color = warnStyle
|
||||
}
|
||||
|
||||
bar := strings.Repeat("█", filled) + strings.Repeat("░", width-filled)
|
||||
return color.Render(bar)
|
||||
}
|
||||
|
||||
func getPercentColor(percent float64) lipgloss.Style {
|
||||
if percent > 85 {
|
||||
return dangerStyle
|
||||
} else if percent > 70 {
|
||||
return warnStyle
|
||||
}
|
||||
return okStyle
|
||||
}
|
||||
|
||||
func formatBytes(bytes uint64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
div, exp := uint64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
func formatDuration(d time.Duration) string {
|
||||
days := int(d.Hours() / 24)
|
||||
hours := int(d.Hours()) % 24
|
||||
minutes := int(d.Minutes()) % 60
|
||||
|
||||
if days > 0 {
|
||||
return fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
|
||||
}
|
||||
if hours > 0 {
|
||||
return fmt.Sprintf("%dh %dm", hours, minutes)
|
||||
}
|
||||
return fmt.Sprintf("%dm", minutes)
|
||||
}
|
||||
|
||||
func truncateString(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
}
|
||||
|
||||
// getWindowsVersion gets detailed Windows version using PowerShell
|
||||
func getWindowsVersion() string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "powershell", "-Command",
|
||||
"(Get-CimInstance Win32_OperatingSystem).Caption")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "Windows"
|
||||
}
|
||||
return strings.TrimSpace(string(output))
|
||||
}
|
||||
|
||||
// getBatteryInfo gets battery info on Windows (for laptops)
|
||||
func getBatteryInfo() (int, bool, bool) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "powershell", "-Command",
|
||||
"(Get-CimInstance Win32_Battery).EstimatedChargeRemaining")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0, false, false
|
||||
}
|
||||
|
||||
percent, err := strconv.Atoi(strings.TrimSpace(string(output)))
|
||||
if err != nil {
|
||||
return 0, false, false
|
||||
}
|
||||
|
||||
// Check if charging
|
||||
cmdStatus := exec.CommandContext(ctx, "powershell", "-Command",
|
||||
"(Get-CimInstance Win32_Battery).BatteryStatus")
|
||||
statusOutput, _ := cmdStatus.Output()
|
||||
status, _ := strconv.Atoi(strings.TrimSpace(string(statusOutput)))
|
||||
isCharging := status == 2 // 2 = AC Power
|
||||
|
||||
return percent, isCharging, true
|
||||
}
|
||||
|
||||
func main() {
|
||||
p := tea.NewProgram(newModel(), tea.WithAltScreen())
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user