mirror of
https://github.com/tw93/Mole.git
synced 2026-02-09 02:04:17 +00:00
Add System Status Dashboard
This commit is contained in:
140
cmd/status/main.go
Normal file
140
cmd/status/main.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
const refreshInterval = time.Second
|
||||
|
||||
var (
|
||||
Version = "dev"
|
||||
BuildTime = ""
|
||||
)
|
||||
|
||||
type tickMsg struct{}
|
||||
type animTickMsg struct{}
|
||||
|
||||
type metricsMsg struct {
|
||||
data MetricsSnapshot
|
||||
err error
|
||||
}
|
||||
|
||||
type model struct {
|
||||
collector *Collector
|
||||
width int
|
||||
height int
|
||||
metrics MetricsSnapshot
|
||||
errMessage string
|
||||
ready bool
|
||||
lastUpdated time.Time
|
||||
collecting bool
|
||||
animFrame int
|
||||
}
|
||||
|
||||
func newModel() model {
|
||||
return model{
|
||||
collector: NewCollector(),
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
return tea.Batch(tickAfter(0), animTick())
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "q", "esc", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
return m, nil
|
||||
case tickMsg:
|
||||
if m.collecting {
|
||||
return m, nil
|
||||
}
|
||||
m.collecting = true
|
||||
return m, m.collectCmd()
|
||||
case metricsMsg:
|
||||
if msg.err != nil {
|
||||
m.errMessage = msg.err.Error()
|
||||
} else {
|
||||
m.errMessage = ""
|
||||
}
|
||||
m.metrics = msg.data
|
||||
m.lastUpdated = msg.data.CollectedAt
|
||||
m.collecting = false
|
||||
// Mark ready after first successful data collection
|
||||
if !m.ready {
|
||||
m.ready = true
|
||||
}
|
||||
return m, tickAfter(refreshInterval)
|
||||
case animTickMsg:
|
||||
m.animFrame++
|
||||
return m, animTickWithSpeed(m.metrics.CPU.Usage)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
if !m.ready {
|
||||
return "Loading..."
|
||||
}
|
||||
|
||||
header := renderHeader(m.metrics, m.errMessage, m.animFrame, m.width)
|
||||
cardWidth := 0
|
||||
if m.width > 80 {
|
||||
cardWidth = maxInt(24, m.width/2-4)
|
||||
}
|
||||
cards := buildCards(m.metrics, cardWidth)
|
||||
|
||||
if m.width <= 80 {
|
||||
var rendered []string
|
||||
for _, c := range cards {
|
||||
rendered = append(rendered, renderCard(c, cardWidth, 0))
|
||||
}
|
||||
return header + "\n" + lipgloss.JoinVertical(lipgloss.Left, rendered...)
|
||||
}
|
||||
|
||||
return header + "\n" + renderTwoColumns(cards, m.width)
|
||||
}
|
||||
|
||||
func (m model) collectCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
data, err := m.collector.Collect()
|
||||
return metricsMsg{data: data, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func tickAfter(delay time.Duration) tea.Cmd {
|
||||
return tea.Tick(delay, func(time.Time) tea.Msg { return tickMsg{} })
|
||||
}
|
||||
|
||||
func animTick() tea.Cmd {
|
||||
return tea.Tick(200*time.Millisecond, func(time.Time) tea.Msg { return animTickMsg{} })
|
||||
}
|
||||
|
||||
func animTickWithSpeed(cpuUsage float64) tea.Cmd {
|
||||
// Higher CPU = faster animation (50ms to 300ms)
|
||||
interval := 300 - int(cpuUsage*2.5)
|
||||
if interval < 50 {
|
||||
interval = 50
|
||||
}
|
||||
return tea.Tick(time.Duration(interval)*time.Millisecond, func(time.Time) tea.Msg { return animTickMsg{} })
|
||||
}
|
||||
|
||||
func main() {
|
||||
p := tea.NewProgram(newModel(), tea.WithAltScreen())
|
||||
if err := p.Start(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "system status error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
1244
cmd/status/metrics.go
Normal file
1244
cmd/status/metrics.go
Normal file
File diff suppressed because it is too large
Load Diff
594
cmd/status/view.go
Normal file
594
cmd/status/view.go
Normal file
@@ -0,0 +1,594 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
var (
|
||||
titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#5FD7FF")).Bold(true)
|
||||
subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#6C6C6C"))
|
||||
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F"))
|
||||
dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5F5F")).Bold(true)
|
||||
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#87D787"))
|
||||
lineStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4A4A4A"))
|
||||
)
|
||||
|
||||
const (
|
||||
colWidth = 38
|
||||
iconCPU = "⚙"
|
||||
iconMemory = "▦"
|
||||
iconGPU = "▣"
|
||||
iconDisk = "▤"
|
||||
iconNetwork = "⇅"
|
||||
iconBattery = "▮"
|
||||
iconSensors = "♨"
|
||||
iconProcs = "▶"
|
||||
)
|
||||
|
||||
// Mole body frames (legs animate)
|
||||
var moleBody = [][]string{
|
||||
{
|
||||
` /\_/\`,
|
||||
` ___/ o o \`,
|
||||
`/___ =-= /`,
|
||||
`\____)-m-m)`,
|
||||
},
|
||||
{
|
||||
` /\_/\`,
|
||||
` ___/ o o \`,
|
||||
`/___ =-= /`,
|
||||
`\____)mm__)`,
|
||||
},
|
||||
{
|
||||
` /\_/\`,
|
||||
` ___/ · · \`,
|
||||
`/___ =-= /`,
|
||||
`\___)-m__m)`,
|
||||
},
|
||||
{
|
||||
` /\_/\`,
|
||||
` ___/ o o \`,
|
||||
`/___ =-= /`,
|
||||
`\____)-mm-)`,
|
||||
},
|
||||
}
|
||||
|
||||
// Generate frames with horizontal movement
|
||||
func getMoleFrame(animFrame int, termWidth int) string {
|
||||
bodyIdx := animFrame % len(moleBody)
|
||||
body := moleBody[bodyIdx]
|
||||
|
||||
// Calculate mole width (approximate)
|
||||
moleWidth := 15
|
||||
// Move across terminal width
|
||||
maxPos := termWidth - moleWidth
|
||||
if maxPos < 0 {
|
||||
maxPos = 0
|
||||
}
|
||||
|
||||
// Move position: 0 -> maxPos -> 0
|
||||
cycleLength := maxPos * 2
|
||||
if cycleLength == 0 {
|
||||
cycleLength = 1
|
||||
}
|
||||
pos := animFrame % cycleLength
|
||||
if pos > maxPos {
|
||||
pos = cycleLength - pos
|
||||
}
|
||||
|
||||
padding := strings.Repeat(" ", pos)
|
||||
var lines []string
|
||||
for _, line := range body {
|
||||
lines = append(lines, padding+line)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
type cardData struct {
|
||||
icon string
|
||||
title string
|
||||
lines []string
|
||||
}
|
||||
|
||||
func renderHeader(m MetricsSnapshot, errMsg string, animFrame int, termWidth int) string {
|
||||
// Title
|
||||
title := titleStyle.Render("Mole Status")
|
||||
|
||||
// Health Score with color and label
|
||||
scoreStyle := getScoreStyle(m.HealthScore)
|
||||
scoreText := subtleStyle.Render("Health ") + scoreStyle.Render(fmt.Sprintf("● %d", m.HealthScore))
|
||||
|
||||
// Hardware info
|
||||
infoParts := []string{}
|
||||
if m.Hardware.Model != "" {
|
||||
infoParts = append(infoParts, m.Hardware.Model)
|
||||
}
|
||||
if m.Hardware.CPUModel != "" {
|
||||
infoParts = append(infoParts, m.Hardware.CPUModel)
|
||||
}
|
||||
if m.Hardware.TotalRAM != "" {
|
||||
infoParts = append(infoParts, m.Hardware.TotalRAM)
|
||||
}
|
||||
if m.Hardware.DiskSize != "" {
|
||||
infoParts = append(infoParts, m.Hardware.DiskSize)
|
||||
}
|
||||
if m.Hardware.OSVersion != "" {
|
||||
infoParts = append(infoParts, m.Hardware.OSVersion)
|
||||
}
|
||||
|
||||
headerLine := title + " " + scoreText + " " + subtleStyle.Render(strings.Join(infoParts, " · "))
|
||||
|
||||
// Running mole animation
|
||||
mole := getMoleFrame(animFrame, termWidth)
|
||||
|
||||
if errMsg != "" {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, headerLine, "", mole, dangerStyle.Render(errMsg), "")
|
||||
}
|
||||
return headerLine + "\n\n" + mole
|
||||
}
|
||||
|
||||
func getScoreStyle(score int) lipgloss.Style {
|
||||
if score >= 90 {
|
||||
// Excellent - Green
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#87D787")).Bold(true)
|
||||
} else if score >= 75 {
|
||||
// Good - Light Green
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#AFD787")).Bold(true)
|
||||
} else if score >= 60 {
|
||||
// Fair - Yellow
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD75F")).Bold(true)
|
||||
} else if score >= 40 {
|
||||
// Poor - Orange
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#FFAF5F")).Bold(true)
|
||||
} else {
|
||||
// Critical - Red
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5F5F")).Bold(true)
|
||||
}
|
||||
}
|
||||
|
||||
func buildCards(m MetricsSnapshot, _ int) []cardData {
|
||||
// Row 1: CPU + Memory
|
||||
// Row 2: Disk + Power
|
||||
// Row 3: Top Processes + Network
|
||||
cards := []cardData{
|
||||
renderCPUCard(m.CPU),
|
||||
renderMemoryCard(m.Memory),
|
||||
renderDiskCard(m.Disks, m.DiskIO),
|
||||
renderBatteryCard(m.Batteries, m.Thermal),
|
||||
renderProcessCard(m.TopProcesses),
|
||||
renderNetworkCard(m.Network, m.Proxy),
|
||||
}
|
||||
// Only show GPU card if there are GPUs with usage data
|
||||
if len(m.GPU) > 0 && m.GPU[0].Usage >= 0 {
|
||||
cards = append(cards, renderGPUCard(m.GPU))
|
||||
}
|
||||
// Only show sensors if we have valid temperature readings
|
||||
if hasSensorData(m.Sensors) {
|
||||
cards = append(cards, renderSensorsCard(m.Sensors))
|
||||
}
|
||||
return cards
|
||||
}
|
||||
|
||||
func hasSensorData(sensors []SensorReading) bool {
|
||||
for _, s := range sensors {
|
||||
if s.Note == "" && s.Value > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func renderCPUCard(cpu CPUStatus) cardData {
|
||||
var lines []string
|
||||
lines = append(lines, fmt.Sprintf("Total %s %5.1f%%", progressBar(cpu.Usage), cpu.Usage))
|
||||
lines = append(lines, subtleStyle.Render(fmt.Sprintf("%.2f / %.2f / %.2f (%d cores)", cpu.Load1, cpu.Load5, cpu.Load15, cpu.LogicalCPU)))
|
||||
|
||||
// Show top 3 busiest cores
|
||||
type coreUsage struct {
|
||||
idx int
|
||||
val float64
|
||||
}
|
||||
var cores []coreUsage
|
||||
for i, v := range cpu.PerCore {
|
||||
cores = append(cores, coreUsage{i, v})
|
||||
}
|
||||
sort.Slice(cores, func(i, j int) bool { return cores[i].val > cores[j].val })
|
||||
|
||||
maxCores := 3
|
||||
if len(cores) < maxCores {
|
||||
maxCores = len(cores)
|
||||
}
|
||||
for i := 0; i < maxCores; i++ {
|
||||
c := cores[i]
|
||||
lines = append(lines, fmt.Sprintf("Core%-2d %s %5.1f%%", c.idx+1, progressBar(c.val), c.val))
|
||||
}
|
||||
|
||||
return cardData{icon: iconCPU, title: "CPU", lines: lines}
|
||||
}
|
||||
|
||||
func renderGPUCard(gpus []GPUStatus) cardData {
|
||||
var lines []string
|
||||
if len(gpus) == 0 {
|
||||
lines = append(lines, subtleStyle.Render("No GPU detected"))
|
||||
} else {
|
||||
for _, g := range gpus {
|
||||
name := shorten(g.Name, 12)
|
||||
if g.Usage >= 0 {
|
||||
lines = append(lines, fmt.Sprintf("%-12s %s %5.1f%%", name, progressBar(g.Usage), g.Usage))
|
||||
} else {
|
||||
lines = append(lines, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return cardData{icon: iconGPU, title: "GPU", lines: lines}
|
||||
}
|
||||
|
||||
func renderMemoryCard(mem MemoryStatus) cardData {
|
||||
var lines []string
|
||||
lines = append(lines, fmt.Sprintf("Used %s %5.1f%%", progressBar(mem.UsedPercent), mem.UsedPercent))
|
||||
lines = append(lines, subtleStyle.Render(fmt.Sprintf("%s / %s total", humanBytes(mem.Used), humanBytes(mem.Total))))
|
||||
lines = append(lines, "")
|
||||
// Show available memory
|
||||
available := mem.Total - mem.Used
|
||||
freePercent := 100 - mem.UsedPercent
|
||||
lines = append(lines, fmt.Sprintf("Free %s %5.1f%%", progressBar(freePercent), freePercent))
|
||||
lines = append(lines, subtleStyle.Render(fmt.Sprintf("%s available", humanBytes(available))))
|
||||
// Memory pressure
|
||||
if mem.Pressure != "" {
|
||||
pressureStyle := okStyle
|
||||
pressureText := "Status " + mem.Pressure
|
||||
if mem.Pressure == "warn" {
|
||||
pressureStyle = warnStyle
|
||||
} else if mem.Pressure == "critical" {
|
||||
pressureStyle = dangerStyle
|
||||
}
|
||||
lines = append(lines, pressureStyle.Render(pressureText))
|
||||
}
|
||||
return cardData{icon: iconMemory, title: "Memory", lines: lines}
|
||||
}
|
||||
|
||||
func renderDiskCard(disks []DiskStatus, io DiskIOStatus) cardData {
|
||||
var lines []string
|
||||
// Show main disk
|
||||
if len(disks) > 0 {
|
||||
d := disks[0]
|
||||
freeSpace := d.Total - d.Used
|
||||
bar := diskBar(d.UsedPercent)
|
||||
lines = append(lines, fmt.Sprintf("Used %s %4.0f%% (%s free)", bar, d.UsedPercent, humanBytes(freeSpace)))
|
||||
}
|
||||
// IO
|
||||
readBar := ioBar(io.ReadRate)
|
||||
writeBar := ioBar(io.WriteRate)
|
||||
lines = append(lines, fmt.Sprintf("Read %s %.1f MB/s", readBar, io.ReadRate))
|
||||
lines = append(lines, fmt.Sprintf("Write %s %.1f MB/s", writeBar, io.WriteRate))
|
||||
return cardData{icon: iconDisk, title: "Disk", lines: lines}
|
||||
}
|
||||
|
||||
func diskBar(percent float64) string {
|
||||
total := 16
|
||||
filled := int(percent / 100 * float64(total))
|
||||
if filled > total {
|
||||
filled = total
|
||||
}
|
||||
bar := strings.Repeat("█", filled) + strings.Repeat("░", total-filled)
|
||||
return colorizePercent(percent, bar)
|
||||
}
|
||||
|
||||
func ioBar(rate float64) string {
|
||||
// Scale: 0-50 MB/s maps to 0-5 blocks
|
||||
filled := int(rate / 10.0)
|
||||
if filled > 5 {
|
||||
filled = 5
|
||||
}
|
||||
if filled < 0 {
|
||||
filled = 0
|
||||
}
|
||||
bar := strings.Repeat("▮", filled) + strings.Repeat("▯", 5-filled)
|
||||
if rate > 80 {
|
||||
return dangerStyle.Render(bar)
|
||||
}
|
||||
if rate > 30 {
|
||||
return warnStyle.Render(bar)
|
||||
}
|
||||
return okStyle.Render(bar)
|
||||
}
|
||||
|
||||
func renderProcessCard(procs []ProcessInfo) cardData {
|
||||
var lines []string
|
||||
maxProcs := 3
|
||||
for i, p := range procs {
|
||||
if i >= maxProcs {
|
||||
break
|
||||
}
|
||||
name := shorten(p.Name, 12)
|
||||
cpuBar := miniBar(p.CPU)
|
||||
lines = append(lines, fmt.Sprintf("%-12s %s %5.1f%%", name, cpuBar, p.CPU))
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
lines = append(lines, subtleStyle.Render("No data"))
|
||||
}
|
||||
return cardData{icon: iconProcs, title: "Processes", lines: lines}
|
||||
}
|
||||
|
||||
func miniBar(percent float64) string {
|
||||
filled := int(percent / 20) // 5 chars max for 100%
|
||||
if filled > 5 {
|
||||
filled = 5
|
||||
}
|
||||
if filled < 0 {
|
||||
filled = 0
|
||||
}
|
||||
return colorizePercent(percent, strings.Repeat("▮", filled)+strings.Repeat("▯", 5-filled))
|
||||
}
|
||||
|
||||
func renderNetworkCard(netStats []NetworkStatus, proxy ProxyStatus) cardData {
|
||||
var lines []string
|
||||
var totalRx, totalTx float64
|
||||
var primaryIP string
|
||||
|
||||
for _, n := range netStats {
|
||||
totalRx += n.RxRateMBs
|
||||
totalTx += n.TxRateMBs
|
||||
if primaryIP == "" && n.IP != "" && n.Name == "en0" {
|
||||
primaryIP = n.IP
|
||||
}
|
||||
}
|
||||
|
||||
if len(netStats) == 0 {
|
||||
lines = []string{subtleStyle.Render("Collecting...")}
|
||||
} else {
|
||||
rxBar := netBar(totalRx)
|
||||
txBar := netBar(totalTx)
|
||||
lines = append(lines, fmt.Sprintf("Down %s %s", rxBar, formatRate(totalRx)))
|
||||
lines = append(lines, fmt.Sprintf("Up %s %s", txBar, formatRate(totalTx)))
|
||||
// Proxy + IP
|
||||
info := ""
|
||||
if proxy.Enabled {
|
||||
info = okStyle.Render("Proxy: " + proxy.Type)
|
||||
}
|
||||
if primaryIP != "" {
|
||||
if info != "" {
|
||||
info += " · "
|
||||
}
|
||||
info += primaryIP
|
||||
}
|
||||
if info != "" {
|
||||
lines = append(lines, subtleStyle.Render(info))
|
||||
}
|
||||
}
|
||||
return cardData{icon: iconNetwork, title: "Network", lines: lines}
|
||||
}
|
||||
|
||||
func netBar(rate float64) string {
|
||||
// Scale: 0-10 MB/s maps to 0-5 blocks
|
||||
filled := int(rate / 2.0)
|
||||
if filled > 5 {
|
||||
filled = 5
|
||||
}
|
||||
if filled < 0 {
|
||||
filled = 0
|
||||
}
|
||||
bar := strings.Repeat("▮", filled) + strings.Repeat("▯", 5-filled)
|
||||
if rate > 8 {
|
||||
return dangerStyle.Render(bar)
|
||||
}
|
||||
if rate > 3 {
|
||||
return warnStyle.Render(bar)
|
||||
}
|
||||
return okStyle.Render(bar)
|
||||
}
|
||||
|
||||
func renderBatteryCard(batts []BatteryStatus, thermal ThermalStatus) cardData {
|
||||
var lines []string
|
||||
if len(batts) == 0 {
|
||||
lines = append(lines, subtleStyle.Render("No battery"))
|
||||
} else {
|
||||
b := batts[0]
|
||||
// Line 1: label + percentage + bar
|
||||
lines = append(lines, fmt.Sprintf("Level %3.0f%% %s", b.Percent, progressBar(b.Percent)))
|
||||
|
||||
// Line 2: status
|
||||
statusIcon := ""
|
||||
statusStyle := subtleStyle
|
||||
statusLower := strings.ToLower(b.Status)
|
||||
if statusLower == "charging" || statusLower == "charged" {
|
||||
statusIcon = " ⚡"
|
||||
statusStyle = okStyle
|
||||
} else if b.Percent < 20 {
|
||||
statusStyle = dangerStyle
|
||||
}
|
||||
// Capitalize first letter
|
||||
statusText := b.Status
|
||||
if len(statusText) > 0 {
|
||||
statusText = strings.ToUpper(statusText[:1]) + strings.ToLower(statusText[1:])
|
||||
}
|
||||
if b.TimeLeft != "" {
|
||||
statusText += " · " + b.TimeLeft
|
||||
}
|
||||
lines = append(lines, statusStyle.Render(statusText+statusIcon))
|
||||
|
||||
// Line 3: Health + cycles
|
||||
healthParts := []string{}
|
||||
if b.Health != "" {
|
||||
healthParts = append(healthParts, b.Health)
|
||||
}
|
||||
if b.CycleCount > 0 {
|
||||
healthParts = append(healthParts, fmt.Sprintf("%d cycles", b.CycleCount))
|
||||
}
|
||||
if len(healthParts) > 0 {
|
||||
lines = append(lines, subtleStyle.Render(strings.Join(healthParts, " · ")))
|
||||
}
|
||||
|
||||
// Line 4: Temp + Fan combined
|
||||
var thermalParts []string
|
||||
if thermal.CPUTemp > 0 {
|
||||
tempStyle := okStyle
|
||||
if thermal.CPUTemp > 80 {
|
||||
tempStyle = dangerStyle
|
||||
} else if thermal.CPUTemp > 60 {
|
||||
tempStyle = warnStyle
|
||||
}
|
||||
thermalParts = append(thermalParts, tempStyle.Render(fmt.Sprintf("%.0f°C", thermal.CPUTemp)))
|
||||
}
|
||||
if thermal.FanSpeed > 0 {
|
||||
thermalParts = append(thermalParts, fmt.Sprintf("%d RPM", thermal.FanSpeed))
|
||||
}
|
||||
if len(thermalParts) > 0 {
|
||||
lines = append(lines, strings.Join(thermalParts, " · "))
|
||||
}
|
||||
}
|
||||
return cardData{icon: iconBattery, title: "Power", lines: lines}
|
||||
}
|
||||
|
||||
func renderSensorsCard(sensors []SensorReading) cardData {
|
||||
var lines []string
|
||||
for _, s := range sensors {
|
||||
if s.Note != "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%-12s %s", shorten(s.Label, 12), colorizeTemp(s.Value)+s.Unit))
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
lines = append(lines, subtleStyle.Render("No sensors"))
|
||||
}
|
||||
return cardData{icon: iconSensors, title: "Sensors", lines: lines}
|
||||
}
|
||||
|
||||
|
||||
func renderCard(data cardData, width int, height int) string {
|
||||
titleText := data.icon + " " + data.title
|
||||
lineLen := width - lipgloss.Width(titleText) - 1
|
||||
if lineLen < 4 {
|
||||
lineLen = 4
|
||||
}
|
||||
header := titleStyle.Render(titleText) + " " + lineStyle.Render(strings.Repeat("─", lineLen))
|
||||
content := header + "\n" + strings.Join(data.lines, "\n") + "\n"
|
||||
|
||||
// Pad to target height
|
||||
lines := strings.Split(content, "\n")
|
||||
for len(lines) < height {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func progressBar(percent float64) string {
|
||||
total := 18
|
||||
if percent < 0 {
|
||||
percent = 0
|
||||
}
|
||||
if percent > 100 {
|
||||
percent = 100
|
||||
}
|
||||
filled := int(percent / 100 * float64(total))
|
||||
if filled > total {
|
||||
filled = total
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
for i := 0; i < total; i++ {
|
||||
if i < filled {
|
||||
builder.WriteString("█")
|
||||
} else {
|
||||
builder.WriteString("░")
|
||||
}
|
||||
}
|
||||
return colorizePercent(percent, builder.String())
|
||||
}
|
||||
|
||||
func colorizePercent(percent float64, s string) string {
|
||||
switch {
|
||||
case percent >= 90:
|
||||
return dangerStyle.Render(s)
|
||||
case percent >= 70:
|
||||
return warnStyle.Render(s)
|
||||
default:
|
||||
return okStyle.Render(s)
|
||||
}
|
||||
}
|
||||
|
||||
func colorizeTemp(t float64) string {
|
||||
switch {
|
||||
case t >= 85:
|
||||
return dangerStyle.Render(fmt.Sprintf("%.1f", t))
|
||||
case t >= 70:
|
||||
return warnStyle.Render(fmt.Sprintf("%.1f", t))
|
||||
default:
|
||||
return subtleStyle.Render(fmt.Sprintf("%.1f", t))
|
||||
}
|
||||
}
|
||||
|
||||
func formatRate(mb float64) string {
|
||||
if mb < 0.01 {
|
||||
return "0 MB/s"
|
||||
}
|
||||
if mb < 1 {
|
||||
return fmt.Sprintf("%.2f MB/s", mb)
|
||||
}
|
||||
if mb < 10 {
|
||||
return fmt.Sprintf("%.1f MB/s", mb)
|
||||
}
|
||||
return fmt.Sprintf("%.0f MB/s", mb)
|
||||
}
|
||||
|
||||
func humanBytes(v uint64) string {
|
||||
switch {
|
||||
case v > 1<<40:
|
||||
return fmt.Sprintf("%.1f TB", float64(v)/(1<<40))
|
||||
case v > 1<<30:
|
||||
return fmt.Sprintf("%.1f GB", float64(v)/(1<<30))
|
||||
case v > 1<<20:
|
||||
return fmt.Sprintf("%.1f MB", float64(v)/(1<<20))
|
||||
case v > 1<<10:
|
||||
return fmt.Sprintf("%.1f KB", float64(v)/(1<<10))
|
||||
default:
|
||||
return strconv.FormatUint(v, 10) + " B"
|
||||
}
|
||||
}
|
||||
|
||||
func shorten(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max-1] + "…"
|
||||
}
|
||||
|
||||
func renderTwoColumns(cards []cardData, width int) string {
|
||||
if len(cards) == 0 {
|
||||
return ""
|
||||
}
|
||||
cw := colWidth
|
||||
if width > 0 && width/2-2 > cw {
|
||||
cw = width/2 - 2
|
||||
}
|
||||
var rows []string
|
||||
for i := 0; i < len(cards); i += 2 {
|
||||
left := renderCard(cards[i], cw, 0)
|
||||
right := ""
|
||||
if i+1 < len(cards) {
|
||||
right = renderCard(cards[i+1], cw, 0)
|
||||
}
|
||||
targetHeight := maxInt(lipgloss.Height(left), lipgloss.Height(right))
|
||||
left = renderCard(cards[i], cw, targetHeight)
|
||||
if right != "" {
|
||||
right = renderCard(cards[i+1], cw, targetHeight)
|
||||
rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right))
|
||||
} else {
|
||||
rows = append(rows, left)
|
||||
}
|
||||
}
|
||||
return lipgloss.JoinVertical(lipgloss.Left, rows...)
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user