├── assets ├── screenshot.png └── screenshot2.png ├── .gitignore ├── internal ├── ui │ ├── styles.go │ ├── connect.go │ ├── view.go │ ├── update.go │ ├── model.go │ └── render.go ├── version.go ├── gpu │ ├── base │ │ └── types.go │ ├── registry.go │ ├── nvidia.go │ └── amd.go ├── updater.go ├── sysinfo.go └── ssh.go ├── .github └── workflows │ └── release.yml ├── LICENSE ├── .goreleaser.yml ├── scripts └── get_version.sh ├── go.mod ├── Makefile ├── cmd └── ssh_dashboard │ └── main.go ├── go.sum └── README.md /assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlpinDale/ssh-dashboard/HEAD/assets/screenshot.png -------------------------------------------------------------------------------- /assets/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlpinDale/ssh-dashboard/HEAD/assets/screenshot2.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries 2 | /ssh-dashboard 3 | /ssh-dashboard-* 4 | test_* 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | *.test 11 | *.out 12 | vendor/ 13 | 14 | # GoReleaser 15 | dist/ 16 | 17 | # IDE 18 | .vscode/ 19 | .idea/ 20 | *.swp 21 | *.swo 22 | *~ 23 | 24 | # OS 25 | .DS_Store 26 | Thumbs.db 27 | 28 | -------------------------------------------------------------------------------- /internal/ui/styles.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import "github.com/charmbracelet/lipgloss" 4 | 5 | var ( 6 | titleStyle = lipgloss.NewStyle(). 7 | Bold(true). 8 | Foreground(lipgloss.Color("86")). 9 | Background(lipgloss.Color("63")). 10 | Padding(0, 1) 11 | 12 | headerStyle = lipgloss.NewStyle(). 13 | Bold(true). 14 | Foreground(lipgloss.Color("63")) 15 | ) 16 | -------------------------------------------------------------------------------- /internal/version.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | var ( 4 | Version = "dev" 5 | GitCommit = "unknown" 6 | BuildDate = "unknown" 7 | GitTag = "unknown" 8 | ) 9 | 10 | func FullVersion() string { 11 | if Version == "dev" && GitCommit != "unknown" { 12 | return "dev+" + GitCommit[:8] 13 | } 14 | return Version 15 | } 16 | 17 | func ShortVersion() string { 18 | return Version 19 | } 20 | -------------------------------------------------------------------------------- /internal/gpu/base/types.go: -------------------------------------------------------------------------------- 1 | package base 2 | 3 | // a single GPU/accelerator in vendor-neutral terms 4 | type Device struct { 5 | Index int 6 | Name string 7 | VRAMTotal int // MB 8 | VRAMUsed int // MB 9 | Utilization int // percentage 10 | PowerDraw int // watts 11 | PowerLimit int // watts 12 | Temperature int // celsius 13 | Vendor string 14 | } 15 | 16 | type RunCmdFunc func(string) (string, error) 17 | 18 | type Provider interface { 19 | // returns the vendor name (e.g., "nvidia", "amd") 20 | Name() string 21 | 22 | // returns true if the required tooling exists on the host 23 | Detect(runCmd RunCmdFunc) bool 24 | 25 | // returns a slice of GPU devices or an error 26 | Query(runCmd RunCmdFunc) ([]Device, error) 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | release: 13 | name: Release 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | 21 | - name: Set up Go 22 | uses: actions/setup-go@v4 23 | with: 24 | go-version: '1.21' 25 | 26 | - name: Run GoReleaser 27 | uses: goreleaser/goreleaser-action@v6 28 | with: 29 | distribution: goreleaser 30 | version: '~> v2' 31 | args: release --clean 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | -------------------------------------------------------------------------------- /internal/gpu/registry.go: -------------------------------------------------------------------------------- 1 | package gpu 2 | 3 | import "github.com/alpindale/ssh-dashboard/internal/gpu/base" 4 | 5 | // the list of all available GPU providers 6 | // They will be checked in order, and the first one that detects 7 | // its tooling on the host will be used 8 | // TODO: add support for multiple providers per host 9 | var providers = []base.Provider{ 10 | NvidiaProvider{}, 11 | AMDProvider{}, 12 | } 13 | 14 | // attempt to detect and query GPUs from all registered providers 15 | // Returns the first successful result, or empty if no GPUs are found 16 | func QueryAll(runCmd base.RunCmdFunc) ([]base.Device, error) { 17 | for _, p := range providers { 18 | if p.Detect(runCmd) { 19 | devices, err := p.Query(runCmd) 20 | if err != nil { 21 | continue 22 | } 23 | if len(devices) > 0 { 24 | return devices, nil 25 | } 26 | } 27 | } 28 | // no GPUs found 29 | return []base.Device{}, nil 30 | } 31 | 32 | func Register(p base.Provider) { 33 | providers = append(providers, p) 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 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 | 23 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | project_name: ssh-dashboard 4 | 5 | before: 6 | hooks: 7 | - go mod tidy 8 | 9 | builds: 10 | - id: ssh-dashboard 11 | main: ./cmd/ssh_dashboard 12 | binary: ssh-dashboard 13 | env: 14 | - CGO_ENABLED=0 15 | goos: 16 | - linux 17 | - darwin 18 | - windows 19 | goarch: 20 | - amd64 21 | - arm64 22 | ldflags: 23 | - -s -w 24 | - -X 'github.com/alpindale/ssh-dashboard/internal.Version={{ .Version }}' 25 | - -X 'github.com/alpindale/ssh-dashboard/internal.GitCommit={{ .Commit }}' 26 | - -X 'github.com/alpindale/ssh-dashboard/internal.BuildDate={{ .Date }}' 27 | - -X 'github.com/alpindale/ssh-dashboard/internal.GitTag={{ .Tag }}' 28 | 29 | archives: 30 | - id: default 31 | name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}" 32 | formats: [binary] 33 | 34 | checksum: 35 | name_template: 'checksums.txt' 36 | 37 | release: 38 | github: 39 | owner: alpindale 40 | name: ssh-dashboard 41 | draft: false 42 | prerelease: auto 43 | name_template: "Release {{ .Version }}" 44 | 45 | changelog: 46 | sort: asc 47 | filters: 48 | exclude: 49 | - '^docs:' 50 | - '^test:' 51 | - '^chore:' 52 | -------------------------------------------------------------------------------- /scripts/get_version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Generate version string from git, similar to setuptools-scm 3 | 4 | set -e 5 | 6 | GIT_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") 7 | GIT_COMMIT=$(git rev-parse --short=8 HEAD 2>/dev/null || echo "unknown") 8 | BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") 9 | 10 | if git describe --exact-match --tags HEAD &>/dev/null; then 11 | VERSION="${GIT_TAG#v}" 12 | else 13 | COMMITS_SINCE=$(git rev-list ${GIT_TAG}..HEAD --count 2>/dev/null || echo "0") 14 | 15 | if [ "$COMMITS_SINCE" -gt 0 ]; then 16 | # Format: 0.1.0-dev5+gabc1234 17 | BASE_VERSION="${GIT_TAG#v}" 18 | VERSION="${BASE_VERSION}-dev${COMMITS_SINCE}+g${GIT_COMMIT}" 19 | else 20 | VERSION="0.0.0-dev+g${GIT_COMMIT}" 21 | fi 22 | fi 23 | 24 | if [ "$1" = "--json" ]; then 25 | cat <= 8 { 36 | device := base.Device{ 37 | Vendor: "nvidia", 38 | } 39 | 40 | if val, err := strconv.Atoi(strings.TrimSpace(parts[0])); err == nil { 41 | device.Index = val 42 | } 43 | device.Name = strings.TrimSpace(parts[1]) 44 | 45 | if val, err := strconv.Atoi(strings.TrimSpace(parts[2])); err == nil { 46 | device.VRAMTotal = val 47 | } 48 | if val, err := strconv.Atoi(strings.TrimSpace(parts[3])); err == nil { 49 | device.VRAMUsed = val 50 | } 51 | if val, err := strconv.Atoi(strings.TrimSpace(parts[4])); err == nil { 52 | device.Utilization = val 53 | } 54 | if val, err := strconv.ParseFloat(strings.TrimSpace(parts[5]), 64); err == nil { 55 | device.PowerDraw = int(val) 56 | } 57 | if val, err := strconv.ParseFloat(strings.TrimSpace(parts[6]), 64); err == nil { 58 | device.PowerLimit = int(val) 59 | } 60 | if val, err := strconv.Atoi(strings.TrimSpace(parts[7])); err == nil { 61 | device.Temperature = val 62 | } 63 | 64 | devices = append(devices, device) 65 | } 66 | } 67 | 68 | return devices, nil 69 | } 70 | -------------------------------------------------------------------------------- /internal/ui/connect.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/alpindale/ssh-dashboard/internal" 7 | tea "github.com/charmbracelet/bubbletea" 8 | ) 9 | 10 | func (m Model) connectToHosts() tea.Cmd { 11 | var cmds []tea.Cmd 12 | for _, host := range m.selectedHosts { 13 | h := host 14 | cmds = append(cmds, func() tea.Msg { 15 | client, err := internal.NewSSHClient(h) 16 | return ConnectedMsg{hostName: h.Name, client: client, err: err} 17 | }) 18 | } 19 | if len(cmds) > 0 { 20 | return tea.Batch(cmds...) 21 | } 22 | return nil 23 | } 24 | 25 | func (m Model) connectToHost(host internal.SSHHost) tea.Cmd { 26 | return func() tea.Msg { 27 | client, err := internal.NewSSHClient(host) 28 | return ConnectedMsg{hostName: host.Name, client: client, err: err} 29 | } 30 | } 31 | 32 | func (m Model) connectNewHosts() tea.Cmd { 33 | var cmds []tea.Cmd 34 | for _, host := range m.selectedHosts { 35 | if m.clients[host.Name] == nil { 36 | h := host 37 | cmds = append(cmds, func() tea.Msg { 38 | client, err := internal.NewSSHClient(h) 39 | return ConnectedMsg{hostName: h.Name, client: client, err: err} 40 | }) 41 | } 42 | } 43 | if len(cmds) > 0 { 44 | return tea.Batch(cmds...) 45 | } 46 | return nil 47 | } 48 | 49 | func (m Model) gatherAllSysInfo() tea.Cmd { 50 | var cmds []tea.Cmd 51 | for _, host := range m.selectedHosts { 52 | h := host 53 | client := m.clients[h.Name] 54 | if client != nil { 55 | cmds = append(cmds, func() tea.Msg { 56 | info, err := internal.GatherSystemInfo(client) 57 | return SystemInfoMsg{hostName: h.Name, info: info, err: err} 58 | }) 59 | } 60 | } 61 | return tea.Batch(cmds...) 62 | } 63 | 64 | func (m Model) gatherSysInfoForHost(hostName string) tea.Cmd { 65 | client := m.clients[hostName] 66 | if client == nil { 67 | return nil 68 | } 69 | return func() tea.Msg { 70 | info, err := internal.GatherSystemInfo(client) 71 | return SystemInfoMsg{hostName: hostName, info: info, err: err} 72 | } 73 | } 74 | 75 | func (m Model) tick() tea.Cmd { 76 | return tea.Tick(m.updateInterval, func(t time.Time) tea.Msg { 77 | return TickMsg(t) 78 | }) 79 | } 80 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build clean install test run snapshot version 2 | 3 | BINARY_NAME=ssh-dashboard 4 | INSTALL_PATH=$(HOME)/.local/bin 5 | 6 | VERSION_LDFLAGS=$(shell ./scripts/get_version.sh --ldflags) 7 | 8 | build: 9 | @echo "Building $(shell ./scripts/get_version.sh)..." 10 | @go build -ldflags "$(VERSION_LDFLAGS)" -o ${BINARY_NAME} ./cmd/ssh_dashboard 11 | 12 | build-all: 13 | @echo "Building $(shell ./scripts/get_version.sh) for multiple platforms..." 14 | @GOOS=linux GOARCH=amd64 go build -ldflags "$(VERSION_LDFLAGS)" -o ${BINARY_NAME}-linux-amd64 ./cmd/ssh_dashboard 15 | @GOOS=darwin GOARCH=amd64 go build -ldflags "$(VERSION_LDFLAGS)" -o ${BINARY_NAME}-darwin-amd64 ./cmd/ssh_dashboard 16 | @GOOS=darwin GOARCH=arm64 go build -ldflags "$(VERSION_LDFLAGS)" -o ${BINARY_NAME}-darwin-arm64 ./cmd/ssh_dashboard 17 | @GOOS=windows GOARCH=amd64 go build -ldflags "$(VERSION_LDFLAGS)" -o ${BINARY_NAME}-windows-amd64.exe ./cmd/ssh_dashboard 18 | 19 | snapshot: 20 | @echo "Building snapshot with goreleaser..." 21 | @goreleaser release --snapshot --clean 22 | 23 | clean: 24 | @echo "Cleaning..." 25 | @go clean 26 | @rm -f ${BINARY_NAME} 27 | @rm -f ${BINARY_NAME}-* 28 | 29 | install: build 30 | @echo "Installing to ${INSTALL_PATH}..." 31 | @mkdir -p ${INSTALL_PATH} 32 | @cp ${BINARY_NAME} ${INSTALL_PATH}/ 33 | @chmod +x ${INSTALL_PATH}/${BINARY_NAME} 34 | @rm -f ${BINARY_NAME} 35 | @echo "Installed!" 36 | @echo "" 37 | @echo "Make sure ${INSTALL_PATH} is in your PATH" 38 | 39 | uninstall: 40 | @echo "Uninstalling from ${INSTALL_PATH}..." 41 | @rm -f ${INSTALL_PATH}/${BINARY_NAME} 42 | @echo "Uninstalled!" 43 | 44 | run: build 45 | @./${BINARY_NAME} 46 | 47 | test: 48 | @echo "Running tests..." 49 | @go test -v ./... 50 | 51 | deps: 52 | @echo "Downloading dependencies..." 53 | @go mod download 54 | @go mod tidy 55 | 56 | version: 57 | @./scripts/get_version.sh --json 58 | 59 | help: 60 | @echo "Available targets:" 61 | @echo " build - Build the binary with version info" 62 | @echo " build-all - Build for multiple platforms with version info" 63 | @echo " snapshot - Build snapshot with goreleaser" 64 | @echo " clean - Remove built binaries" 65 | @echo " install - Install to ${INSTALL_PATH}" 66 | @echo " uninstall - Remove from ${INSTALL_PATH}" 67 | @echo " run - Build and run" 68 | @echo " test - Run tests" 69 | @echo " deps - Download and tidy dependencies" 70 | @echo " version - Show current version information" 71 | 72 | -------------------------------------------------------------------------------- /internal/updater.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | const ( 12 | githubAPIURL = "https://api.github.com/repos/alpindale/ssh-dashboard/releases/latest" 13 | updateCheckTimeout = 3 * time.Second 14 | ) 15 | 16 | type UpdateInfo struct { 17 | Available bool 18 | LatestVersion string 19 | CurrentVersion string 20 | } 21 | 22 | type githubRelease struct { 23 | TagName string `json:"tag_name"` 24 | HTMLURL string `json:"html_url"` 25 | } 26 | 27 | func CheckForUpdates() UpdateInfo { 28 | currentVersion := Version 29 | if currentVersion == "dev" { 30 | return UpdateInfo{Available: false, CurrentVersion: currentVersion} 31 | } 32 | 33 | latestVersion, err := fetchLatestVersion() 34 | if err != nil { 35 | return UpdateInfo{Available: false, CurrentVersion: currentVersion} 36 | } 37 | 38 | needsUpdate := compareVersions(currentVersion, latestVersion) 39 | 40 | return UpdateInfo{ 41 | Available: needsUpdate, 42 | LatestVersion: latestVersion, 43 | CurrentVersion: currentVersion, 44 | } 45 | } 46 | 47 | func fetchLatestVersion() (string, error) { 48 | client := &http.Client{ 49 | Timeout: updateCheckTimeout, 50 | } 51 | 52 | resp, err := client.Get(githubAPIURL) 53 | if err != nil { 54 | return "", err 55 | } 56 | defer resp.Body.Close() 57 | 58 | if resp.StatusCode != http.StatusOK { 59 | return "", fmt.Errorf("github api returned status %d", resp.StatusCode) 60 | } 61 | 62 | var release githubRelease 63 | if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { 64 | return "", err 65 | } 66 | 67 | return release.TagName, nil 68 | } 69 | 70 | func compareVersions(current, latest string) bool { 71 | current = strings.TrimPrefix(current, "v") 72 | latest = strings.TrimPrefix(latest, "v") 73 | 74 | currentBase := strings.Split(strings.Split(current, "-")[0], "+")[0] 75 | latestBase := strings.Split(strings.Split(latest, "-")[0], "+")[0] 76 | 77 | currentParts := strings.Split(currentBase, ".") 78 | latestParts := strings.Split(latestBase, ".") 79 | 80 | for len(currentParts) < 3 { 81 | currentParts = append(currentParts, "0") 82 | } 83 | for len(latestParts) < 3 { 84 | latestParts = append(latestParts, "0") 85 | } 86 | 87 | for i := 0; i < 3; i++ { 88 | var currentNum, latestNum int 89 | fmt.Sscanf(currentParts[i], "%d", ¤tNum) 90 | fmt.Sscanf(latestParts[i], "%d", &latestNum) 91 | 92 | if latestNum > currentNum { 93 | return true 94 | } else if latestNum < currentNum { 95 | return false 96 | } 97 | } 98 | 99 | return false 100 | } 101 | -------------------------------------------------------------------------------- /internal/ui/view.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/alpindale/ssh-dashboard/internal" 8 | "github.com/charmbracelet/lipgloss" 9 | ) 10 | 11 | func (m Model) renderUpdateNotification() string { 12 | if !m.updateInfo.Available { 13 | return "" 14 | } 15 | 16 | updateStyle := lipgloss.NewStyle(). 17 | Foreground(lipgloss.Color("226")). 18 | Bold(true) 19 | 20 | currentVer := m.updateInfo.CurrentVersion 21 | if !strings.HasPrefix(currentVer, "v") { 22 | currentVer = "v" + currentVer 23 | } 24 | 25 | return updateStyle.Render(fmt.Sprintf("\n\n⬆ Update available! %s → %s", 26 | currentVer, m.updateInfo.LatestVersion)) 27 | } 28 | 29 | func (m Model) View() string { 30 | switch m.screen { 31 | case ScreenHostList: 32 | listView := m.list.View() 33 | if len(m.failedHosts) > 0 { 34 | failedDetails := make([]string, 0, len(m.failedHosts)) 35 | for hostName, err := range m.failedHosts { 36 | failedDetails = append(failedDetails, fmt.Sprintf("%s (%v)", hostName, err)) 37 | } 38 | warning := fmt.Sprintf("\n⚠ Failed to connect: %s", strings.Join(failedDetails, ", ")) 39 | listView += lipgloss.NewStyle().Foreground(lipgloss.Color("208")).Render(warning) 40 | } 41 | if len(m.selectedHosts) > 0 { 42 | selectedNames := make([]string, len(m.selectedHosts)) 43 | for i, h := range m.selectedHosts { 44 | selectedNames[i] = h.Name 45 | } 46 | footer := fmt.Sprintf("\nSelected (%d): %s", len(m.selectedHosts), strings.Join(selectedNames, ", ")) 47 | listView += lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render(footer) 48 | } 49 | versionFooter := fmt.Sprintf("\nv%s", internal.ShortVersion()) 50 | listView += lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(versionFooter) 51 | listView += m.renderUpdateNotification() 52 | return listView 53 | 54 | case ScreenConnecting: 55 | return m.renderConnectingProgress() 56 | 57 | case ScreenDashboard: 58 | if len(m.selectedHosts) > 0 && m.currentHostIdx < len(m.selectedHosts) { 59 | currentHost := m.selectedHosts[m.currentHostIdx] 60 | 61 | if m.clients[currentHost.Name] == nil || m.sysInfos[currentHost.Name] == nil { 62 | return m.renderConnectingProgress() 63 | } 64 | 65 | sysInfo := m.sysInfos[currentHost.Name] 66 | lastUpdate := m.lastUpdates[currentHost.Name] 67 | 68 | hostIndicator := "" 69 | if len(m.selectedHosts) > 1 { 70 | hostIndicator = fmt.Sprintf(" [%d/%d]", m.currentHostIdx+1, len(m.selectedHosts)) 71 | } 72 | dashboardView := renderDashboard(currentHost.Name+hostIndicator, sysInfo, m.updateInterval, lastUpdate, m.width, m.height, len(m.selectedHosts) > 1) 73 | return dashboardView + m.renderUpdateNotification() 74 | } 75 | return m.renderConnectingProgress() 76 | 77 | case ScreenOverview: 78 | overviewView := m.renderOverview() 79 | return overviewView + m.renderUpdateNotification() 80 | } 81 | 82 | return "" 83 | } 84 | -------------------------------------------------------------------------------- /cmd/ssh_dashboard/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "strconv" 9 | "syscall" 10 | "time" 11 | 12 | "github.com/alpindale/ssh-dashboard/internal" 13 | "github.com/alpindale/ssh-dashboard/internal/ui" 14 | tea "github.com/charmbracelet/bubbletea" 15 | ) 16 | 17 | func validateInterval(seconds float64) time.Duration { 18 | if seconds < 0.01 || seconds > 3600 { 19 | return 0 20 | } 21 | return time.Duration(seconds * float64(time.Second)) 22 | } 23 | 24 | func main() { 25 | var showVersion bool 26 | 27 | flag.Usage = func() { 28 | // HACK: make it look like python's argparse 29 | fmt.Fprintf(os.Stderr, "Usage: %s [OPTIONS] [HOST...]\n\n", os.Args[0]) 30 | fmt.Fprintf(os.Stderr, "Options:\n") 31 | fmt.Fprintf(os.Stderr, " -n, --interval float Update interval in seconds (default: 5, or SSH_DASHBOARD_INTERVAL env var)\n") 32 | fmt.Fprintf(os.Stderr, " -v, --version Show version information\n") 33 | fmt.Fprintf(os.Stderr, " -h, --help Show this help message\n") 34 | fmt.Fprintf(os.Stderr, "\nArguments:\n") 35 | fmt.Fprintf(os.Stderr, " HOST... One or more hostnames from SSH config to connect to directly\n") 36 | fmt.Fprintf(os.Stderr, " Example: ssh-dashboard myHost myOtherHost\n") 37 | } 38 | 39 | var updateIntervalVal float64 40 | flag.Float64Var(&updateIntervalVal, "n", 0, "") 41 | flag.Float64Var(&updateIntervalVal, "interval", 0, "") 42 | flag.BoolVar(&showVersion, "v", false, "") 43 | flag.BoolVar(&showVersion, "version", false, "") 44 | flag.Parse() 45 | 46 | requestedHosts := flag.Args() 47 | 48 | if showVersion { 49 | fmt.Printf("ssh-dashboard version %s\n", internal.FullVersion()) 50 | fmt.Printf(" git commit: %s\n", internal.GitCommit) 51 | fmt.Printf(" build date: %s\n", internal.BuildDate) 52 | fmt.Printf(" git tag: %s\n", internal.GitTag) 53 | os.Exit(0) 54 | } 55 | 56 | updateInterval := &updateIntervalVal 57 | 58 | interval := 5 * time.Second 59 | 60 | if *updateInterval > 0 { 61 | if validated := validateInterval(*updateInterval); validated > 0 { 62 | interval = validated 63 | } 64 | } else if envInterval := os.Getenv("SSH_DASHBOARD_INTERVAL"); envInterval != "" { 65 | if seconds, err := strconv.ParseFloat(envInterval, 64); err == nil { 66 | if validated := validateInterval(seconds); validated > 0 { 67 | interval = validated 68 | } 69 | } 70 | } 71 | 72 | hosts, err := internal.ParseSSHConfig("") 73 | if err != nil { 74 | fmt.Fprintf(os.Stderr, "Error parsing SSH config: %v\n", err) 75 | os.Exit(1) 76 | } 77 | 78 | if len(hosts) == 0 { 79 | fmt.Fprintf(os.Stderr, "No hosts found in SSH config\n") 80 | os.Exit(1) 81 | } 82 | 83 | var initialModel ui.Model 84 | 85 | if len(requestedHosts) > 0 { 86 | var selectedHosts []internal.SSHHost 87 | hostMap := make(map[string]internal.SSHHost) 88 | 89 | for _, host := range hosts { 90 | hostMap[host.Name] = host 91 | } 92 | 93 | for _, requestedName := range requestedHosts { 94 | if host, found := hostMap[requestedName]; found { 95 | selectedHosts = append(selectedHosts, host) 96 | } else { 97 | fmt.Fprintf(os.Stderr, "Host '%s' not found in SSH config\n", requestedName) 98 | os.Exit(1) 99 | } 100 | } 101 | 102 | initialModel = ui.InitialModelWithHosts(hosts, selectedHosts, interval) 103 | } else { 104 | initialModel = ui.InitialModel(hosts, interval) 105 | } 106 | 107 | p := tea.NewProgram(initialModel, tea.WithAltScreen()) 108 | finalModel, err := p.Run() 109 | if err != nil { 110 | fmt.Fprintf(os.Stderr, "Error running program: %v\n", err) 111 | os.Exit(1) 112 | } 113 | 114 | if m, ok := finalModel.(ui.Model); ok { 115 | if sshHost := m.GetSSHOnExit(); sshHost != "" { 116 | sshPath, err := exec.LookPath("ssh") 117 | if err != nil { 118 | fmt.Fprintf(os.Stderr, "Error finding ssh: %v\n", err) 119 | os.Exit(1) 120 | } 121 | 122 | args := []string{"ssh", sshHost} 123 | env := os.Environ() 124 | 125 | err = syscall.Exec(sshPath, args, env) 126 | if err != nil { 127 | fmt.Fprintf(os.Stderr, "Error executing ssh: %v\n", err) 128 | os.Exit(1) 129 | } 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /internal/ui/update.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/alpindale/ssh-dashboard/internal" 7 | tea "github.com/charmbracelet/bubbletea" 8 | ) 9 | 10 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 11 | switch msg := msg.(type) { 12 | case tea.KeyMsg: 13 | switch msg.String() { 14 | case "ctrl+c", "q": 15 | for _, client := range m.clients { 16 | if client != nil { 17 | client.Close() 18 | } 19 | } 20 | return m, tea.Quit 21 | case " ": 22 | if m.screen == ScreenHostList { 23 | if item, ok := m.list.SelectedItem().(hostItem); ok { 24 | host := item.host 25 | found := false 26 | for i, h := range m.selectedHosts { 27 | if h.Name == host.Name { 28 | m.selectedHosts = append(m.selectedHosts[:i], m.selectedHosts[i+1:]...) 29 | found = true 30 | break 31 | } 32 | } 33 | if !found { 34 | m.selectedHosts = append(m.selectedHosts, host) 35 | } 36 | m.updateListSelection() 37 | } 38 | } 39 | case "enter": 40 | if m.screen == ScreenHostList { 41 | if len(m.selectedHosts) == 0 { 42 | if item, ok := m.list.SelectedItem().(hostItem); ok { 43 | m.selectedHosts = append(m.selectedHosts, item.host) 44 | } 45 | } 46 | if len(m.selectedHosts) > 0 { 47 | m.failedHosts = make(map[string]error) 48 | 49 | hasConnections := len(m.clients) > 0 50 | 51 | if hasConnections { 52 | m.screen = ScreenDashboard 53 | cmd := m.connectNewHosts() 54 | if cmd != nil { 55 | return m, cmd 56 | } 57 | } else { 58 | m.screen = ScreenConnecting 59 | return m, m.connectToHosts() 60 | } 61 | } 62 | } 63 | case "n": 64 | if m.screen == ScreenDashboard && len(m.selectedHosts) > 1 { 65 | m.currentHostIdx = (m.currentHostIdx + 1) % len(m.selectedHosts) 66 | nextHost := m.selectedHosts[m.currentHostIdx] 67 | if m.clients[nextHost.Name] == nil { 68 | return m, m.connectToHost(nextHost) 69 | } 70 | } 71 | case "c": 72 | if m.screen == ScreenDashboard || m.screen == ScreenOverview { 73 | m.screen = ScreenHostList 74 | m.updateListSelection() 75 | } 76 | case "t": 77 | if m.screen == ScreenDashboard && len(m.selectedHosts) > 1 { 78 | m.screen = ScreenOverview 79 | } else if m.screen == ScreenOverview { 80 | m.screen = ScreenDashboard 81 | } 82 | case "s": 83 | if m.screen == ScreenDashboard { 84 | if len(m.selectedHosts) > 0 { 85 | currentHost := m.selectedHosts[m.currentHostIdx] 86 | m.sshOnExit = currentHost.Name 87 | for _, client := range m.clients { 88 | if client != nil { 89 | client.Close() 90 | } 91 | } 92 | return m, tea.Quit 93 | } 94 | } 95 | } 96 | 97 | case tea.WindowSizeMsg: 98 | m.width = msg.Width 99 | m.height = msg.Height 100 | m.list.SetSize(msg.Width, msg.Height-2) 101 | 102 | case ConnectedMsg: 103 | if msg.err != nil { 104 | m.failedHosts[msg.hostName] = msg.err 105 | 106 | for i, h := range m.selectedHosts { 107 | if h.Name == msg.hostName { 108 | m.selectedHosts = append(m.selectedHosts[:i], m.selectedHosts[i+1:]...) 109 | break 110 | } 111 | } 112 | 113 | if len(m.selectedHosts) == 0 { 114 | m.screen = ScreenHostList 115 | m.updateListSelection() 116 | } else { 117 | if m.currentHostIdx >= len(m.selectedHosts) { 118 | m.currentHostIdx = len(m.selectedHosts) - 1 119 | } 120 | } 121 | return m, nil 122 | } 123 | m.clients[msg.hostName] = msg.client 124 | 125 | if m.screen == ScreenConnecting { 126 | return m, m.gatherSysInfoForHost(msg.hostName) 127 | } 128 | 129 | if m.screen == ScreenDashboard || m.screen == ScreenOverview { 130 | return m, m.gatherSysInfoForHost(msg.hostName) 131 | } 132 | 133 | case SystemInfoMsg: 134 | if msg.err != nil { 135 | return m, nil 136 | } 137 | m.sysInfos[msg.hostName] = msg.info 138 | m.lastUpdates[msg.hostName] = time.Now() 139 | 140 | if m.screen == ScreenConnecting && len(m.selectedHosts) > 0 { 141 | firstHost := m.selectedHosts[0] 142 | if m.clients[firstHost.Name] != nil && m.sysInfos[firstHost.Name] != nil { 143 | m.screen = ScreenDashboard 144 | return m, m.tick() 145 | } 146 | } 147 | 148 | case UpdateCheckMsg: 149 | m.updateInfo = internal.UpdateInfo(msg) 150 | 151 | case TickMsg: 152 | // update every 10 seconds 153 | return m, tea.Batch(m.gatherAllSysInfo(), m.tick()) 154 | } 155 | 156 | var spinnerCmd tea.Cmd 157 | m.spinner, spinnerCmd = m.spinner.Update(msg) 158 | 159 | if m.screen == ScreenHostList { 160 | var listCmd tea.Cmd 161 | m.list, listCmd = m.list.Update(msg) 162 | return m, tea.Batch(spinnerCmd, listCmd) 163 | } 164 | 165 | return m, spinnerCmd 166 | } 167 | -------------------------------------------------------------------------------- /internal/sysinfo.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/alpindale/ssh-dashboard/internal/gpu" 9 | ) 10 | 11 | type SystemInfo struct { 12 | CPU CPUInfo 13 | GPUs []GPUInfo 14 | RAM RAMInfo 15 | Disk []DiskInfo 16 | } 17 | 18 | type CPUInfo struct { 19 | Model string 20 | Count string 21 | Usage string 22 | } 23 | 24 | type GPUInfo struct { 25 | Index string 26 | Name string 27 | VRAMTotal int // in MB 28 | VRAMUsed int // in MB 29 | Utilization int // percentage 30 | PowerDraw int // in Watts 31 | PowerLimit int // in Watts 32 | Temperature int // in Celsius 33 | } 34 | 35 | type RAMInfo struct { 36 | Total int // in MB 37 | Used int // in MB 38 | UsagePercent float64 39 | } 40 | 41 | type DiskInfo struct { 42 | Device string 43 | Size string 44 | Used string 45 | Available string 46 | UsagePercent string 47 | MountPoint string 48 | } 49 | 50 | func GatherSystemInfo(client *SSHClient) (*SystemInfo, error) { 51 | info := &SystemInfo{} 52 | 53 | cpuInfo, err := getCPUInfo(client) 54 | if err != nil { 55 | return nil, fmt.Errorf("failed to get CPU info: %w", err) 56 | } 57 | info.CPU = cpuInfo 58 | 59 | gpuInfo, _ := getGPUInfo(client) 60 | info.GPUs = gpuInfo 61 | 62 | ramInfo, err := getRAMInfo(client) 63 | if err != nil { 64 | return nil, fmt.Errorf("failed to get RAM info: %w", err) 65 | } 66 | info.RAM = ramInfo 67 | 68 | diskInfo, err := getDiskInfo(client) 69 | if err != nil { 70 | return nil, fmt.Errorf("failed to get disk info: %w", err) 71 | } 72 | info.Disk = diskInfo 73 | 74 | return info, nil 75 | } 76 | 77 | func getCPUInfo(client *SSHClient) (CPUInfo, error) { 78 | info := CPUInfo{} 79 | 80 | output, err := client.ExecuteCommand("lscpu | grep -E 'Model name|CPU\\(s\\):'") 81 | if err == nil { 82 | lines := strings.Split(output, "\n") 83 | for _, line := range lines { 84 | if strings.Contains(line, "Model name:") { 85 | parts := strings.SplitN(line, ":", 2) 86 | if len(parts) == 2 { 87 | info.Model = strings.TrimSpace(parts[1]) 88 | } 89 | } else if strings.HasPrefix(line, "CPU(s):") { 90 | parts := strings.SplitN(line, ":", 2) 91 | if len(parts) == 2 { 92 | info.Count = strings.TrimSpace(parts[1]) 93 | } 94 | } 95 | } 96 | } 97 | 98 | output, err = client.ExecuteCommand("top -bn1 | grep 'Cpu(s)' | sed 's/.*, *\\([0-9.]*\\)%* id.*/\\1/' | awk '{print 100 - $1}'") 99 | if err == nil { 100 | usage := strings.TrimSpace(output) 101 | if usage != "" { 102 | if val, err := strconv.ParseFloat(usage, 64); err == nil { 103 | info.Usage = fmt.Sprintf("%.1f%%", val) 104 | } 105 | } 106 | } 107 | 108 | if info.Usage == "" { 109 | info.Usage = "N/A" 110 | } 111 | 112 | return info, nil 113 | } 114 | 115 | func getGPUInfo(client *SSHClient) ([]GPUInfo, error) { 116 | runCmd := func(cmd string) (string, error) { 117 | return client.ExecuteCommand(cmd) 118 | } 119 | 120 | devices, err := gpu.QueryAll(runCmd) 121 | if err != nil { 122 | return []GPUInfo{}, nil 123 | } 124 | 125 | gpus := make([]GPUInfo, len(devices)) 126 | for i, dev := range devices { 127 | gpus[i] = GPUInfo{ 128 | Index: fmt.Sprintf("%d", dev.Index), 129 | Name: dev.Name, 130 | VRAMTotal: dev.VRAMTotal, 131 | VRAMUsed: dev.VRAMUsed, 132 | Utilization: dev.Utilization, 133 | PowerDraw: dev.PowerDraw, 134 | PowerLimit: dev.PowerLimit, 135 | Temperature: dev.Temperature, 136 | } 137 | } 138 | 139 | return gpus, nil 140 | } 141 | 142 | func getRAMInfo(client *SSHClient) (RAMInfo, error) { 143 | info := RAMInfo{} 144 | 145 | output, err := client.ExecuteCommand("free -m | grep Mem:") 146 | if err != nil { 147 | return info, err 148 | } 149 | 150 | parts := strings.Fields(output) 151 | if len(parts) >= 3 { 152 | if val, err := strconv.Atoi(parts[1]); err == nil { 153 | info.Total = val 154 | } 155 | if val, err := strconv.Atoi(parts[2]); err == nil { 156 | info.Used = val 157 | } 158 | 159 | if info.Total > 0 { 160 | info.UsagePercent = (float64(info.Used) / float64(info.Total)) * 100 161 | } 162 | } 163 | 164 | return info, nil 165 | } 166 | 167 | func getDiskInfo(client *SSHClient) ([]DiskInfo, error) { 168 | output, err := client.ExecuteCommand("df -h | grep -E '^/dev/'") 169 | if err != nil { 170 | return nil, err 171 | } 172 | 173 | var disks []DiskInfo 174 | lines := strings.Split(strings.TrimSpace(output), "\n") 175 | for _, line := range lines { 176 | if line == "" { 177 | continue 178 | } 179 | 180 | parts := strings.Fields(line) 181 | if len(parts) >= 6 { 182 | disk := DiskInfo{ 183 | Device: parts[0], 184 | Size: parts[1], 185 | Used: parts[2], 186 | Available: parts[3], 187 | UsagePercent: parts[4], 188 | MountPoint: parts[5], 189 | } 190 | disks = append(disks, disk) 191 | } 192 | } 193 | 194 | return disks, nil 195 | } 196 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 2 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 3 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 5 | github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= 6 | github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 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.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= 10 | github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= 11 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 12 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 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/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= 16 | github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 17 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 18 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 19 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= 20 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 21 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 22 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 23 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 24 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 25 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 26 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 27 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 28 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 29 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 30 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 31 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 32 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 33 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 34 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 35 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 36 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 37 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 38 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 39 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 40 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 41 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 42 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 43 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 44 | github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= 45 | github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 46 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 47 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 48 | golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= 49 | golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= 50 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= 51 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 52 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 53 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 54 | golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= 55 | golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 56 | golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= 57 | golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= 58 | golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= 59 | golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= 60 | -------------------------------------------------------------------------------- /internal/gpu/amd.go: -------------------------------------------------------------------------------- 1 | package gpu 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/alpindale/ssh-dashboard/internal/gpu/base" 10 | ) 11 | 12 | type AMDProvider struct{} 13 | 14 | func (p AMDProvider) Name() string { 15 | return "amd" 16 | } 17 | 18 | func (p AMDProvider) Detect(runCmd base.RunCmdFunc) bool { 19 | if _, err := runCmd("which amd-smi"); err == nil { 20 | return true 21 | } 22 | if _, err := runCmd("which rocm-smi"); err == nil { 23 | return true 24 | } 25 | return false 26 | } 27 | 28 | func (p AMDProvider) Query(runCmd base.RunCmdFunc) ([]base.Device, error) { 29 | if _, err := runCmd("which amd-smi"); err == nil { 30 | return p.queryModern(runCmd) 31 | } 32 | return p.queryLegacy(runCmd) 33 | } 34 | 35 | func (p AMDProvider) queryModern(runCmd base.RunCmdFunc) ([]base.Device, error) { 36 | staticOutput, err := runCmd("amd-smi static --json 2>/dev/null") 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | metricsOutput, err := runCmd("amd-smi metric --usage --power --temperature --mem-usage --json 2>/dev/null") 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | var staticData struct { 47 | GPUData []struct { 48 | GPU int `json:"gpu"` 49 | ASIC struct { 50 | MarketName string `json:"market_name"` 51 | } `json:"asic"` 52 | VRAM struct { 53 | Size struct { 54 | Value int `json:"value"` 55 | Unit string `json:"unit"` 56 | } `json:"size"` 57 | } `json:"vram"` 58 | } `json:"gpu_data"` 59 | } 60 | 61 | var metricsData struct { 62 | GPUData []struct { 63 | GPU int `json:"gpu"` 64 | Usage struct { 65 | GFXActivity struct { 66 | Value int `json:"value"` 67 | } `json:"gfx_activity"` 68 | } `json:"usage"` 69 | Power struct { 70 | SocketPower struct { 71 | Value int `json:"value"` 72 | } `json:"socket_power"` 73 | } `json:"power"` 74 | Temperature struct { 75 | Hotspot struct { 76 | Value int `json:"value"` 77 | } `json:"hotspot"` 78 | } `json:"temperature"` 79 | MemUsage struct { 80 | TotalVRAM struct { 81 | Value int `json:"value"` 82 | } `json:"total_vram"` 83 | UsedVRAM struct { 84 | Value int `json:"value"` 85 | } `json:"used_vram"` 86 | } `json:"mem_usage"` 87 | } `json:"gpu_data"` 88 | } 89 | 90 | if err := json.Unmarshal([]byte(staticOutput), &staticData); err != nil { 91 | return nil, err 92 | } 93 | 94 | if err := json.Unmarshal([]byte(metricsOutput), &metricsData); err != nil { 95 | return nil, err 96 | } 97 | 98 | var devices []base.Device 99 | for i, static := range staticData.GPUData { 100 | if i >= len(metricsData.GPUData) { 101 | break 102 | } 103 | metrics := metricsData.GPUData[i] 104 | 105 | device := base.Device{ 106 | Index: static.GPU, 107 | Name: static.ASIC.MarketName, 108 | VRAMTotal: metrics.MemUsage.TotalVRAM.Value, 109 | VRAMUsed: metrics.MemUsage.UsedVRAM.Value, 110 | Utilization: metrics.Usage.GFXActivity.Value, 111 | PowerDraw: metrics.Power.SocketPower.Value, 112 | PowerLimit: 700, // AMD doesn't always report this, conservative estimate 113 | Temperature: metrics.Temperature.Hotspot.Value, 114 | Vendor: "amd", 115 | } 116 | devices = append(devices, device) 117 | } 118 | 119 | return devices, nil 120 | } 121 | 122 | func (p AMDProvider) queryLegacy(runCmd base.RunCmdFunc) ([]base.Device, error) { 123 | output, err := runCmd("rocm-smi --showproductname --showmeminfo vram --showuse -t -P --csv 2>/dev/null") 124 | if err != nil { 125 | return nil, err 126 | } 127 | 128 | var devices []base.Device 129 | lines := strings.Split(strings.TrimSpace(output), "\n") 130 | 131 | if len(lines) < 2 { 132 | return nil, fmt.Errorf("insufficient output from rocm-smi") 133 | } 134 | 135 | for i, line := range lines[1:] { 136 | if line == "" { 137 | continue 138 | } 139 | 140 | parts := strings.Split(line, ",") 141 | if len(parts) < 7 { 142 | continue 143 | } 144 | 145 | device := base.Device{ 146 | Index: i, 147 | Vendor: "amd", 148 | } 149 | 150 | if val, err := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64); err == nil { 151 | device.Temperature = int(val) 152 | } 153 | 154 | if val, err := strconv.ParseFloat(strings.TrimSpace(parts[3]), 64); err == nil { 155 | device.PowerDraw = int(val) 156 | } 157 | 158 | if val, err := strconv.Atoi(strings.TrimSpace(parts[4])); err == nil { 159 | device.Utilization = val 160 | } 161 | 162 | if val, err := strconv.Atoi(strings.TrimSpace(parts[6])); err == nil { 163 | memOutput, err := runCmd(fmt.Sprintf("rocm-smi -d %d --showmeminfo vram --csv 2>/dev/null | grep -i 'Total VRAM'", i)) 164 | if err == nil { 165 | memParts := strings.Split(memOutput, ",") 166 | if len(memParts) >= 2 { 167 | vramStr := strings.TrimSpace(memParts[1]) 168 | vramStr = strings.TrimSuffix(vramStr, " MB") 169 | if totalVRAM, err := strconv.Atoi(strings.TrimSpace(vramStr)); err == nil { 170 | device.VRAMTotal = totalVRAM 171 | device.VRAMUsed = (totalVRAM * val) / 100 172 | } 173 | } 174 | } 175 | } 176 | 177 | if len(parts) >= 12 { 178 | series := strings.TrimSpace(parts[10]) 179 | model := strings.TrimSpace(parts[11]) 180 | if series != "" { 181 | device.Name = series 182 | if model != "" && model != series { 183 | device.Name = fmt.Sprintf("%s (%s)", series, model) 184 | } 185 | } 186 | } 187 | 188 | if device.Name == "" { 189 | device.Name = "AMD GPU" 190 | } 191 | 192 | device.PowerLimit = 300 // Conservative estimate for legacy AMD GPUs 193 | 194 | devices = append(devices, device) 195 | } 196 | 197 | return devices, nil 198 | } 199 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SSH Dashboard 2 | 3 | Monitor CPU, GPU, RAM, and disk usage on your remote servers with a live-updating terminal dashboard. 4 | 5 | ### Single-host monitoring: 6 |
7 | SSH Dashboard Screenshot 8 |
9 | 10 | ### Multi-host monitoring: 11 |
12 | SSH Dashboard Screenshot 13 |
14 | 15 | ## Installation 16 | 17 | ### Pre-built Binaries 18 | 19 | Download the latest release for your platform from the [Releases page](https://github.com/AlpinDale/ssh-dashboard/releases). 20 | 21 | **Quick install (Linux/macOS):** 22 | ```bash 23 | # Download the binary for your platform 24 | # Example for Linux AMD64: 25 | curl -L -o ssh-dashboard https://github.com/AlpinDale/ssh-dashboard/releases/download/v0.0.1/ssh-dashboard-v0.0.1-linux-amd64 26 | 27 | # Make it executable and move to PATH 28 | chmod +x ssh-dashboard 29 | sudo mv ssh-dashboard /usr/local/bin/ 30 | ``` 31 | 32 | **Supported platforms:** 33 | - `linux-amd64`, `linux-arm64` 34 | - `darwin-amd64` (Intel Mac), `darwin-arm64` (Apple Silicon) 35 | - `windows-amd64.exe`, `windows-arm64.exe` 36 | 37 | ### From Source 38 | 39 | #### AUR 40 | ```bash 41 | yay -S ssh-dashboard-git 42 | ``` 43 | 44 | #### Manual 45 | 46 | ```bash 47 | git clone https://github.com/AlpinDale/ssh-dashboard.git 48 | cd ssh-dashboard 49 | make install 50 | ``` 51 | 52 | This will install to `~/.local/bin`. Make sure this directory is in your PATH (it usually is): 53 | 54 | ```bash 55 | export PATH="$HOME/.local/bin:$PATH" 56 | ``` 57 | 58 | ### Prerequisites 59 | 60 | - Go 1.21 or higher 61 | - SSH access to remote hosts 62 | - SSH keys loaded in your SSH agent 63 | 64 | ## Usage 65 | 66 | Simply run: 67 | 68 | ```bash 69 | ssh-dashboard 70 | ``` 71 | 72 | The tool will: 73 | 1. Scan your `~/.ssh/config` for available hosts 74 | 2. Present an interactive list to select from 75 | 3. Connect and display a live dashboard 76 | 4. Update stats every 5 seconds (configurable) 77 | 78 | **Multi-host workflow:** 79 | - Start by selecting one or more hosts (use `Space` to toggle selection) 80 | - Press `Enter` to connect and view the dashboard 81 | - While in the dashboard, press `c` to return to host selection to add/remove hosts 82 | - Press `n` to cycle through connected hosts (like tmux sessions) 83 | - Press `t` to toggle overview mode, showing all selected hosts at once with GPU pressure summaries 84 | - Press `s` to exit the dashboard and drop into an interactive SSH shell with the current host 85 | - All connections remain active - no need to reconnect! 86 | 87 | ### Configuration 88 | 89 | **Update Interval:** 90 | 91 | Control how often the dashboard refreshes in seconds (default: 5). Supports decimal values for sub-second updates: 92 | 93 | ```bash 94 | # Update every second 95 | ssh-dashboard -n 1 96 | 97 | # Update 10 times per second (100ms) 98 | ssh-dashboard -n 0.1 99 | 100 | # or with an env var 101 | export SSH_DASHBOARD_INTERVAL=0.5 102 | ssh-dashboard 103 | ``` 104 | 105 | **Keybindings:** 106 | - `q` or `Ctrl+C` - Quit 107 | - `Space` - Select/deselect hosts (in host selection screen) 108 | - `Enter` - Connect to selected host(s) 109 | - `n` - Switch to next host (when multiple hosts selected) 110 | - `t` - Toggle overview screen (shows all hosts at once) 111 | - `s` - Exit and SSH into current host 112 | - `c` - Add hosts (from dashboard, returns to host selection) 113 | 114 | ## SSH Configuration 115 | 116 | Make sure your `~/.ssh/config` is properly configured: 117 | 118 | ``` 119 | Host myserver 120 | HostName 192.168.1.100 121 | User username 122 | Port 22 # optional 123 | IdentityFile ~/.ssh/id_rsa # optional 124 | 125 | Host gpu-server 126 | HostName gpu.example.com 127 | User admin 128 | IdentityFile ~/.ssh/id_ed25519 # optional 129 | ``` 130 | 131 | ### SSH Agent 132 | 133 | The dashboard uses SSH agent for authentication. Make sure your keys are loaded: 134 | 135 | ```bash 136 | ssh-add ~/.ssh/id_rsa 137 | ssh-add ~/.ssh/id_ed25519 138 | 139 | # verify 140 | ssh-add -l 141 | ``` 142 | 143 | ## Remote Requirements 144 | 145 | The remote hosts should have these commands available: 146 | - `lscpu` - CPU information 147 | - `top` - CPU usage 148 | - `free` - RAM information 149 | - `df` - Disk usage 150 | - `nvidia-smi` - GPU information (NVIDIA GPUs only) 151 | - `amd-smi` or `rocm-smi` - GPU information (AMD GPUs only) 152 | 153 | Most Linux distributions include these by default. 154 | 155 | ## Development 156 | 157 | ### Build 158 | 159 | ```bash 160 | make build 161 | ``` 162 | 163 | ### Run 164 | 165 | ```bash 166 | make run 167 | ``` 168 | 169 | ### Build for Multiple Platforms 170 | 171 | The project uses [GoReleaser](https://goreleaser.com/) for multi-platform builds and releases. 172 | 173 | **Test the release locally:** 174 | ```bash 175 | goreleaser release --snapshot --clean 176 | ``` 177 | 178 | **Build all platforms with Make:** 179 | ```bash 180 | make build-all 181 | ``` 182 | 183 | This creates binaries for: 184 | - Linux (amd64, arm64) 185 | - macOS (amd64, arm64) 186 | - Windows (amd64, arm64) 187 | 188 | ### Clean 189 | 190 | ```bash 191 | make clean 192 | ``` 193 | 194 | ## License 195 | 196 | MIT License - see LICENSE file for details. 197 | 198 | ## Contributing 199 | 200 | Contributions are welcome! Please feel free to submit a Pull Request. 201 | 202 | ## Troubleshooting 203 | 204 | ### Connection Issues 205 | - Verify your SSH config is correct 206 | - Test manual connection: `ssh hostname` 207 | - Ensure SSH keys are loaded: `ssh-add -l` 208 | 209 | ### Missing GPU Information 210 | - (NVIDIA) Verify NVIDIA drivers are installed: `ssh hostname nvidia-smi` 211 | - (AMD) Verify AMD drivers are installed: `ssh hostname amd-smi` or `ssh hostname rocm-smi` 212 | 213 | ### Permission Denied 214 | - Check SSH key permissions (should be 600) 215 | - Verify the user has appropriate access rights 216 | 217 | ## Acknowledgments 218 | 219 | Built with: 220 | - [Bubble Tea](https://github.com/charmbracelet/bubbletea) - TUI framework 221 | - [Lipgloss](https://github.com/charmbracelet/lipgloss) - Terminal styling 222 | - [Bubbles](https://github.com/charmbracelet/bubbles) - TUI components 223 | -------------------------------------------------------------------------------- /internal/ui/model.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/alpindale/ssh-dashboard/internal" 9 | "github.com/charmbracelet/bubbles/list" 10 | "github.com/charmbracelet/bubbles/spinner" 11 | tea "github.com/charmbracelet/bubbletea" 12 | "github.com/charmbracelet/lipgloss" 13 | ) 14 | 15 | type Screen int 16 | 17 | const ( 18 | ScreenHostList Screen = iota 19 | ScreenConnecting 20 | ScreenDashboard 21 | ScreenOverview 22 | ) 23 | 24 | type Model struct { 25 | screen Screen 26 | hosts []internal.SSHHost 27 | selectedHosts []internal.SSHHost 28 | currentHostIdx int 29 | list list.Model 30 | spinner spinner.Model 31 | clients map[string]*internal.SSHClient 32 | sysInfos map[string]*internal.SystemInfo 33 | lastUpdates map[string]time.Time 34 | updateInterval time.Duration 35 | failedHosts map[string]error 36 | width int 37 | height int 38 | sshOnExit string 39 | updateInfo internal.UpdateInfo 40 | } 41 | 42 | type TickMsg time.Time 43 | 44 | type UpdateCheckMsg internal.UpdateInfo 45 | 46 | type SystemInfoMsg struct { 47 | hostName string 48 | info *internal.SystemInfo 49 | err error 50 | } 51 | 52 | type ConnectedMsg struct { 53 | hostName string 54 | client *internal.SSHClient 55 | err error 56 | } 57 | 58 | type hostItem struct { 59 | host internal.SSHHost 60 | selected bool 61 | } 62 | 63 | func (h hostItem) FilterValue() string { return h.host.Name } 64 | func (h hostItem) Title() string { 65 | prefix := " " 66 | if h.selected { 67 | prefix = "✓ " 68 | } 69 | return prefix + h.host.Name 70 | } 71 | func (h hostItem) Description() string { 72 | if h.host.Hostname != "" { 73 | return fmt.Sprintf(" %s@%s:%s", h.host.User, censorHostname(h.host.Hostname), h.host.Port) 74 | } 75 | return "" 76 | } 77 | 78 | func checkForUpdates() tea.Msg { 79 | updateInfo := internal.CheckForUpdates() 80 | return UpdateCheckMsg(updateInfo) 81 | } 82 | 83 | func censorHostname(hostname string) string { 84 | if hostname == "" { 85 | return "" 86 | } 87 | 88 | if strings.Contains(hostname, ".") { 89 | parts := strings.Split(hostname, ".") 90 | if len(parts) >= 4 { 91 | lastOctet := parts[len(parts)-1] 92 | lastPart := lastOctet 93 | if len(lastOctet) > 2 { 94 | lastPart = lastOctet[len(lastOctet)-2:] 95 | } 96 | return fmt.Sprintf("%s.***.***%s", parts[0], lastPart) 97 | } 98 | } 99 | 100 | if len(hostname) <= 8 { 101 | if len(hostname) <= 3 { 102 | return hostname 103 | } 104 | return hostname[:2] + strings.Repeat("*", len(hostname)-2) 105 | } 106 | 107 | return hostname[:3] + strings.Repeat("*", 5) + hostname[len(hostname)-3:] 108 | } 109 | 110 | func formatInterval(interval time.Duration) string { 111 | seconds := interval.Seconds() 112 | if seconds < 1 { 113 | return fmt.Sprintf("%.2fs", seconds) 114 | } else if seconds < 10 { 115 | return fmt.Sprintf("%.1fs", seconds) 116 | } 117 | return fmt.Sprintf("%.0fs", seconds) 118 | } 119 | 120 | func InitialModel(hosts []internal.SSHHost, updateInterval time.Duration) Model { 121 | items := make([]list.Item, len(hosts)) 122 | for i, h := range hosts { 123 | items[i] = hostItem{host: h, selected: false} 124 | } 125 | 126 | delegate := list.NewDefaultDelegate() 127 | l := list.New(items, delegate, 0, 0) 128 | l.Title = "Select SSH Hosts to Monitor (Space to select, Enter to confirm)" 129 | l.SetShowStatusBar(false) 130 | l.SetFilteringEnabled(true) 131 | 132 | s := spinner.New() 133 | s.Spinner = spinner.Dot 134 | s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) 135 | 136 | return Model{ 137 | screen: ScreenHostList, 138 | hosts: hosts, 139 | list: l, 140 | spinner: s, 141 | clients: make(map[string]*internal.SSHClient), 142 | sysInfos: make(map[string]*internal.SystemInfo), 143 | lastUpdates: make(map[string]time.Time), 144 | failedHosts: make(map[string]error), 145 | updateInterval: updateInterval, 146 | } 147 | } 148 | 149 | func InitialModelWithHost(host internal.SSHHost, updateInterval time.Duration) Model { 150 | return InitialModelWithHosts([]internal.SSHHost{host}, []internal.SSHHost{host}, updateInterval) 151 | } 152 | 153 | func InitialModelWithHosts(allHosts []internal.SSHHost, selectedHosts []internal.SSHHost, updateInterval time.Duration) Model { 154 | items := make([]list.Item, len(allHosts)) 155 | selectedMap := make(map[string]bool) 156 | for _, h := range selectedHosts { 157 | selectedMap[h.Name] = true 158 | } 159 | 160 | for i, h := range allHosts { 161 | items[i] = hostItem{host: h, selected: selectedMap[h.Name]} 162 | } 163 | 164 | delegate := list.NewDefaultDelegate() 165 | l := list.New(items, delegate, 0, 0) 166 | l.Title = "Select SSH Hosts to Monitor (Space to select, Enter to confirm)" 167 | l.SetShowStatusBar(false) 168 | l.SetFilteringEnabled(true) 169 | 170 | s := spinner.New() 171 | s.Spinner = spinner.Dot 172 | s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) 173 | 174 | return Model{ 175 | screen: ScreenConnecting, 176 | hosts: allHosts, 177 | selectedHosts: selectedHosts, 178 | currentHostIdx: 0, 179 | list: l, 180 | spinner: s, 181 | clients: make(map[string]*internal.SSHClient), 182 | sysInfos: make(map[string]*internal.SystemInfo), 183 | lastUpdates: make(map[string]time.Time), 184 | failedHosts: make(map[string]error), 185 | updateInterval: updateInterval, 186 | } 187 | } 188 | 189 | func (m Model) GetSSHOnExit() string { 190 | return m.sshOnExit 191 | } 192 | 193 | func (m *Model) updateListSelection() { 194 | items := m.list.Items() 195 | selectedMap := make(map[string]bool) 196 | for _, h := range m.selectedHosts { 197 | selectedMap[h.Name] = true 198 | } 199 | 200 | newItems := make([]list.Item, len(items)) 201 | for i, item := range items { 202 | if hi, ok := item.(hostItem); ok { 203 | hi.selected = selectedMap[hi.host.Name] 204 | newItems[i] = hi 205 | } 206 | } 207 | m.list.SetItems(newItems) 208 | } 209 | 210 | func (m Model) Init() tea.Cmd { 211 | if m.screen == ScreenConnecting && len(m.selectedHosts) > 0 { 212 | return tea.Batch(m.spinner.Tick, m.connectToHosts(), checkForUpdates) 213 | } 214 | return tea.Batch(m.spinner.Tick, checkForUpdates) 215 | } 216 | -------------------------------------------------------------------------------- /internal/ssh.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "net" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | "time" 11 | 12 | "golang.org/x/crypto/ssh" 13 | "golang.org/x/crypto/ssh/agent" 14 | "golang.org/x/crypto/ssh/knownhosts" 15 | ) 16 | 17 | type SSHHost struct { 18 | Name string 19 | Hostname string 20 | User string 21 | Port string 22 | IdentityFile string 23 | } 24 | 25 | type SSHClient struct { 26 | client *ssh.Client 27 | config *SSHHost 28 | } 29 | 30 | func ParseSSHConfig(configPath string) ([]SSHHost, error) { 31 | if configPath == "" { 32 | home, err := os.UserHomeDir() 33 | if err != nil { 34 | return nil, err 35 | } 36 | configPath = filepath.Join(home, ".ssh", "config") 37 | } 38 | 39 | visited := make(map[string]bool) 40 | return parseSSHConfigRecursive(configPath, visited) 41 | } 42 | 43 | func parseSSHConfigRecursive(configPath string, visited map[string]bool) ([]SSHHost, error) { 44 | absPath, err := filepath.Abs(configPath) 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | if visited[absPath] { 50 | return nil, nil 51 | } 52 | visited[absPath] = true 53 | 54 | file, err := os.Open(configPath) 55 | if err != nil { 56 | return nil, err 57 | } 58 | defer file.Close() 59 | 60 | var hosts []SSHHost 61 | var currentHost *SSHHost 62 | 63 | scanner := bufio.NewScanner(file) 64 | for scanner.Scan() { 65 | line := strings.TrimSpace(scanner.Text()) 66 | 67 | if line == "" || strings.HasPrefix(line, "#") { 68 | continue 69 | } 70 | 71 | parts := strings.Fields(line) 72 | if len(parts) < 2 { 73 | continue 74 | } 75 | 76 | key := strings.ToLower(parts[0]) 77 | value := strings.Join(parts[1:], " ") 78 | 79 | if key == "include" { 80 | includePath := expandPath(value) 81 | 82 | if !filepath.IsAbs(includePath) { 83 | configDir := filepath.Dir(configPath) 84 | includePath = filepath.Join(configDir, includePath) 85 | } 86 | 87 | matches, err := filepath.Glob(includePath) 88 | if err != nil { 89 | continue 90 | } 91 | 92 | for _, match := range matches { 93 | includedHosts, err := parseSSHConfigRecursive(match, visited) 94 | if err != nil { 95 | continue 96 | } 97 | hosts = append(hosts, includedHosts...) 98 | } 99 | continue 100 | } 101 | 102 | if key == "host" { 103 | if currentHost != nil && !strings.Contains(currentHost.Name, "*") && !strings.Contains(currentHost.Name, "?") { 104 | hosts = append(hosts, *currentHost) 105 | } 106 | 107 | currentHost = &SSHHost{ 108 | Name: value, 109 | Port: "22", 110 | } 111 | } else if currentHost != nil { 112 | switch key { 113 | case "hostname": 114 | currentHost.Hostname = value 115 | case "user": 116 | currentHost.User = value 117 | case "port": 118 | currentHost.Port = value 119 | case "identityfile": 120 | currentHost.IdentityFile = expandPath(value) 121 | } 122 | } 123 | } 124 | 125 | if currentHost != nil && !strings.Contains(currentHost.Name, "*") && !strings.Contains(currentHost.Name, "?") { 126 | hosts = append(hosts, *currentHost) 127 | } 128 | 129 | if err := scanner.Err(); err != nil { 130 | return nil, err 131 | } 132 | 133 | return hosts, nil 134 | } 135 | 136 | func expandPath(path string) string { 137 | if path == "" { 138 | return "" 139 | } 140 | 141 | path = filepath.Clean(path) 142 | 143 | if strings.Contains(path, "..") { 144 | return "" 145 | } 146 | 147 | if strings.HasPrefix(path, "~/") { 148 | home, err := os.UserHomeDir() 149 | if err != nil { 150 | return "" 151 | } 152 | expandedPath := filepath.Join(home, path[2:]) 153 | 154 | absHome, err := filepath.Abs(home) 155 | if err != nil { 156 | return "" 157 | } 158 | absPath, err := filepath.Abs(expandedPath) 159 | if err != nil { 160 | return "" 161 | } 162 | 163 | if !strings.HasPrefix(absPath, absHome) { 164 | return "" 165 | } 166 | 167 | return expandedPath 168 | } 169 | 170 | if filepath.IsAbs(path) { 171 | home, _ := os.UserHomeDir() 172 | sshDir := filepath.Join(home, ".ssh") 173 | 174 | absPath, err := filepath.Abs(path) 175 | if err != nil { 176 | return "" 177 | } 178 | 179 | if strings.HasPrefix(absPath, sshDir) || strings.HasPrefix(absPath, "/etc/ssh") { 180 | return path 181 | } 182 | 183 | return "" 184 | } 185 | 186 | home, err := os.UserHomeDir() 187 | if err != nil { 188 | return "" 189 | } 190 | return filepath.Join(home, ".ssh", path) 191 | } 192 | 193 | func getHostKeyCallback() (ssh.HostKeyCallback, error) { 194 | home, err := os.UserHomeDir() 195 | if err != nil { 196 | return nil, fmt.Errorf("unable to get user home directory: %w", err) 197 | } 198 | 199 | knownHostsPath := filepath.Join(home, ".ssh", "known_hosts") 200 | 201 | if _, err := os.Stat(knownHostsPath); os.IsNotExist(err) { 202 | if err := os.MkdirAll(filepath.Dir(knownHostsPath), 0700); err != nil { 203 | return nil, fmt.Errorf("unable to create .ssh directory: %w", err) 204 | } 205 | if _, err := os.Create(knownHostsPath); err != nil { 206 | return nil, fmt.Errorf("unable to create known_hosts file: %w", err) 207 | } 208 | } 209 | 210 | hostKeyCallback, err := knownhosts.New(knownHostsPath) 211 | if err != nil { 212 | return nil, fmt.Errorf("unable to load known_hosts: %w", err) 213 | } 214 | 215 | return ssh.HostKeyCallback(func(hostname string, remote net.Addr, key ssh.PublicKey) error { 216 | err := hostKeyCallback(hostname, remote, key) 217 | if err != nil { 218 | if keyErr, ok := err.(*knownhosts.KeyError); ok && len(keyErr.Want) > 0 { 219 | return fmt.Errorf("host key verification failed: host key has changed for %s. Remove the old key from %s if you trust this connection", hostname, knownHostsPath) 220 | } else if keyErr, ok := err.(*knownhosts.KeyError); ok && len(keyErr.Want) == 0 { 221 | return fmt.Errorf("host key verification failed: %s is not in known_hosts. Add the host key to %s or run 'ssh %s' first to accept the host key", hostname, knownHostsPath, hostname) 222 | } 223 | return fmt.Errorf("host key verification failed: %w", err) 224 | } 225 | return nil 226 | }), nil 227 | } 228 | 229 | func getValidatedUsername() string { 230 | user := os.Getenv("USER") 231 | if user == "" { 232 | return "" 233 | } 234 | 235 | if len(user) > 32 { 236 | return "" 237 | } 238 | 239 | for _, char := range user { 240 | if !((char >= 'a' && char <= 'z') || 241 | (char >= 'A' && char <= 'Z') || 242 | (char >= '0' && char <= '9') || 243 | char == '_' || char == '-' || char == '.') { 244 | return "" 245 | } 246 | } 247 | 248 | return user 249 | } 250 | 251 | func getValidatedSSHAuthSock() string { 252 | socket := os.Getenv("SSH_AUTH_SOCK") 253 | if socket == "" { 254 | return "" 255 | } 256 | 257 | if !filepath.IsAbs(socket) { 258 | return "" 259 | } 260 | 261 | cleanSocket := filepath.Clean(socket) 262 | if strings.Contains(cleanSocket, "..") { 263 | return "" 264 | } 265 | 266 | validPrefixes := []string{ 267 | "/tmp/", 268 | "/var/run/", 269 | "/run/", 270 | } 271 | 272 | if tmpDir := os.Getenv("TMPDIR"); tmpDir != "" { 273 | validPrefixes = append(validPrefixes, tmpDir) 274 | } 275 | 276 | for _, prefix := range validPrefixes { 277 | if strings.HasPrefix(cleanSocket, prefix) { 278 | return socket 279 | } 280 | } 281 | 282 | if info, err := os.Stat(socket); err == nil { 283 | if info.Mode()&os.ModeSocket != 0 { 284 | return socket 285 | } 286 | } 287 | 288 | return "" 289 | } 290 | 291 | func NewSSHClient(host SSHHost) (*SSHClient, error) { 292 | if host.Hostname == "" { 293 | host.Hostname = host.Name 294 | } 295 | if host.User == "" { 296 | host.User = getValidatedUsername() 297 | } 298 | if host.Port == "" { 299 | host.Port = "22" 300 | } 301 | 302 | var authMethods []ssh.AuthMethod 303 | 304 | // First, try the specific identity file from SSH config (highest priority) 305 | if host.IdentityFile != "" { 306 | if keyAuth, err := publicKeyAuth(host.IdentityFile); err == nil { 307 | authMethods = append(authMethods, keyAuth) 308 | } 309 | } 310 | 311 | // Second, try SSH agent (if available) 312 | if agentAuth, agentErr := sshAgentAuth(); agentErr == nil { 313 | authMethods = append(authMethods, agentAuth) 314 | } 315 | 316 | // Finally, try default key files as fallback 317 | home, err := os.UserHomeDir() 318 | if err == nil { 319 | defaultKeys := []string{ 320 | filepath.Join(home, ".ssh", "id_rsa"), 321 | filepath.Join(home, ".ssh", "id_ed25519"), 322 | filepath.Join(home, ".ssh", "id_ecdsa"), 323 | } 324 | for _, keyPath := range defaultKeys { 325 | // Skip if this is the same as the identity file we already tried 326 | if keyPath != host.IdentityFile { 327 | if keyAuth, err := publicKeyAuth(keyPath); err == nil { 328 | authMethods = append(authMethods, keyAuth) 329 | } 330 | } 331 | } 332 | } 333 | 334 | if len(authMethods) == 0 { 335 | return nil, fmt.Errorf("no authentication methods available") 336 | } 337 | 338 | hostKeyCallback, err := getHostKeyCallback() 339 | if err != nil { 340 | return nil, fmt.Errorf("failed to setup host key verification: %w", err) 341 | } 342 | 343 | config := &ssh.ClientConfig{ 344 | User: host.User, 345 | Auth: authMethods, 346 | HostKeyCallback: hostKeyCallback, 347 | Timeout: 10 * time.Second, 348 | } 349 | 350 | addr := fmt.Sprintf("%s:%s", host.Hostname, host.Port) 351 | client, err := ssh.Dial("tcp", addr, config) 352 | if err != nil { 353 | return nil, fmt.Errorf("failed to connect to %s: %w", addr, err) 354 | } 355 | 356 | return &SSHClient{ 357 | client: client, 358 | config: &host, 359 | }, nil 360 | } 361 | 362 | func publicKeyAuth(keyPath string) (ssh.AuthMethod, error) { 363 | key, err := os.ReadFile(keyPath) 364 | if err != nil { 365 | return nil, err 366 | } 367 | 368 | signer, err := ssh.ParsePrivateKey(key) 369 | if err != nil { 370 | // if it's an encrypted key error, we can't handle it without a passphrase 371 | // just return the error - SSH agent should handle these keys 372 | return nil, err 373 | } 374 | 375 | return ssh.PublicKeys(signer), nil 376 | } 377 | 378 | func sshAgentAuth() (ssh.AuthMethod, error) { 379 | socket := getValidatedSSHAuthSock() 380 | if socket == "" { 381 | return nil, fmt.Errorf("SSH_AUTH_SOCK not set or invalid") 382 | } 383 | 384 | conn, err := net.Dial("unix", socket) 385 | if err != nil { 386 | return nil, fmt.Errorf("failed to connect to SSH agent: %w", err) 387 | } 388 | 389 | agentClient := agent.NewClient(conn) 390 | 391 | return ssh.PublicKeysCallback(agentClient.Signers), nil 392 | } 393 | 394 | func isAllowedCommand(cmd string) bool { 395 | allowedPrefixes := []string{ 396 | "lscpu ", 397 | "top -", 398 | "which ", 399 | "nvidia-smi ", 400 | "amd-smi ", 401 | "rocm-smi ", 402 | "free -", 403 | "df -", 404 | } 405 | 406 | cmd = strings.TrimSpace(cmd) 407 | for _, prefix := range allowedPrefixes { 408 | if strings.HasPrefix(cmd, prefix) { 409 | return true 410 | } 411 | } 412 | 413 | return false 414 | } 415 | 416 | func (c *SSHClient) ExecuteCommand(cmd string) (string, error) { 417 | if !isAllowedCommand(cmd) { 418 | return "", fmt.Errorf("command not in allowed list: %s", cmd) 419 | } 420 | 421 | session, err := c.client.NewSession() 422 | if err != nil { 423 | return "", err 424 | } 425 | defer session.Close() 426 | 427 | output, err := session.CombinedOutput(cmd) 428 | if err != nil { 429 | return string(output), err 430 | } 431 | 432 | return string(output), nil 433 | } 434 | 435 | func (c *SSHClient) Close() error { 436 | if c.client != nil { 437 | return c.client.Close() 438 | } 439 | return nil 440 | } 441 | -------------------------------------------------------------------------------- /internal/ui/render.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | "time" 8 | 9 | "github.com/alpindale/ssh-dashboard/internal" 10 | "github.com/charmbracelet/lipgloss" 11 | ) 12 | 13 | func renderProgressBar(percent float64, width int, color lipgloss.Color) string { 14 | if percent > 100 { 15 | percent = 100 16 | } 17 | if percent < 0 { 18 | percent = 0 19 | } 20 | 21 | filled := int(float64(width) * percent / 100.0) 22 | empty := width - filled 23 | 24 | filledStyle := lipgloss.NewStyle().Foreground(color) 25 | emptyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240")) 26 | 27 | bar := filledStyle.Render(strings.Repeat("█", filled)) + 28 | emptyStyle.Render(strings.Repeat("░", empty)) 29 | 30 | return bar 31 | } 32 | 33 | func (m Model) renderConnectingProgress() string { 34 | var b strings.Builder 35 | 36 | title := " System Dashboard - Connecting " 37 | connectedCount := len(m.clients) 38 | totalCount := len(m.selectedHosts) 39 | subtitle := fmt.Sprintf("v%s | Connecting to %d host(s)... (%d/%d ready)", internal.ShortVersion(), totalCount, connectedCount, totalCount) 40 | 41 | b.WriteString(titleStyle.Render(title)) 42 | b.WriteString("\n") 43 | b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(subtitle)) 44 | b.WriteString("\n\n") 45 | 46 | maxNameLen := 0 47 | for _, host := range m.selectedHosts { 48 | if len(host.Name) > maxNameLen { 49 | maxNameLen = len(host.Name) 50 | } 51 | } 52 | 53 | for _, host := range m.selectedHosts { 54 | client := m.clients[host.Name] 55 | sysInfo := m.sysInfos[host.Name] 56 | 57 | statusIcon := m.spinner.View() 58 | statusText := "Connecting..." 59 | statusColor := lipgloss.Color("240") 60 | 61 | if client != nil { 62 | if sysInfo != nil { 63 | statusIcon = "✓" 64 | statusText = "Ready" 65 | statusColor = lipgloss.Color("10") 66 | } else { 67 | statusIcon = m.spinner.View() 68 | statusText = "Gathering information..." 69 | statusColor = lipgloss.Color("11") 70 | } 71 | } 72 | 73 | paddedName := host.Name + strings.Repeat(" ", maxNameLen-len(host.Name)) 74 | hostName := headerStyle.Render("● " + paddedName) 75 | status := lipgloss.NewStyle().Foreground(statusColor).Render(fmt.Sprintf("%s %s", statusIcon, statusText)) 76 | 77 | b.WriteString(fmt.Sprintf(" %s %s\n", hostName, status)) 78 | } 79 | 80 | b.WriteString("\n") 81 | b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Please wait...")) 82 | 83 | return b.String() 84 | } 85 | 86 | func (m Model) renderOverview() string { 87 | var b strings.Builder 88 | 89 | title := fmt.Sprintf(" Overview - All Hosts (%d) ", len(m.selectedHosts)) 90 | subtitle := fmt.Sprintf("v%s | Last Updated: %s | Interval: %s | 't' per-host | 'c' add hosts | 'q' quit", 91 | internal.ShortVersion(), time.Now().Format("15:04:05"), formatInterval(m.updateInterval)) 92 | 93 | b.WriteString(titleStyle.Render(title)) 94 | b.WriteString("\n") 95 | b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(subtitle)) 96 | b.WriteString("\n\n") 97 | 98 | for i := 0; i < len(m.selectedHosts); i += 2 { 99 | var leftHost, rightHost string 100 | 101 | leftHost = m.renderSingleHostOverview(m.selectedHosts[i]) 102 | 103 | if i+1 < len(m.selectedHosts) { 104 | rightHost = m.renderSingleHostOverview(m.selectedHosts[i+1]) 105 | row := lipgloss.JoinHorizontal(lipgloss.Top, leftHost, " ", rightHost) 106 | b.WriteString(row) 107 | } else { 108 | b.WriteString(leftHost) 109 | } 110 | b.WriteString("\n") 111 | } 112 | 113 | return b.String() 114 | } 115 | 116 | func (m Model) renderSingleHostOverview(host internal.SSHHost) string { 117 | var b strings.Builder 118 | 119 | sysInfo := m.sysInfos[host.Name] 120 | 121 | if sysInfo == nil { 122 | b.WriteString(headerStyle.Render(fmt.Sprintf("● %s", host.Name))) 123 | b.WriteString(" ") 124 | b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Loading...")) 125 | b.WriteString("\n") 126 | return b.String() 127 | } 128 | 129 | b.WriteString(headerStyle.Render(fmt.Sprintf("● %s", host.Name))) 130 | b.WriteString("\n") 131 | 132 | cpuUsage := sysInfo.CPU.Usage 133 | if cpuUsage == "" { 134 | cpuUsage = "N/A" 135 | } 136 | b.WriteString(fmt.Sprintf(" CPU: %s", cpuUsage)) 137 | b.WriteString("\n") 138 | 139 | if sysInfo.RAM.Total > 0 { 140 | b.WriteString(fmt.Sprintf(" RAM: %.1f / %.1f GB (%.0f%%)", 141 | float64(sysInfo.RAM.Used)/1024, 142 | float64(sysInfo.RAM.Total)/1024, 143 | sysInfo.RAM.UsagePercent)) 144 | } else { 145 | b.WriteString(" RAM: N/A") 146 | } 147 | b.WriteString("\n") 148 | 149 | if len(sysInfo.Disk) > 0 { 150 | disk := sysInfo.Disk[0] 151 | b.WriteString(fmt.Sprintf(" Disk: %s / %s (%s)", disk.Used, disk.Size, disk.UsagePercent)) 152 | } else { 153 | b.WriteString(" Disk: N/A") 154 | } 155 | b.WriteString("\n") 156 | 157 | if len(sysInfo.GPUs) > 0 { 158 | var totalVRAM, usedVRAM int 159 | var totalUtil int 160 | for _, gpu := range sysInfo.GPUs { 161 | totalVRAM += gpu.VRAMTotal 162 | usedVRAM += gpu.VRAMUsed 163 | totalUtil += gpu.Utilization 164 | } 165 | 166 | vramPercent := 0.0 167 | if totalVRAM > 0 { 168 | vramPercent = (float64(usedVRAM) / float64(totalVRAM)) * 100 169 | } 170 | 171 | avgUtil := 0 172 | if len(sysInfo.GPUs) > 0 { 173 | avgUtil = totalUtil / len(sysInfo.GPUs) 174 | } 175 | 176 | barWidth := 50 177 | vramBar := renderProgressBar(vramPercent, barWidth, lipgloss.Color("33")) 178 | utilBar := renderProgressBar(float64(avgUtil), barWidth, lipgloss.Color("208")) 179 | 180 | b.WriteString(fmt.Sprintf(" GPU VRAM: %.1f / %.1f GB (%.0f%%)\n", float64(usedVRAM)/1024, float64(totalVRAM)/1024, vramPercent)) 181 | b.WriteString(" " + vramBar + "\n") 182 | b.WriteString(fmt.Sprintf(" GPU Util: %d%% avg\n", avgUtil)) 183 | b.WriteString(" " + utilBar + "\n") 184 | } else { 185 | paddingLine := strings.Repeat(" ", 52) 186 | b.WriteString(" GPU: N/A\n") 187 | b.WriteString(paddingLine + "\n") 188 | b.WriteString(paddingLine + "\n") 189 | b.WriteString(paddingLine + "\n") 190 | } 191 | 192 | return b.String() 193 | } 194 | 195 | func renderDashboard(hostName string, info *internal.SystemInfo, updateInterval time.Duration, lastUpdate time.Time, width, height int, multiHost bool) string { 196 | var b strings.Builder 197 | 198 | title := fmt.Sprintf(" System Dashboard - %s ", hostName) 199 | navHint := "" 200 | if multiHost { 201 | navHint = " | 'n' next | 't' overview" 202 | } 203 | subtitle := fmt.Sprintf("v%s | Last Updated: %s | Interval: %s%s | 's' shell | 'c' add hosts | 'q' quit", 204 | internal.ShortVersion(), lastUpdate.Format("15:04:05"), formatInterval(updateInterval), navHint) 205 | 206 | b.WriteString(titleStyle.Render(title)) 207 | b.WriteString("\n") 208 | b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(subtitle)) 209 | b.WriteString("\n\n") 210 | 211 | b.WriteString(renderCPUSection(info.CPU)) 212 | b.WriteString("\n") 213 | 214 | b.WriteString(renderRAMAndDiskSection(info.RAM, info.Disk)) 215 | 216 | if len(info.GPUs) > 1 { 217 | b.WriteString("\n") 218 | b.WriteString(renderAggregateGPUSection(info.GPUs)) 219 | } 220 | 221 | if len(info.GPUs) > 0 { 222 | b.WriteString("\n") 223 | b.WriteString(renderGPUSection(info.GPUs)) 224 | } 225 | 226 | return b.String() 227 | } 228 | 229 | func renderCPUSection(cpu internal.CPUInfo) string { 230 | var parts []string 231 | 232 | if cpu.Model != "" { 233 | parts = append(parts, cpu.Model) 234 | } 235 | if cpu.Count != "" { 236 | parts = append(parts, fmt.Sprintf("%s cores", cpu.Count)) 237 | } 238 | parts = append(parts, fmt.Sprintf("Usage: %s", cpu.Usage)) 239 | 240 | cpuInfo := strings.Join(parts, " | ") 241 | return headerStyle.Render("● CPU") + " " + cpuInfo + "\n" 242 | } 243 | 244 | func renderAggregateGPUSection(gpus []internal.GPUInfo) string { 245 | var b strings.Builder 246 | 247 | var totalVRAM, usedVRAM int 248 | var totalUtil int 249 | for _, gpu := range gpus { 250 | totalVRAM += gpu.VRAMTotal 251 | usedVRAM += gpu.VRAMUsed 252 | totalUtil += gpu.Utilization 253 | } 254 | 255 | vramPercent := 0.0 256 | if totalVRAM > 0 { 257 | vramPercent = (float64(usedVRAM) / float64(totalVRAM)) * 100 258 | } 259 | avgUtil := 0.0 260 | if len(gpus) > 0 { 261 | avgUtil = float64(totalUtil) / float64(len(gpus)) 262 | } 263 | 264 | totalVRAMGB := float64(totalVRAM) / 1024.0 265 | usedVRAMGB := float64(usedVRAM) / 1024.0 266 | 267 | b.WriteString(headerStyle.Render("● Total GPU Pressure")) 268 | b.WriteString("\n\n") 269 | 270 | const fullBarWidth = 106 271 | 272 | vramLabel := lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Render("VRAM") 273 | b.WriteString(fmt.Sprintf(" %s %.1f/%.1f GB (%.1f%%) across %d GPUs\n", vramLabel, usedVRAMGB, totalVRAMGB, vramPercent, len(gpus))) 274 | b.WriteString(" ") 275 | b.WriteString(renderProgressBar(vramPercent, fullBarWidth, lipgloss.Color("39"))) 276 | b.WriteString("\n") 277 | 278 | utilLabel := lipgloss.NewStyle().Foreground(lipgloss.Color("208")).Render("Util") 279 | b.WriteString(fmt.Sprintf(" %s %.1f%% average\n", utilLabel, avgUtil)) 280 | b.WriteString(" ") 281 | b.WriteString(renderProgressBar(avgUtil, fullBarWidth, lipgloss.Color("208"))) 282 | b.WriteString("\n") 283 | 284 | return b.String() 285 | } 286 | 287 | func renderGPUSection(gpus []internal.GPUInfo) string { 288 | var b strings.Builder 289 | 290 | b.WriteString(headerStyle.Render("● GPU Information")) 291 | b.WriteString("\n\n") 292 | 293 | for i := 0; i < len(gpus); i += 2 { 294 | var leftGPU, rightGPU string 295 | 296 | leftGPU = renderSingleGPU(gpus[i]) 297 | 298 | if i+1 < len(gpus) { 299 | rightGPU = renderSingleGPU(gpus[i+1]) 300 | row := lipgloss.JoinHorizontal(lipgloss.Top, leftGPU, " ", rightGPU) 301 | b.WriteString(row) 302 | } else { 303 | b.WriteString(leftGPU) 304 | } 305 | b.WriteString("\n") 306 | } 307 | 308 | return b.String() 309 | } 310 | 311 | func renderSingleGPU(gpu internal.GPUInfo) string { 312 | var b strings.Builder 313 | 314 | vramTotalGB := float64(gpu.VRAMTotal) / 1024.0 315 | vramUsedGB := float64(gpu.VRAMUsed) / 1024.0 316 | vramPercent := 0.0 317 | if gpu.VRAMTotal > 0 { 318 | vramPercent = (float64(gpu.VRAMUsed) / float64(gpu.VRAMTotal)) * 100 319 | } 320 | 321 | gpuIndex := lipgloss.NewStyle(). 322 | Bold(true). 323 | Foreground(lipgloss.Color("12")). 324 | Render(gpu.Index) 325 | 326 | gpuName := lipgloss.NewStyle(). 327 | Foreground(lipgloss.Color("240")). 328 | Render(gpu.Name) 329 | 330 | powerPercent := 0.0 331 | if gpu.PowerLimit > 0 { 332 | powerPercent = (float64(gpu.PowerDraw) / float64(gpu.PowerLimit)) * 100 333 | } 334 | var powerColor lipgloss.Color 335 | if powerPercent < 70 { 336 | powerColor = lipgloss.Color("10") // Green 337 | } else if powerPercent < 90 { 338 | powerColor = lipgloss.Color("11") // Yellow 339 | } else { 340 | powerColor = lipgloss.Color("196") // Red 341 | } 342 | powerText := lipgloss.NewStyle(). 343 | Foreground(powerColor). 344 | Render(fmt.Sprintf("%dW", gpu.PowerDraw)) 345 | 346 | var tempColor lipgloss.Color 347 | if gpu.Temperature < 70 { 348 | tempColor = lipgloss.Color("10") // Green 349 | } else if gpu.Temperature < 80 { 350 | tempColor = lipgloss.Color("11") // Yellow 351 | } else if gpu.Temperature < 85 { 352 | tempColor = lipgloss.Color("208") // Orange 353 | } else { 354 | tempColor = lipgloss.Color("196") // Red 355 | } 356 | tempText := lipgloss.NewStyle(). 357 | Foreground(tempColor). 358 | Render(fmt.Sprintf("%d°C", gpu.Temperature)) 359 | 360 | const barWidth = 50 361 | 362 | b.WriteString(fmt.Sprintf(" %s %s %s %s\n", gpuIndex, gpuName, powerText, tempText)) 363 | 364 | vramLabel := lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Render("VRAM") 365 | b.WriteString(fmt.Sprintf(" %s %.1f/%.1f GB (%.1f%%)\n", vramLabel, vramUsedGB, vramTotalGB, vramPercent)) 366 | b.WriteString(" ") 367 | b.WriteString(renderProgressBar(vramPercent, barWidth, lipgloss.Color("39"))) 368 | b.WriteString("\n") 369 | 370 | utilLabel := lipgloss.NewStyle().Foreground(lipgloss.Color("208")).Render("Util") 371 | b.WriteString(fmt.Sprintf(" %s %d%%\n", utilLabel, gpu.Utilization)) 372 | b.WriteString(" ") 373 | b.WriteString(renderProgressBar(float64(gpu.Utilization), barWidth, lipgloss.Color("208"))) 374 | b.WriteString("\n") 375 | 376 | return b.String() 377 | } 378 | 379 | func renderRAMAndDiskSection(ram internal.RAMInfo, disks []internal.DiskInfo) string { 380 | ramSection := renderRAMSection(ram) 381 | diskSection := renderDiskSection(disks) 382 | 383 | return lipgloss.JoinHorizontal(lipgloss.Top, diskSection, " ", ramSection) + "\n" 384 | } 385 | 386 | func renderRAMSection(ram internal.RAMInfo) string { 387 | var b strings.Builder 388 | 389 | b.WriteString(headerStyle.Render("● RAM Information")) 390 | b.WriteString("\n") 391 | 392 | totalGB := float64(ram.Total) / 1024.0 393 | usedGB := float64(ram.Used) / 1024.0 394 | 395 | b.WriteString(fmt.Sprintf(" %.1f GB / %.1f GB (%.1f%%)\n", usedGB, totalGB, ram.UsagePercent)) 396 | b.WriteString(" ") 397 | b.WriteString(renderProgressBar(ram.UsagePercent, 50, lipgloss.Color("10"))) 398 | b.WriteString("\n") 399 | 400 | return b.String() 401 | } 402 | 403 | func renderDiskSection(disks []internal.DiskInfo) string { 404 | var b strings.Builder 405 | 406 | b.WriteString(headerStyle.Render("● Disk Usage")) 407 | b.WriteString("\n") 408 | 409 | for _, disk := range disks { 410 | usageStr := strings.TrimSuffix(disk.UsagePercent, "%") 411 | usagePercent := 0.0 412 | if val, err := strconv.ParseFloat(usageStr, 64); err == nil { 413 | usagePercent = val 414 | } 415 | 416 | b.WriteString(fmt.Sprintf(" %s %s %s / %s (%s)\n", 417 | disk.Device, disk.MountPoint, disk.Used, disk.Size, disk.UsagePercent)) 418 | 419 | b.WriteString(" ") 420 | 421 | var barColor lipgloss.Color 422 | if usagePercent >= 90 { 423 | barColor = lipgloss.Color("196") // Red 424 | } else if usagePercent >= 75 { 425 | barColor = lipgloss.Color("208") // Orange 426 | } else { 427 | barColor = lipgloss.Color("10") // Green 428 | } 429 | 430 | b.WriteString(renderProgressBar(usagePercent, 50, barColor)) 431 | b.WriteString("\n\n") 432 | } 433 | 434 | return b.String() 435 | } 436 | --------------------------------------------------------------------------------