checkpointing, heavy dev
This commit is contained in:
@@ -1,156 +1,222 @@
|
||||
// Package renderer provides screen rendering implementations for hdmistat
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"strings"
|
||||
|
||||
"git.eeqj.de/sneak/hdmistat/internal/layout"
|
||||
"git.eeqj.de/sneak/hdmistat/internal/statcollector"
|
||||
"github.com/dustin/go-humanize"
|
||||
)
|
||||
|
||||
// OverviewScreen displays system overview
|
||||
type OverviewScreen struct{}
|
||||
|
||||
// NewOverviewScreen creates a new overview screen renderer
|
||||
func NewOverviewScreen() *OverviewScreen {
|
||||
return &OverviewScreen{}
|
||||
}
|
||||
|
||||
// Name returns the name of this screen
|
||||
func (s *OverviewScreen) Name() string {
|
||||
return "System Overview"
|
||||
}
|
||||
|
||||
// Render draws the overview screen to the provided canvas
|
||||
func (s *OverviewScreen) Render(canvas *layout.Canvas, info *statcollector.SystemInfo) error {
|
||||
width, height := canvas.Size()
|
||||
_, _ = canvas.Size()
|
||||
|
||||
// Colors
|
||||
textColor := color.RGBA{255, 255, 255, 255}
|
||||
headerColor := color.RGBA{100, 200, 255, 255}
|
||||
dimColor := color.RGBA{150, 150, 150, 255}
|
||||
|
||||
// Styles
|
||||
titleStyle := layout.TextStyle{Size: 48, Color: headerColor}
|
||||
headerStyle := layout.TextStyle{Size: 24, Color: headerColor}
|
||||
headerStyle := layout.TextStyle{Size: 18, Color: headerColor, Bold: true}
|
||||
normalStyle := layout.TextStyle{Size: 18, Color: textColor}
|
||||
smallStyle := layout.TextStyle{Size: 16, Color: dimColor}
|
||||
|
||||
y := 50
|
||||
y := 120 // Start below header
|
||||
|
||||
// Title
|
||||
_ = canvas.DrawText(info.Hostname, layout.Point{X: width / 2, Y: y}, layout.TextStyle{
|
||||
Size: titleStyle.Size,
|
||||
// Get short hostname
|
||||
shortHostname := info.Hostname
|
||||
if idx := strings.Index(shortHostname, "."); idx > 0 {
|
||||
shortHostname = shortHostname[:idx]
|
||||
}
|
||||
|
||||
// Title - left aligned at consistent position
|
||||
titleText := fmt.Sprintf("%s: status", shortHostname)
|
||||
_ = canvas.DrawText(titleText, layout.Point{X: 50, Y: y}, layout.TextStyle{
|
||||
Size: 36, // Smaller than before
|
||||
Color: titleStyle.Color,
|
||||
Alignment: layout.AlignCenter,
|
||||
})
|
||||
y += 80
|
||||
|
||||
// Uptime
|
||||
uptimeText := fmt.Sprintf("Uptime: %s", layout.FormatDuration(info.Uptime.Seconds()))
|
||||
_ = canvas.DrawText(uptimeText, layout.Point{X: width / 2, Y: y}, layout.TextStyle{
|
||||
Size: smallStyle.Size,
|
||||
Color: smallStyle.Color,
|
||||
Alignment: layout.AlignCenter,
|
||||
Alignment: layout.AlignLeft,
|
||||
})
|
||||
y += 60
|
||||
|
||||
// Two column layout
|
||||
leftX := 50
|
||||
rightX := width/2 + 50
|
||||
|
||||
// Memory section (left)
|
||||
_ = canvas.DrawText("MEMORY", layout.Point{X: leftX, Y: y}, headerStyle)
|
||||
y += 35
|
||||
|
||||
memUsedPercent := float64(info.MemoryUsed) / float64(info.MemoryTotal) * 100
|
||||
_ = canvas.DrawText(fmt.Sprintf("Total: %s", layout.FormatBytes(info.MemoryTotal)),
|
||||
layout.Point{X: leftX, Y: y}, normalStyle)
|
||||
y += 25
|
||||
_ = canvas.DrawText(fmt.Sprintf("Used: %s (%.1f%%)", layout.FormatBytes(info.MemoryUsed), memUsedPercent),
|
||||
layout.Point{X: leftX, Y: y}, normalStyle)
|
||||
y += 25
|
||||
_ = canvas.DrawText(fmt.Sprintf("Free: %s", layout.FormatBytes(info.MemoryFree)),
|
||||
layout.Point{X: leftX, Y: y}, normalStyle)
|
||||
y += 35
|
||||
|
||||
// Memory progress bar
|
||||
canvas.DrawProgress(leftX, y, 400, 20, memUsedPercent,
|
||||
color.RGBA{100, 200, 100, 255},
|
||||
color.RGBA{50, 50, 50, 255})
|
||||
|
||||
// CPU section (right)
|
||||
cpuY := y - 115
|
||||
_ = canvas.DrawText("CPU", layout.Point{X: rightX, Y: cpuY}, headerStyle)
|
||||
cpuY += 35
|
||||
|
||||
// Show per-core CPU usage
|
||||
for i, percent := range info.CPUPercent {
|
||||
if i >= 8 {
|
||||
// Limit display to 8 cores
|
||||
_ = canvas.DrawText(fmt.Sprintf("... and %d more cores", len(info.CPUPercent)-8),
|
||||
layout.Point{X: rightX, Y: cpuY}, smallStyle)
|
||||
break
|
||||
}
|
||||
_ = canvas.DrawText(fmt.Sprintf("Core %d:", i), layout.Point{X: rightX, Y: cpuY}, smallStyle)
|
||||
canvas.DrawProgress(rightX+80, cpuY-12, 200, 15, percent,
|
||||
color.RGBA{255, 100, 100, 255},
|
||||
color.RGBA{50, 50, 50, 255})
|
||||
cpuY += 20
|
||||
}
|
||||
|
||||
y += 60
|
||||
|
||||
// Disk usage section
|
||||
_ = canvas.DrawText("DISK USAGE", layout.Point{X: leftX, Y: y}, headerStyle)
|
||||
y += 35
|
||||
|
||||
for i, disk := range info.DiskUsage {
|
||||
if i >= 4 {
|
||||
break // Limit to 4 disks
|
||||
}
|
||||
_ = canvas.DrawText(disk.Path, layout.Point{X: leftX, Y: y}, normalStyle)
|
||||
usageText := fmt.Sprintf("%s / %s", layout.FormatBytes(disk.Used), layout.FormatBytes(disk.Total))
|
||||
_ = canvas.DrawText(usageText, layout.Point{X: leftX + 200, Y: y}, smallStyle)
|
||||
canvas.DrawProgress(leftX+400, y-12, 300, 15, disk.UsedPercent,
|
||||
color.RGBA{200, 200, 100, 255},
|
||||
color.RGBA{50, 50, 50, 255})
|
||||
y += 30
|
||||
}
|
||||
// Standard bar dimensions
|
||||
barWidth := 400
|
||||
barHeight := 20
|
||||
sectionSpacing := 60
|
||||
|
||||
// CPU section
|
||||
_ = canvas.DrawText("CPU", layout.Point{X: 50, Y: y}, headerStyle)
|
||||
y += 30
|
||||
|
||||
// Network section
|
||||
_ = canvas.DrawText("NETWORK", layout.Point{X: leftX, Y: y}, headerStyle)
|
||||
y += 35
|
||||
// Calculate average CPU usage
|
||||
totalCPU := 0.0
|
||||
for _, cpu := range info.CPUPercent {
|
||||
totalCPU += cpu
|
||||
}
|
||||
avgCPU := totalCPU / float64(len(info.CPUPercent))
|
||||
|
||||
for i, net := range info.Network {
|
||||
if i >= 3 {
|
||||
break // Limit to 3 interfaces
|
||||
}
|
||||
_ = canvas.DrawText(net.Name, layout.Point{X: leftX, Y: y}, normalStyle)
|
||||
// Draw composite CPU bar below header
|
||||
cpuBar := &layout.ProgressBar{
|
||||
X: 50, Y: y,
|
||||
Width: barWidth, Height: barHeight,
|
||||
Value: avgCPU / 100.0,
|
||||
Label: fmt.Sprintf("%.1f%% average across %d cores", avgCPU, len(info.CPUPercent)),
|
||||
LeftLabel: "0%",
|
||||
RightLabel: "100%",
|
||||
BarColor: color.RGBA{255, 100, 100, 255},
|
||||
}
|
||||
cpuBar.Draw(canvas)
|
||||
y += sectionSpacing
|
||||
|
||||
if len(net.IPAddresses) > 0 {
|
||||
_ = canvas.DrawText(net.IPAddresses[0], layout.Point{X: leftX + 150, Y: y}, smallStyle)
|
||||
}
|
||||
// Memory section
|
||||
_ = canvas.DrawText("MEMORY", layout.Point{X: 50, Y: y}, headerStyle)
|
||||
y += 30
|
||||
|
||||
trafficText := fmt.Sprintf("TX: %s RX: %s",
|
||||
layout.FormatBytes(net.BytesSent),
|
||||
layout.FormatBytes(net.BytesRecv))
|
||||
_ = canvas.DrawText(trafficText, layout.Point{X: leftX + 400, Y: y}, smallStyle)
|
||||
memUsedPercent := float64(info.MemoryUsed) / float64(info.MemoryTotal)
|
||||
memoryBar := &layout.ProgressBar{
|
||||
X: 50, Y: y,
|
||||
Width: barWidth, Height: barHeight,
|
||||
Value: memUsedPercent,
|
||||
Label: fmt.Sprintf("%s of %s", layout.FormatBytes(info.MemoryUsed), layout.FormatBytes(info.MemoryTotal)),
|
||||
LeftLabel: "0B",
|
||||
RightLabel: layout.FormatBytes(info.MemoryTotal),
|
||||
BarColor: color.RGBA{100, 200, 100, 255},
|
||||
}
|
||||
memoryBar.Draw(canvas)
|
||||
y += sectionSpacing
|
||||
|
||||
// Temperature section
|
||||
if len(info.Temperature) > 0 {
|
||||
_ = canvas.DrawText("TEMPERATURE", layout.Point{X: 50, Y: y}, headerStyle)
|
||||
y += 30
|
||||
|
||||
// Find the highest temperature
|
||||
maxTemp := 0.0
|
||||
maxSensor := ""
|
||||
for sensor, temp := range info.Temperature {
|
||||
if temp > maxTemp {
|
||||
maxTemp = temp
|
||||
maxSensor = sensor
|
||||
}
|
||||
}
|
||||
|
||||
// Temperature scale from 30°C to 99°C
|
||||
tempValue := (maxTemp - 30.0) / (99.0 - 30.0)
|
||||
if tempValue < 0 {
|
||||
tempValue = 0
|
||||
}
|
||||
if tempValue > 1 {
|
||||
tempValue = 1
|
||||
}
|
||||
|
||||
tempBar := &layout.ProgressBar{
|
||||
X: 50, Y: y,
|
||||
Width: barWidth, Height: barHeight,
|
||||
Value: tempValue,
|
||||
Label: fmt.Sprintf("%s: %.1f°C", maxSensor, maxTemp),
|
||||
LeftLabel: "30°C",
|
||||
RightLabel: "99°C",
|
||||
BarColor: color.RGBA{255, 150, 50, 255},
|
||||
}
|
||||
tempBar.Draw(canvas)
|
||||
y += sectionSpacing
|
||||
}
|
||||
|
||||
// Temperature section (bottom right)
|
||||
if len(info.Temperature) > 0 {
|
||||
tempY := height - 200
|
||||
_ = canvas.DrawText("TEMPERATURE", layout.Point{X: rightX, Y: tempY}, headerStyle)
|
||||
tempY += 35
|
||||
// Disk usage section
|
||||
_ = canvas.DrawText("DISK USAGE", layout.Point{X: 50, Y: y}, headerStyle)
|
||||
y += 30
|
||||
|
||||
for sensor, temp := range info.Temperature {
|
||||
if tempY > height-50 {
|
||||
break
|
||||
for _, disk := range info.DiskUsage {
|
||||
// Skip snap disks
|
||||
if strings.HasPrefix(disk.Path, "/snap") {
|
||||
continue
|
||||
}
|
||||
|
||||
diskBar := &layout.ProgressBar{
|
||||
X: 50, Y: y,
|
||||
Width: barWidth, Height: barHeight,
|
||||
Value: disk.UsedPercent / 100.0,
|
||||
Label: fmt.Sprintf("%s: %s of %s", disk.Path, layout.FormatBytes(disk.Used), layout.FormatBytes(disk.Total)),
|
||||
LeftLabel: "0B",
|
||||
RightLabel: layout.FormatBytes(disk.Total),
|
||||
BarColor: color.RGBA{200, 200, 100, 255},
|
||||
}
|
||||
diskBar.Draw(canvas)
|
||||
y += 40
|
||||
|
||||
if y > 700 {
|
||||
break // Don't overflow the screen
|
||||
}
|
||||
}
|
||||
|
||||
y += sectionSpacing
|
||||
|
||||
// Network section
|
||||
if len(info.Network) > 0 {
|
||||
_ = canvas.DrawText("NETWORK", layout.Point{X: 50, Y: y}, headerStyle)
|
||||
y += 30
|
||||
|
||||
for _, net := range info.Network {
|
||||
// Network interface info
|
||||
interfaceText := net.Name
|
||||
if len(net.IPAddresses) > 0 {
|
||||
interfaceText = fmt.Sprintf("%s (%s)", net.Name, net.IPAddresses[0])
|
||||
}
|
||||
_ = canvas.DrawText(interfaceText, layout.Point{X: 50, Y: y}, normalStyle)
|
||||
y += 25
|
||||
|
||||
// Get link speed for scaling (default to 1 Gbps if unknown)
|
||||
linkSpeed := net.LinkSpeed
|
||||
if linkSpeed == 0 {
|
||||
linkSpeed = 1000 * 1000 * 1000 // 1 Gbps in bits
|
||||
}
|
||||
|
||||
// TX rate bar
|
||||
txValue := float64(net.BitsSentRate) / float64(linkSpeed)
|
||||
txBar := &layout.ProgressBar{
|
||||
X: 50, Y: y,
|
||||
Width: barWidth/2 - 10, Height: barHeight,
|
||||
Value: txValue,
|
||||
Label: fmt.Sprintf("↑ %s", net.FormatSentRate()),
|
||||
LeftLabel: "0",
|
||||
RightLabel: humanize.SI(float64(linkSpeed), "bit/s"),
|
||||
BarColor: color.RGBA{100, 255, 100, 255},
|
||||
}
|
||||
txBar.Draw(canvas)
|
||||
|
||||
// RX rate bar
|
||||
rxValue := float64(net.BitsRecvRate) / float64(linkSpeed)
|
||||
rxBar := &layout.ProgressBar{
|
||||
X: 50 + barWidth/2 + 10, Y: y,
|
||||
Width: barWidth/2 - 10, Height: barHeight,
|
||||
Value: rxValue,
|
||||
Label: fmt.Sprintf("↓ %s", net.FormatRecvRate()),
|
||||
LeftLabel: "0",
|
||||
RightLabel: humanize.SI(float64(linkSpeed), "bit/s"),
|
||||
BarColor: color.RGBA{100, 100, 255, 255},
|
||||
}
|
||||
rxBar.Draw(canvas)
|
||||
|
||||
y += 60
|
||||
|
||||
if y > 900 {
|
||||
break // Don't overflow the screen
|
||||
}
|
||||
_ = canvas.DrawText(fmt.Sprintf("%s: %.1f°C", sensor, temp),
|
||||
layout.Point{X: rightX, Y: tempY}, normalStyle)
|
||||
tempY += 25
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,24 +4,39 @@ import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.eeqj.de/sneak/hdmistat/internal/layout"
|
||||
"git.eeqj.de/sneak/hdmistat/internal/statcollector"
|
||||
)
|
||||
|
||||
const (
|
||||
// Display constants
|
||||
maxProcessNameLen = 30
|
||||
maxUsernameLen = 12
|
||||
topProcessCount = 20
|
||||
cpuHighThreshold = 50.0
|
||||
memoryHighRatio = 0.1
|
||||
percentMultiplier = 100.0
|
||||
halfDivisor = 2
|
||||
)
|
||||
|
||||
// ProcessScreen displays top processes
|
||||
type ProcessScreen struct {
|
||||
SortBy string // "cpu" or "memory"
|
||||
}
|
||||
|
||||
// NewProcessScreenCPU creates a new process screen sorted by CPU usage
|
||||
func NewProcessScreenCPU() *ProcessScreen {
|
||||
return &ProcessScreen{SortBy: "cpu"}
|
||||
}
|
||||
|
||||
// NewProcessScreenMemory creates a new process screen sorted by memory usage
|
||||
func NewProcessScreenMemory() *ProcessScreen {
|
||||
return &ProcessScreen{SortBy: "memory"}
|
||||
}
|
||||
|
||||
// Name returns the name of this screen
|
||||
func (s *ProcessScreen) Name() string {
|
||||
if s.SortBy == "cpu" {
|
||||
return "Top Processes by CPU"
|
||||
@@ -29,6 +44,7 @@ func (s *ProcessScreen) Name() string {
|
||||
return "Top Processes by Memory"
|
||||
}
|
||||
|
||||
// Render draws the process screen to the provided canvas
|
||||
func (s *ProcessScreen) Render(canvas *layout.Canvas, info *statcollector.SystemInfo) error {
|
||||
width, _ := canvas.Size()
|
||||
|
||||
@@ -43,15 +59,27 @@ func (s *ProcessScreen) Render(canvas *layout.Canvas, info *statcollector.System
|
||||
normalStyle := layout.TextStyle{Size: 16, Color: textColor}
|
||||
smallStyle := layout.TextStyle{Size: 14, Color: dimColor}
|
||||
|
||||
y := 50
|
||||
y := 120 // Start below header - same as overview
|
||||
|
||||
// Title
|
||||
_ = canvas.DrawText(s.Name(), layout.Point{X: width / 2, Y: y}, layout.TextStyle{
|
||||
Size: titleStyle.Size,
|
||||
// Get short hostname
|
||||
shortHostname := info.Hostname
|
||||
if idx := strings.Index(shortHostname, "."); idx > 0 {
|
||||
shortHostname = shortHostname[:idx]
|
||||
}
|
||||
|
||||
// Title - left aligned at consistent position
|
||||
titleText := ""
|
||||
if s.SortBy == "cpu" {
|
||||
titleText = fmt.Sprintf("%s: cpu", shortHostname)
|
||||
} else {
|
||||
titleText = fmt.Sprintf("%s: memory", shortHostname)
|
||||
}
|
||||
_ = canvas.DrawText(titleText, layout.Point{X: 50, Y: y}, layout.TextStyle{
|
||||
Size: 36, // Same size as overview
|
||||
Color: titleStyle.Color,
|
||||
Alignment: layout.AlignCenter,
|
||||
Alignment: layout.AlignLeft,
|
||||
})
|
||||
y += 70
|
||||
y += 60
|
||||
|
||||
// Sort processes
|
||||
processes := make([]statcollector.ProcessInfo, len(info.Processes))
|
||||
@@ -81,19 +109,26 @@ func (s *ProcessScreen) Render(canvas *layout.Canvas, info *statcollector.System
|
||||
|
||||
// Display top 20 processes
|
||||
for i, proc := range processes {
|
||||
if i >= 20 {
|
||||
if i >= topProcessCount {
|
||||
break
|
||||
}
|
||||
|
||||
// Truncate long names
|
||||
name := proc.Name
|
||||
if len(name) > 30 {
|
||||
name = name[:27] + "..."
|
||||
if len(name) > maxProcessNameLen {
|
||||
name = name[:maxProcessNameLen-3] + "..."
|
||||
}
|
||||
|
||||
user := proc.Username
|
||||
if len(user) > 12 {
|
||||
user = user[:9] + "..."
|
||||
if len(user) > maxUsernameLen {
|
||||
user = user[:maxUsernameLen-3] + "..."
|
||||
}
|
||||
|
||||
// Highlight bar for high usage (draw BEFORE text)
|
||||
if s.SortBy == "cpu" && proc.CPUPercent > cpuHighThreshold {
|
||||
canvas.DrawBox(x-5, y-15, width-90, 20, color.RGBA{100, 50, 50, 100})
|
||||
} else if s.SortBy == "memory" && float64(proc.MemoryRSS)/float64(info.MemoryTotal) > memoryHighRatio {
|
||||
canvas.DrawBox(x-5, y-15, width-90, 20, color.RGBA{50, 50, 100, 100})
|
||||
}
|
||||
|
||||
_ = canvas.DrawText(fmt.Sprintf("%d", proc.PID), layout.Point{X: x, Y: y}, normalStyle)
|
||||
@@ -102,13 +137,6 @@ func (s *ProcessScreen) Render(canvas *layout.Canvas, info *statcollector.System
|
||||
_ = canvas.DrawText(fmt.Sprintf("%.1f", proc.CPUPercent), layout.Point{X: x + 600, Y: y}, normalStyle)
|
||||
_ = canvas.DrawText(layout.FormatBytes(proc.MemoryRSS), layout.Point{X: x + 700, Y: y}, normalStyle)
|
||||
|
||||
// Highlight bar for high usage
|
||||
if s.SortBy == "cpu" && proc.CPUPercent > 50 {
|
||||
canvas.DrawBox(x-5, y-15, width-90, 20, color.RGBA{100, 50, 50, 100})
|
||||
} else if s.SortBy == "memory" && float64(proc.MemoryRSS)/float64(info.MemoryTotal) > 0.1 {
|
||||
canvas.DrawBox(x-5, y-15, width-90, 20, color.RGBA{50, 50, 100, 100})
|
||||
}
|
||||
|
||||
y += 25
|
||||
}
|
||||
|
||||
@@ -127,9 +155,9 @@ func (s *ProcessScreen) Render(canvas *layout.Canvas, info *statcollector.System
|
||||
avgCPU,
|
||||
layout.FormatBytes(info.MemoryUsed),
|
||||
layout.FormatBytes(info.MemoryTotal),
|
||||
float64(info.MemoryUsed)/float64(info.MemoryTotal)*100)
|
||||
float64(info.MemoryUsed)/float64(info.MemoryTotal)*percentMultiplier)
|
||||
|
||||
_ = canvas.DrawText(footerText, layout.Point{X: width / 2, Y: y}, layout.TextStyle{
|
||||
_ = canvas.DrawText(footerText, layout.Point{X: width / halfDivisor, Y: y}, layout.TextStyle{
|
||||
Size: smallStyle.Size,
|
||||
Color: smallStyle.Color,
|
||||
Alignment: layout.AlignCenter,
|
||||
|
||||
@@ -2,7 +2,11 @@ package renderer
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/hdmistat/internal/layout"
|
||||
"git.eeqj.de/sneak/hdmistat/internal/statcollector"
|
||||
@@ -43,9 +47,134 @@ func (r *Renderer) SetResolution(width, height int) {
|
||||
func (r *Renderer) RenderScreen(screen Screen, info *statcollector.SystemInfo) (*image.RGBA, error) {
|
||||
canvas := layout.NewCanvas(r.width, r.height, r.font, r.logger)
|
||||
|
||||
// Draw common header
|
||||
r.drawHeader(canvas, info)
|
||||
|
||||
if err := screen.Render(canvas, info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return canvas.Image(), nil
|
||||
}
|
||||
|
||||
// drawHeader draws the common header with system info
|
||||
func (r *Renderer) drawHeader(canvas *layout.Canvas, _ *statcollector.SystemInfo) {
|
||||
width, _ := canvas.Size()
|
||||
headerColor := color.RGBA{150, 150, 150, 255}
|
||||
headerStyle := layout.TextStyle{Size: 14, Color: headerColor, Bold: true}
|
||||
|
||||
// Get uname info
|
||||
uname := "Unknown System"
|
||||
if output, err := exec.Command("uname", "-a").Output(); err == nil {
|
||||
uname = strings.TrimSpace(string(output))
|
||||
// Truncate if too long
|
||||
if len(uname) > 150 {
|
||||
uname = uname[:147] + "..."
|
||||
}
|
||||
}
|
||||
|
||||
// Draw uname on left
|
||||
_ = canvas.DrawText(uname, layout.Point{X: 20, Y: 20}, headerStyle)
|
||||
|
||||
// Check NTP sync status
|
||||
ntpSynced := r.checkNTPSync()
|
||||
var syncIndicator string
|
||||
var syncColor color.Color
|
||||
if ntpSynced {
|
||||
syncIndicator = "*"
|
||||
syncColor = color.RGBA{0, 255, 0, 255} // Green
|
||||
} else {
|
||||
syncIndicator = "?"
|
||||
syncColor = color.RGBA{255, 0, 0, 255} // Red
|
||||
}
|
||||
|
||||
// Time formats
|
||||
now := time.Now()
|
||||
utcTime := now.UTC().Format("Mon 2006-01-02 15:04:05 UTC")
|
||||
localTime := now.Format("Mon 2006-01-02 15:04:05 MST")
|
||||
|
||||
// Draw times on the right with sync indicators
|
||||
// For simplicity, we'll use a fixed position approach
|
||||
|
||||
// Draw UTC time
|
||||
_ = canvas.DrawText(utcTime, layout.Point{X: width - 40, Y: 20}, layout.TextStyle{
|
||||
Size: headerStyle.Size,
|
||||
Color: color.RGBA{255, 255, 255, 255}, // White
|
||||
Alignment: layout.AlignRight,
|
||||
Bold: true,
|
||||
})
|
||||
// UTC sync indicators
|
||||
_ = canvas.DrawText(syncIndicator, layout.Point{X: width - 280, Y: 20}, layout.TextStyle{
|
||||
Size: headerStyle.Size,
|
||||
Color: syncColor,
|
||||
Bold: true,
|
||||
})
|
||||
_ = canvas.DrawText(syncIndicator, layout.Point{X: width - 20, Y: 20}, layout.TextStyle{
|
||||
Size: headerStyle.Size,
|
||||
Color: syncColor,
|
||||
Bold: true,
|
||||
})
|
||||
|
||||
// Draw local time
|
||||
_ = canvas.DrawText(localTime, layout.Point{X: width - 40, Y: 35}, layout.TextStyle{
|
||||
Size: headerStyle.Size,
|
||||
Color: color.RGBA{255, 255, 255, 255}, // White
|
||||
Alignment: layout.AlignRight,
|
||||
Bold: true,
|
||||
})
|
||||
// Local sync indicators
|
||||
_ = canvas.DrawText(syncIndicator, layout.Point{X: width - 280, Y: 35}, layout.TextStyle{
|
||||
Size: headerStyle.Size,
|
||||
Color: syncColor,
|
||||
Bold: true,
|
||||
})
|
||||
_ = canvas.DrawText(syncIndicator, layout.Point{X: width - 20, Y: 35}, layout.TextStyle{
|
||||
Size: headerStyle.Size,
|
||||
Color: syncColor,
|
||||
Bold: true,
|
||||
})
|
||||
|
||||
// Get uptime command output
|
||||
uptimeStr := "uptime unavailable"
|
||||
if output, err := exec.Command("uptime").Output(); err == nil {
|
||||
uptimeStr = strings.TrimSpace(string(output))
|
||||
}
|
||||
|
||||
// Draw uptime on second line
|
||||
_ = canvas.DrawText(uptimeStr, layout.Point{X: 20, Y: 40}, headerStyle)
|
||||
|
||||
// Draw horizontal rule with more space
|
||||
canvas.DrawHLine(0, 70, width, color.RGBA{100, 100, 100, 255})
|
||||
}
|
||||
|
||||
// checkNTPSync checks if the system clock is synchronized with NTP
|
||||
func (r *Renderer) checkNTPSync() bool {
|
||||
// Try timedatectl first (systemd systems)
|
||||
if output, err := exec.Command("timedatectl", "status").Output(); err == nil {
|
||||
outputStr := string(output)
|
||||
// Look for "System clock synchronized: yes" or "NTP synchronized: yes"
|
||||
if strings.Contains(outputStr, "synchronized: yes") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Try chronyc (chrony)
|
||||
if output, err := exec.Command("chronyc", "tracking").Output(); err == nil {
|
||||
outputStr := string(output)
|
||||
// Look for "Leap status : Normal"
|
||||
if strings.Contains(outputStr, "Leap status : Normal") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Try ntpstat (ntpd)
|
||||
if err := exec.Command("ntpstat").Run(); err == nil {
|
||||
// ntpstat returns 0 if synchronized
|
||||
return true
|
||||
}
|
||||
|
||||
// Default to not synced if we can't determine
|
||||
return false
|
||||
}
|
||||
|
||||
246
internal/renderer/status_screen.go
Normal file
246
internal/renderer/status_screen.go
Normal file
@@ -0,0 +1,246 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"strings"
|
||||
|
||||
"git.eeqj.de/sneak/hdmistat/internal/layout"
|
||||
"git.eeqj.de/sneak/hdmistat/internal/statcollector"
|
||||
"github.com/dustin/go-humanize"
|
||||
)
|
||||
|
||||
// StatusScreen displays system status overview
|
||||
type StatusScreen struct{}
|
||||
|
||||
// NewStatusScreen creates a new status screen
|
||||
func NewStatusScreen() *StatusScreen {
|
||||
return &StatusScreen{}
|
||||
}
|
||||
|
||||
// Name returns the screen name
|
||||
func (s *StatusScreen) Name() string {
|
||||
return "System Status"
|
||||
}
|
||||
|
||||
// Render renders the status screen
|
||||
func (s *StatusScreen) Render(canvas *layout.Canvas, info *statcollector.SystemInfo) error {
|
||||
// Use consistent font size for entire screen
|
||||
const fontSize = 16
|
||||
|
||||
// Colors
|
||||
textColor := color.RGBA{255, 255, 255, 255}
|
||||
dimColor := color.RGBA{150, 150, 150, 255}
|
||||
|
||||
// Styles
|
||||
normalStyle := layout.TextStyle{Size: fontSize, Color: textColor}
|
||||
dimStyle := layout.TextStyle{Size: fontSize, Color: dimColor}
|
||||
|
||||
// Get short hostname
|
||||
shortHostname := info.Hostname
|
||||
if idx := strings.Index(shortHostname, "."); idx > 0 {
|
||||
shortHostname = shortHostname[:idx]
|
||||
}
|
||||
|
||||
// Starting Y position (after header)
|
||||
y := 150
|
||||
|
||||
// Title
|
||||
titleText := fmt.Sprintf("%s: system status", shortHostname)
|
||||
_ = canvas.DrawText(titleText, layout.Point{X: 16, Y: y}, normalStyle)
|
||||
y += 40
|
||||
|
||||
// CPU section
|
||||
cpuLabel := fmt.Sprintf("CPU: %.1f%% average across %d cores",
|
||||
getAverageCPU(info.CPUPercent), len(info.CPUPercent))
|
||||
_ = canvas.DrawText(cpuLabel, layout.Point{X: 16, Y: y}, normalStyle)
|
||||
y += 25
|
||||
|
||||
// CPU progress bar
|
||||
_ = canvas.DrawText("0%", layout.Point{X: 100, Y: y}, dimStyle)
|
||||
drawProgressBar(canvas, 130, y-10, getAverageCPU(info.CPUPercent)/100.0, textColor)
|
||||
_ = canvas.DrawText("100%", layout.Point{X: 985, Y: y}, dimStyle)
|
||||
y += 40
|
||||
|
||||
// Memory section
|
||||
memUsedPercent := float64(info.MemoryUsed) / float64(info.MemoryTotal) * 100.0
|
||||
memLabel := fmt.Sprintf("MEMORY: %s of %s (%.1f%%)",
|
||||
layout.FormatBytes(info.MemoryUsed),
|
||||
layout.FormatBytes(info.MemoryTotal),
|
||||
memUsedPercent)
|
||||
_ = canvas.DrawText(memLabel, layout.Point{X: 16, Y: y}, normalStyle)
|
||||
y += 25
|
||||
|
||||
// Memory progress bar
|
||||
_ = canvas.DrawText("0B", layout.Point{X: 100, Y: y}, dimStyle)
|
||||
drawProgressBar(canvas, 130, y-10, float64(info.MemoryUsed)/float64(info.MemoryTotal), textColor)
|
||||
_ = canvas.DrawText(layout.FormatBytes(info.MemoryTotal), layout.Point{X: 985, Y: y}, dimStyle)
|
||||
y += 40
|
||||
|
||||
// Temperature section
|
||||
if len(info.Temperature) > 0 {
|
||||
maxTemp, maxSensor := getMaxTemperature(info.Temperature)
|
||||
tempLabel := fmt.Sprintf("TEMPERATURE: %.1f°C (%s)", maxTemp, maxSensor)
|
||||
_ = canvas.DrawText(tempLabel, layout.Point{X: 16, Y: y}, normalStyle)
|
||||
y += 25
|
||||
|
||||
// Temperature progress bar (30-99°C scale)
|
||||
_ = canvas.DrawText("30°C", layout.Point{X: 90, Y: y}, dimStyle)
|
||||
tempValue := (maxTemp - 30.0) / (99.0 - 30.0)
|
||||
if tempValue < 0 {
|
||||
tempValue = 0
|
||||
}
|
||||
if tempValue > 1 {
|
||||
tempValue = 1
|
||||
}
|
||||
drawProgressBar(canvas, 130, y-10, tempValue, textColor)
|
||||
_ = canvas.DrawText("99°C", layout.Point{X: 985, Y: y}, dimStyle)
|
||||
y += 40
|
||||
}
|
||||
|
||||
// Disk usage section
|
||||
_ = canvas.DrawText("DISK USAGE:", layout.Point{X: 16, Y: y}, normalStyle)
|
||||
y += 25
|
||||
|
||||
for _, disk := range info.DiskUsage {
|
||||
// Skip snap disks
|
||||
if strings.HasPrefix(disk.Path, "/snap") {
|
||||
continue
|
||||
}
|
||||
|
||||
diskLabel := fmt.Sprintf(" * %-12s %s of %s (%.1f%%)",
|
||||
disk.Path,
|
||||
layout.FormatBytes(disk.Used),
|
||||
layout.FormatBytes(disk.Total),
|
||||
disk.UsedPercent)
|
||||
_ = canvas.DrawText(diskLabel, layout.Point{X: 16, Y: y}, normalStyle)
|
||||
|
||||
// Disk progress bar
|
||||
_ = canvas.DrawText("0B", layout.Point{X: 470, Y: y}, dimStyle)
|
||||
drawDiskProgressBar(canvas, 500, y-10, disk.UsedPercent/100.0, textColor)
|
||||
_ = canvas.DrawText(layout.FormatBytes(disk.Total), layout.Point{X: 985, Y: y}, dimStyle)
|
||||
y += 30
|
||||
|
||||
if y > 700 {
|
||||
break // Don't overflow
|
||||
}
|
||||
}
|
||||
|
||||
// Network section
|
||||
if len(info.Network) > 0 {
|
||||
y += 15
|
||||
_ = canvas.DrawText("NETWORK:", layout.Point{X: 16, Y: y}, normalStyle)
|
||||
y += 25
|
||||
|
||||
for _, net := range info.Network {
|
||||
// Interface header
|
||||
interfaceText := fmt.Sprintf(" * %s", net.Name)
|
||||
if len(net.IPAddresses) > 0 {
|
||||
interfaceText = fmt.Sprintf(" * %s (%s):", net.Name, net.IPAddresses[0])
|
||||
}
|
||||
_ = canvas.DrawText(interfaceText, layout.Point{X: 16, Y: y}, normalStyle)
|
||||
y += 25
|
||||
|
||||
// Get link speed for scaling (default to 1 Gbps if unknown)
|
||||
linkSpeed := net.LinkSpeed
|
||||
linkSpeedText := ""
|
||||
if linkSpeed == 0 {
|
||||
linkSpeed = 1000 * 1000 * 1000 // 1 Gbps in bits
|
||||
linkSpeedText = "1G link"
|
||||
} else {
|
||||
linkSpeedText = fmt.Sprintf("%s link", humanize.SI(float64(linkSpeed), "bit/s"))
|
||||
}
|
||||
|
||||
// Upload rate
|
||||
upLabel := fmt.Sprintf(" ↑ %7s (%s)", net.FormatSentRate(), linkSpeedText)
|
||||
_ = canvas.DrawText(upLabel, layout.Point{X: 16, Y: y}, normalStyle)
|
||||
_ = canvas.DrawText("0 bit/s", layout.Point{X: 400, Y: y}, dimStyle)
|
||||
drawNetworkProgressBar(canvas, 500, y-10, float64(net.BitsSentRate)/float64(linkSpeed), textColor)
|
||||
_ = canvas.DrawText(humanize.SI(float64(linkSpeed), "bit/s"), layout.Point{X: 960, Y: y}, dimStyle)
|
||||
y += 25
|
||||
|
||||
// Download rate
|
||||
downLabel := fmt.Sprintf(" ↓ %7s", net.FormatRecvRate())
|
||||
_ = canvas.DrawText(downLabel, layout.Point{X: 16, Y: y}, normalStyle)
|
||||
_ = canvas.DrawText("0 bit/s", layout.Point{X: 400, Y: y}, dimStyle)
|
||||
drawNetworkProgressBar(canvas, 500, y-10, float64(net.BitsRecvRate)/float64(linkSpeed), textColor)
|
||||
_ = canvas.DrawText(humanize.SI(float64(linkSpeed), "bit/s"), layout.Point{X: 960, Y: y}, dimStyle)
|
||||
y += 35
|
||||
|
||||
if y > 900 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// drawProgressBar draws a progress bar matching the mockup style
|
||||
func drawProgressBar(canvas *layout.Canvas, x, y int, value float64, color color.Color) {
|
||||
const barWidth = 850
|
||||
|
||||
// Draw opening bracket
|
||||
_ = canvas.DrawText("[", layout.Point{X: x, Y: y + 15}, layout.TextStyle{Size: 16, Color: color})
|
||||
|
||||
// Calculate fill
|
||||
fillChars := int(value * 80)
|
||||
emptyChars := 80 - fillChars
|
||||
|
||||
// Draw bar content
|
||||
barContent := strings.Repeat("█", fillChars) + strings.Repeat("▒", emptyChars)
|
||||
_ = canvas.DrawText(barContent, layout.Point{X: x + 10, Y: y + 15}, layout.TextStyle{Size: 16, Color: color})
|
||||
|
||||
// Draw closing bracket
|
||||
_ = canvas.DrawText("]", layout.Point{X: x + barWidth - 10, Y: y + 15}, layout.TextStyle{Size: 16, Color: color})
|
||||
}
|
||||
|
||||
// drawDiskProgressBar draws a smaller progress bar for disk usage
|
||||
func drawDiskProgressBar(canvas *layout.Canvas, x, y int, value float64, color color.Color) {
|
||||
const barWidth = 480
|
||||
|
||||
// Draw opening bracket
|
||||
_ = canvas.DrawText("[", layout.Point{X: x, Y: y + 15}, layout.TextStyle{Size: 16, Color: color})
|
||||
|
||||
// Calculate fill (50 chars total)
|
||||
fillChars := int(value * 50)
|
||||
emptyChars := 50 - fillChars
|
||||
|
||||
// Draw bar content
|
||||
barContent := strings.Repeat("█", fillChars) + strings.Repeat("▒", emptyChars)
|
||||
_ = canvas.DrawText(barContent, layout.Point{X: x + 10, Y: y + 15}, layout.TextStyle{Size: 16, Color: color})
|
||||
|
||||
// Draw closing bracket
|
||||
_ = canvas.DrawText("]", layout.Point{X: x + barWidth - 10, Y: y + 15}, layout.TextStyle{Size: 16, Color: color})
|
||||
}
|
||||
|
||||
// drawNetworkProgressBar draws a progress bar for network rates
|
||||
func drawNetworkProgressBar(canvas *layout.Canvas, x, y int, value float64, color color.Color) {
|
||||
// Same as disk progress bar
|
||||
drawDiskProgressBar(canvas, x, y, value, color)
|
||||
}
|
||||
|
||||
// getAverageCPU calculates average CPU usage across all cores
|
||||
func getAverageCPU(cpuPercents []float64) float64 {
|
||||
if len(cpuPercents) == 0 {
|
||||
return 0
|
||||
}
|
||||
total := 0.0
|
||||
for _, cpu := range cpuPercents {
|
||||
total += cpu
|
||||
}
|
||||
return total / float64(len(cpuPercents))
|
||||
}
|
||||
|
||||
// getMaxTemperature finds the highest temperature and its sensor name
|
||||
func getMaxTemperature(temps map[string]float64) (float64, string) {
|
||||
maxTemp := 0.0
|
||||
maxSensor := ""
|
||||
for sensor, temp := range temps {
|
||||
if temp > maxTemp {
|
||||
maxTemp = temp
|
||||
maxSensor = sensor
|
||||
}
|
||||
}
|
||||
return maxTemp, maxSensor
|
||||
}
|
||||
Reference in New Issue
Block a user