feat(tui): overhaul latency sparkline scaling, color, and layout
Replace misleading relative-only sparkline with dual-channel design: bar height uses relative scaling (shows stability and anomalies), color+brightness uses absolute thresholds (shows fast vs slow). - Add brightness gradient within color bands (dim→bright as latency increases toward the next threshold) - Pass row background through sparkline rendering so zebra stripes and selection highlights carry through ANSI sequences - Cap sparkline width to 60 (matches maxHistoryLen) and column width to 62 to eliminate trailing dead space - Quiet group sparkline: subtle dots for healthy, bold red for down - Add braille subpixel canvas (ported from meridian) for future multi-row graph use
This commit is contained in:
@@ -0,0 +1,103 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
// braillePlane is a subpixel canvas where each terminal cell maps to a 2×4
|
||||||
|
// dot grid, rendered via Unicode braille (U+2800..U+28FF).
|
||||||
|
type braillePlane struct {
|
||||||
|
wCells, hCells int
|
||||||
|
wDots, hDots int
|
||||||
|
dots []bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBraillePlane(wCells, hCells int) *braillePlane {
|
||||||
|
wd, hd := wCells*2, hCells*4
|
||||||
|
return &braillePlane{
|
||||||
|
wCells: wCells, hCells: hCells,
|
||||||
|
wDots: wd, hDots: hd,
|
||||||
|
dots: make([]bool, wd*hd),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *braillePlane) set(dx, dy int) {
|
||||||
|
if dx < 0 || dy < 0 || dx >= p.wDots || dy >= p.hDots {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.dots[dy*p.wDots+dx] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// line draws a Bresenham line between two dot coordinates.
|
||||||
|
func (p *braillePlane) line(x0, y0, x1, y1 int) {
|
||||||
|
dx := intAbs(x1 - x0)
|
||||||
|
sx := 1
|
||||||
|
if x0 >= x1 {
|
||||||
|
sx = -1
|
||||||
|
}
|
||||||
|
dy := -intAbs(y1 - y0)
|
||||||
|
sy := 1
|
||||||
|
if y0 >= y1 {
|
||||||
|
sy = -1
|
||||||
|
}
|
||||||
|
err := dx + dy
|
||||||
|
for {
|
||||||
|
p.set(x0, y0)
|
||||||
|
if x0 == x1 && y0 == y1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e2 := 2 * err
|
||||||
|
if e2 >= dy {
|
||||||
|
err += dy
|
||||||
|
x0 += sx
|
||||||
|
}
|
||||||
|
if e2 <= dx {
|
||||||
|
err += dx
|
||||||
|
y0 += sy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fillBelow fills all dots below the topmost lit dot in each column,
|
||||||
|
// producing an area-chart effect.
|
||||||
|
func (p *braillePlane) fillBelow() {
|
||||||
|
for x := 0; x < p.wDots; x++ {
|
||||||
|
topY := -1
|
||||||
|
for y := 0; y < p.hDots; y++ {
|
||||||
|
if p.dots[y*p.wDots+x] {
|
||||||
|
topY = y
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if topY >= 0 {
|
||||||
|
for y := topY + 1; y < p.hDots; y++ {
|
||||||
|
p.dots[y*p.wDots+x] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cellMask builds the U+2800-relative bitmask for one terminal cell.
|
||||||
|
func (p *braillePlane) cellMask(cx, cy int) byte {
|
||||||
|
type bit struct {
|
||||||
|
dx, dy int
|
||||||
|
m byte
|
||||||
|
}
|
||||||
|
bits := [...]bit{
|
||||||
|
{0, 0, 0x01}, {0, 1, 0x02}, {0, 2, 0x04},
|
||||||
|
{1, 0, 0x08}, {1, 1, 0x10}, {1, 2, 0x20},
|
||||||
|
{0, 3, 0x40}, {1, 3, 0x80},
|
||||||
|
}
|
||||||
|
var mask byte
|
||||||
|
for _, b := range bits {
|
||||||
|
dx := cx*2 + b.dx
|
||||||
|
dy := cy*4 + b.dy
|
||||||
|
if dx >= 0 && dx < p.wDots && dy >= 0 && dy < p.hDots && p.dots[dy*p.wDots+dx] {
|
||||||
|
mask |= b.m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mask
|
||||||
|
}
|
||||||
|
|
||||||
|
func intAbs(n int) int {
|
||||||
|
if n < 0 {
|
||||||
|
return -n
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestBraillePlane_Set(t *testing.T) {
|
||||||
|
p := newBraillePlane(2, 1)
|
||||||
|
if p.wDots != 4 || p.hDots != 4 {
|
||||||
|
t.Fatalf("expected 4x4 dots, got %dx%d", p.wDots, p.hDots)
|
||||||
|
}
|
||||||
|
p.set(0, 0)
|
||||||
|
if !p.dots[0] {
|
||||||
|
t.Error("dot at (0,0) should be set")
|
||||||
|
}
|
||||||
|
p.set(-1, 0) // out of bounds, should not panic
|
||||||
|
p.set(0, 99) // out of bounds, should not panic
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBraillePlane_CellMask(t *testing.T) {
|
||||||
|
p := newBraillePlane(1, 1)
|
||||||
|
// Set bottom-left dot
|
||||||
|
p.set(0, 3)
|
||||||
|
mask := p.cellMask(0, 0)
|
||||||
|
if mask != 0x40 {
|
||||||
|
t.Errorf("bottom-left dot should be 0x40, got 0x%02x", mask)
|
||||||
|
}
|
||||||
|
// Set all dots
|
||||||
|
for y := 0; y < 4; y++ {
|
||||||
|
for x := 0; x < 2; x++ {
|
||||||
|
p.set(x, y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mask = p.cellMask(0, 0)
|
||||||
|
if mask != 0xFF {
|
||||||
|
t.Errorf("all dots should be 0xFF, got 0x%02x", mask)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBraillePlane_Line(t *testing.T) {
|
||||||
|
p := newBraillePlane(3, 1)
|
||||||
|
p.line(0, 2, 5, 2) // horizontal line
|
||||||
|
for x := 0; x <= 5; x++ {
|
||||||
|
if !p.dots[2*p.wDots+x] {
|
||||||
|
t.Errorf("dot at (%d, 2) should be set", x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBraillePlane_FillBelow(t *testing.T) {
|
||||||
|
p := newBraillePlane(1, 1)
|
||||||
|
p.set(0, 1) // set dot at row 1
|
||||||
|
p.fillBelow()
|
||||||
|
if !p.dots[1*p.wDots+0] {
|
||||||
|
t.Error("original dot should still be set")
|
||||||
|
}
|
||||||
|
if !p.dots[2*p.wDots+0] {
|
||||||
|
t.Error("row 2 should be filled")
|
||||||
|
}
|
||||||
|
if !p.dots[3*p.wDots+0] {
|
||||||
|
t.Error("row 3 should be filled")
|
||||||
|
}
|
||||||
|
if p.dots[0*p.wDots+0] {
|
||||||
|
t.Error("row 0 above the dot should not be filled")
|
||||||
|
}
|
||||||
|
}
|
||||||
+64
-23
@@ -1,15 +1,63 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
var sparkChars = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
|
var sparkChars = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
|
||||||
|
|
||||||
func latencySparkline(latencies []time.Duration, statuses []bool, width int) string {
|
func parseHex(hex string) (r, g, b uint8) {
|
||||||
|
if len(hex) == 7 && hex[0] == '#' {
|
||||||
|
fmt.Sscanf(hex[1:], "%02x%02x%02x", &r, &g, &b)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func dimColor(hex string, brightness float64) lipgloss.Color {
|
||||||
|
r, g, b := parseHex(hex)
|
||||||
|
f := 0.3 + brightness*0.7
|
||||||
|
return lipgloss.Color(fmt.Sprintf("#%02x%02x%02x",
|
||||||
|
uint8(float64(r)*f),
|
||||||
|
uint8(float64(g)*f),
|
||||||
|
uint8(float64(b)*f),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func withBg(s lipgloss.Style, bg lipgloss.Color) lipgloss.Style {
|
||||||
|
if bg != "" {
|
||||||
|
return s.Background(bg)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func latencyStyle(ms int64, bg lipgloss.Color) lipgloss.Style {
|
||||||
|
var hex string
|
||||||
|
var t float64
|
||||||
|
switch {
|
||||||
|
case ms < 200:
|
||||||
|
hex = sparkSuccess
|
||||||
|
t = float64(ms) / 200
|
||||||
|
case ms < 500:
|
||||||
|
hex = sparkWarning
|
||||||
|
t = float64(ms-200) / 300
|
||||||
|
default:
|
||||||
|
hex = sparkDanger
|
||||||
|
t = float64(ms-500) / 1500
|
||||||
|
if t > 1 {
|
||||||
|
t = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s := lipgloss.NewStyle().Foreground(dimColor(hex, t))
|
||||||
|
return withBg(s, bg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func latencySparkline(latencies []time.Duration, statuses []bool, width int, bg lipgloss.Color) string {
|
||||||
if len(latencies) == 0 {
|
if len(latencies) == 0 {
|
||||||
return subtleStyle.Render(strings.Repeat("·", width))
|
return withBg(subtleStyle, bg).Render(strings.Repeat("·", width))
|
||||||
}
|
}
|
||||||
|
|
||||||
samples := latencies
|
samples := latencies
|
||||||
@@ -30,12 +78,12 @@ func latencySparkline(latencies []time.Duration, statuses []bool, width int) str
|
|||||||
maxL = l
|
maxL = l
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
spread := maxL - minL
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
if remaining := width - len(samples); remaining > 0 {
|
if remaining := width - len(samples); remaining > 0 {
|
||||||
sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining)))
|
sb.WriteString(withBg(subtleStyle, bg).Render(strings.Repeat("·", remaining)))
|
||||||
}
|
}
|
||||||
spread := maxL - minL
|
|
||||||
for i, l := range samples {
|
for i, l := range samples {
|
||||||
idx := 0
|
idx := 0
|
||||||
if spread > 0 {
|
if spread > 0 {
|
||||||
@@ -47,24 +95,17 @@ func latencySparkline(latencies []time.Duration, statuses []bool, width int) str
|
|||||||
ch := string(sparkChars[idx])
|
ch := string(sparkChars[idx])
|
||||||
isDown := i < len(sampledStatuses) && !sampledStatuses[i]
|
isDown := i < len(sampledStatuses) && !sampledStatuses[i]
|
||||||
if isDown {
|
if isDown {
|
||||||
sb.WriteString(dangerStyle.Render(ch))
|
sb.WriteString(withBg(dangerStyle, bg).Render(ch))
|
||||||
} else {
|
} else {
|
||||||
ms := l.Milliseconds()
|
sb.WriteString(latencyStyle(l.Milliseconds(), bg).Render(ch))
|
||||||
if ms < 200 {
|
|
||||||
sb.WriteString(specialStyle.Render(ch))
|
|
||||||
} else if ms < 500 {
|
|
||||||
sb.WriteString(warnStyle.Render(ch))
|
|
||||||
} else {
|
|
||||||
sb.WriteString(dangerStyle.Render(ch))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func heartbeatSparkline(statuses []bool, width int) string {
|
func heartbeatSparkline(statuses []bool, width int, bg lipgloss.Color) string {
|
||||||
if len(statuses) == 0 {
|
if len(statuses) == 0 {
|
||||||
return subtleStyle.Render(strings.Repeat("·", width))
|
return withBg(subtleStyle, bg).Render(strings.Repeat("·", width))
|
||||||
}
|
}
|
||||||
|
|
||||||
samples := statuses
|
samples := statuses
|
||||||
@@ -74,19 +115,19 @@ func heartbeatSparkline(statuses []bool, width int) string {
|
|||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
if remaining := width - len(samples); remaining > 0 {
|
if remaining := width - len(samples); remaining > 0 {
|
||||||
sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining)))
|
sb.WriteString(withBg(subtleStyle, bg).Render(strings.Repeat("·", remaining)))
|
||||||
}
|
}
|
||||||
for _, up := range samples {
|
for _, up := range samples {
|
||||||
if up {
|
if up {
|
||||||
sb.WriteString(specialStyle.Render("▁"))
|
sb.WriteString(withBg(specialStyle, bg).Render("▁"))
|
||||||
} else {
|
} else {
|
||||||
sb.WriteString(dangerStyle.Render("█"))
|
sb.WriteString(withBg(dangerStyle, bg).Render("█"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) groupSparkline(groupID int, width int) string {
|
func (m Model) groupSparkline(groupID int, width int, bg lipgloss.Color) string {
|
||||||
allSites := m.engine.GetAllSites()
|
allSites := m.engine.GetAllSites()
|
||||||
var childStatuses [][]bool
|
var childStatuses [][]bool
|
||||||
for _, s := range allSites {
|
for _, s := range allSites {
|
||||||
@@ -99,7 +140,7 @@ func (m Model) groupSparkline(groupID int, width int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(childStatuses) == 0 {
|
if len(childStatuses) == 0 {
|
||||||
return subtleStyle.Render(strings.Repeat("·", width))
|
return withBg(subtleStyle, bg).Render(strings.Repeat("·", width))
|
||||||
}
|
}
|
||||||
|
|
||||||
maxLen := 0
|
maxLen := 0
|
||||||
@@ -127,13 +168,13 @@ func (m Model) groupSparkline(groupID int, width int) string {
|
|||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
if remaining := width - len(aggregated); remaining > 0 {
|
if remaining := width - len(aggregated); remaining > 0 {
|
||||||
sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining)))
|
sb.WriteString(withBg(subtleStyle, bg).Render(strings.Repeat("·", remaining)))
|
||||||
}
|
}
|
||||||
for _, up := range aggregated {
|
for _, up := range aggregated {
|
||||||
if up {
|
if up {
|
||||||
sb.WriteString(specialStyle.Render("•"))
|
sb.WriteString(withBg(subtleStyle, bg).Render("·"))
|
||||||
} else {
|
} else {
|
||||||
sb.WriteString(dangerStyle.Render("•"))
|
sb.WriteString(withBg(dangerStyle, bg).Render("•"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sb.String()
|
return sb.String()
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLatencySparkline_Empty(t *testing.T) {
|
func TestLatencySparkline_Empty(t *testing.T) {
|
||||||
got := latencySparkline(nil, nil, 10)
|
got := latencySparkline(nil, nil, 10, "")
|
||||||
if !strings.Contains(got, "··········") {
|
if !strings.Contains(got, "··········") {
|
||||||
t.Errorf("empty sparkline should be dots, got %q", got)
|
t.Errorf("empty sparkline should be dots, got %q", got)
|
||||||
}
|
}
|
||||||
@@ -16,10 +17,13 @@ func TestLatencySparkline_Empty(t *testing.T) {
|
|||||||
func TestLatencySparkline_SingleValue(t *testing.T) {
|
func TestLatencySparkline_SingleValue(t *testing.T) {
|
||||||
latencies := []time.Duration{100 * time.Millisecond}
|
latencies := []time.Duration{100 * time.Millisecond}
|
||||||
statuses := []bool{true}
|
statuses := []bool{true}
|
||||||
got := latencySparkline(latencies, statuses, 5)
|
got := latencySparkline(latencies, statuses, 5, "")
|
||||||
if len(got) == 0 {
|
if len(got) == 0 {
|
||||||
t.Error("sparkline should not be empty")
|
t.Error("sparkline should not be empty")
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(got, "····") {
|
||||||
|
t.Errorf("single value with width=5 should have 4 dot padding, got %q", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLatencySparkline_WidthTruncation(t *testing.T) {
|
func TestLatencySparkline_WidthTruncation(t *testing.T) {
|
||||||
@@ -29,14 +33,92 @@ func TestLatencySparkline_WidthTruncation(t *testing.T) {
|
|||||||
latencies[i] = time.Duration(i*50) * time.Millisecond
|
latencies[i] = time.Duration(i*50) * time.Millisecond
|
||||||
statuses[i] = true
|
statuses[i] = true
|
||||||
}
|
}
|
||||||
got := latencySparkline(latencies, statuses, 5)
|
got := latencySparkline(latencies, statuses, 5, "")
|
||||||
if len(got) == 0 {
|
if len(got) == 0 {
|
||||||
t.Error("sparkline should not be empty")
|
t.Error("sparkline should not be empty")
|
||||||
}
|
}
|
||||||
|
if strings.Contains(got, "·") {
|
||||||
|
t.Errorf("20 samples in width=5 should have no padding, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLatencySparkline_RelativeHeight(t *testing.T) {
|
||||||
|
latencies := []time.Duration{10 * time.Millisecond, 50 * time.Millisecond, 10 * time.Millisecond}
|
||||||
|
statuses := []bool{true, true, true}
|
||||||
|
out := stripANSI(latencySparkline(latencies, statuses, 3, ""))
|
||||||
|
runes := []rune(out)
|
||||||
|
if len(runes) < 3 {
|
||||||
|
t.Fatalf("expected 3 runes, got %d", len(runes))
|
||||||
|
}
|
||||||
|
if runes[0] == runes[1] {
|
||||||
|
t.Errorf("min and max should have different bar heights, got %c %c %c", runes[0], runes[1], runes[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLatencyStyle_BandsProduceDifferentColors(t *testing.T) {
|
||||||
|
sparkSuccess = "#00ff00"
|
||||||
|
sparkWarning = "#ffff00"
|
||||||
|
sparkDanger = "#ff0000"
|
||||||
|
defer func() {
|
||||||
|
sparkSuccess = ""
|
||||||
|
sparkWarning = ""
|
||||||
|
sparkDanger = ""
|
||||||
|
}()
|
||||||
|
|
||||||
|
green := latencyStyle(50, "")
|
||||||
|
yellow := latencyStyle(300, "")
|
||||||
|
red := latencyStyle(800, "")
|
||||||
|
|
||||||
|
gfg := green.GetForeground()
|
||||||
|
yfg := yellow.GetForeground()
|
||||||
|
rfg := red.GetForeground()
|
||||||
|
|
||||||
|
if gfg == yfg || yfg == rfg || gfg == rfg {
|
||||||
|
t.Errorf("bands should produce distinct foreground colors: green=%v yellow=%v red=%v", gfg, yfg, rfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLatencyStyle_BrightnessVariesWithinBand(t *testing.T) {
|
||||||
|
sparkSuccess = "#00ff00"
|
||||||
|
defer func() { sparkSuccess = "" }()
|
||||||
|
|
||||||
|
dim := latencyStyle(10, "")
|
||||||
|
bright := latencyStyle(190, "")
|
||||||
|
|
||||||
|
if dim.GetForeground() == bright.GetForeground() {
|
||||||
|
t.Error("10ms and 190ms should have different brightness within green band")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLatencySparkline_OutputWidth(t *testing.T) {
|
||||||
|
latencies := []time.Duration{100 * time.Millisecond, 200 * time.Millisecond, 300 * time.Millisecond}
|
||||||
|
statuses := []bool{true, true, true}
|
||||||
|
got := latencySparkline(latencies, statuses, 5, "")
|
||||||
|
count := utf8.RuneCountInString(stripANSI(got))
|
||||||
|
if count != 5 {
|
||||||
|
t.Errorf("expected 5 rune-width output, got %d from %q", count, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripANSI(s string) string {
|
||||||
|
var out strings.Builder
|
||||||
|
i := 0
|
||||||
|
for i < len(s) {
|
||||||
|
if s[i] == '\x1b' {
|
||||||
|
for i < len(s) && s[i] != 'm' {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.WriteByte(s[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return out.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHeartbeatSparkline_Empty(t *testing.T) {
|
func TestHeartbeatSparkline_Empty(t *testing.T) {
|
||||||
got := heartbeatSparkline(nil, 10)
|
got := heartbeatSparkline(nil, 10, "")
|
||||||
if !strings.Contains(got, "··········") {
|
if !strings.Contains(got, "··········") {
|
||||||
t.Errorf("empty heartbeat should be dots, got %q", got)
|
t.Errorf("empty heartbeat should be dots, got %q", got)
|
||||||
}
|
}
|
||||||
@@ -44,7 +126,7 @@ func TestHeartbeatSparkline_Empty(t *testing.T) {
|
|||||||
|
|
||||||
func TestHeartbeatSparkline_Mixed(t *testing.T) {
|
func TestHeartbeatSparkline_Mixed(t *testing.T) {
|
||||||
statuses := []bool{true, false, true, true, false}
|
statuses := []bool{true, false, true, true, false}
|
||||||
got := heartbeatSparkline(statuses, 5)
|
got := heartbeatSparkline(statuses, 5, "")
|
||||||
if len(got) == 0 {
|
if len(got) == 0 {
|
||||||
t.Error("heartbeat sparkline should not be empty")
|
t.Error("heartbeat sparkline should not be empty")
|
||||||
}
|
}
|
||||||
@@ -52,7 +134,7 @@ func TestHeartbeatSparkline_Mixed(t *testing.T) {
|
|||||||
|
|
||||||
func TestHeartbeatSparkline_PaddedWidth(t *testing.T) {
|
func TestHeartbeatSparkline_PaddedWidth(t *testing.T) {
|
||||||
statuses := []bool{true, true}
|
statuses := []bool{true, true}
|
||||||
got := heartbeatSparkline(statuses, 5)
|
got := heartbeatSparkline(statuses, 5, "")
|
||||||
if !strings.Contains(got, "···") {
|
if !strings.Contains(got, "···") {
|
||||||
t.Errorf("should have dot padding for width > data, got %q", got)
|
t.Errorf("should have dot padding for width > data, got %q", got)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ func (m Model) computeLayout() tableLayout {
|
|||||||
if sparkW < 15 {
|
if sparkW < 15 {
|
||||||
sparkW = 15
|
sparkW = 15
|
||||||
}
|
}
|
||||||
|
if sparkW > 62 {
|
||||||
|
sparkW = 62
|
||||||
|
}
|
||||||
|
|
||||||
widths[1] = nameW
|
widths[1] = nameW
|
||||||
widths[6] = sparkW
|
widths[6] = sparkW
|
||||||
@@ -110,6 +113,9 @@ func (m Model) viewSitesTab() string {
|
|||||||
if sparkWidth < 8 {
|
if sparkWidth < 8 {
|
||||||
sparkWidth = 8
|
sparkWidth = 8
|
||||||
}
|
}
|
||||||
|
if sparkWidth > 60 {
|
||||||
|
sparkWidth = 60
|
||||||
|
}
|
||||||
|
|
||||||
var groupRows map[int]bool
|
var groupRows map[int]bool
|
||||||
return m.renderTable(
|
return m.renderTable(
|
||||||
@@ -120,6 +126,13 @@ func (m Model) viewSitesTab() string {
|
|||||||
var rows [][]string
|
var rows [][]string
|
||||||
for i := start; i < end; i++ {
|
for i := start; i < end; i++ {
|
||||||
site := m.sites[i]
|
site := m.sites[i]
|
||||||
|
rowIdx := i - start
|
||||||
|
var rowBg lipgloss.Color
|
||||||
|
if i == m.cursor {
|
||||||
|
rowBg = m.theme.SelectedBg
|
||||||
|
} else if rowIdx%2 == 1 {
|
||||||
|
rowBg = m.theme.ZebraBg
|
||||||
|
}
|
||||||
|
|
||||||
if site.Type == "group" {
|
if site.Type == "group" {
|
||||||
groupRows[i-start] = true
|
groupRows[i-start] = true
|
||||||
@@ -131,7 +144,7 @@ func (m Model) viewSitesTab() string {
|
|||||||
fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)),
|
fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)),
|
||||||
subtleStyle.Render("—"),
|
subtleStyle.Render("—"),
|
||||||
m.groupUptime(site.ID),
|
m.groupUptime(site.ID),
|
||||||
m.groupSparkline(site.ID, sparkWidth),
|
m.groupSparkline(site.ID, sparkWidth, rowBg),
|
||||||
subtleStyle.Render("-"),
|
subtleStyle.Render("-"),
|
||||||
subtleStyle.Render("—"),
|
subtleStyle.Render("—"),
|
||||||
})
|
})
|
||||||
@@ -166,9 +179,9 @@ func (m Model) viewSitesTab() string {
|
|||||||
hist, _ := m.engine.GetHistory(site.ID)
|
hist, _ := m.engine.GetHistory(site.ID)
|
||||||
var spark string
|
var spark string
|
||||||
if site.Type == "push" {
|
if site.Type == "push" {
|
||||||
spark = heartbeatSparkline(hist.Statuses, sparkWidth)
|
spark = heartbeatSparkline(hist.Statuses, sparkWidth, rowBg)
|
||||||
} else {
|
} else {
|
||||||
spark = latencySparkline(hist.Latencies, hist.Statuses, sparkWidth)
|
spark = latencySparkline(hist.Latencies, hist.Statuses, sparkWidth, rowBg)
|
||||||
}
|
}
|
||||||
|
|
||||||
rows = append(rows, []string{
|
rows = append(rows, []string{
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ var (
|
|||||||
titleStyle lipgloss.Style
|
titleStyle lipgloss.Style
|
||||||
activeTab lipgloss.Style
|
activeTab lipgloss.Style
|
||||||
inactiveTab lipgloss.Style
|
inactiveTab lipgloss.Style
|
||||||
|
|
||||||
|
sparkSuccess string
|
||||||
|
sparkWarning string
|
||||||
|
sparkDanger string
|
||||||
)
|
)
|
||||||
|
|
||||||
func applyTheme(t Theme) {
|
func applyTheme(t Theme) {
|
||||||
@@ -33,6 +37,11 @@ func applyTheme(t Theme) {
|
|||||||
warnStyle = lipgloss.NewStyle().Foreground(t.Warning)
|
warnStyle = lipgloss.NewStyle().Foreground(t.Warning)
|
||||||
staleStyle = lipgloss.NewStyle().Foreground(t.Stale)
|
staleStyle = lipgloss.NewStyle().Foreground(t.Stale)
|
||||||
dangerStyle = lipgloss.NewStyle().Foreground(t.Danger)
|
dangerStyle = lipgloss.NewStyle().Foreground(t.Danger)
|
||||||
|
|
||||||
|
sparkSuccess = string(t.Success)
|
||||||
|
sparkWarning = string(t.Warning)
|
||||||
|
sparkDanger = string(t.Danger)
|
||||||
|
|
||||||
titleStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
|
titleStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
|
||||||
activeTab = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, true, false).BorderForeground(t.Accent).Foreground(t.Accent).Bold(true).Padding(0, 1)
|
activeTab = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, true, false).BorderForeground(t.Accent).Foreground(t.Accent).Bold(true).Padding(0, 1)
|
||||||
inactiveTab = lipgloss.NewStyle().Padding(0, 1).Foreground(t.Muted)
|
inactiveTab = lipgloss.NewStyle().Padding(0, 1).Foreground(t.Muted)
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ func (m Model) viewDetailPanel() string {
|
|||||||
b.WriteString(m.divider() + "\n")
|
b.WriteString(m.divider() + "\n")
|
||||||
const sparkWidth = 40
|
const sparkWidth = 40
|
||||||
if site.Type == "push" {
|
if site.Type == "push" {
|
||||||
b.WriteString(" " + heartbeatSparkline(hist.Statuses, sparkWidth))
|
b.WriteString(" " + heartbeatSparkline(hist.Statuses, sparkWidth, ""))
|
||||||
if len(hist.Statuses) > 0 {
|
if len(hist.Statuses) > 0 {
|
||||||
up := 0
|
up := 0
|
||||||
for _, s := range hist.Statuses {
|
for _, s := range hist.Statuses {
|
||||||
@@ -216,7 +216,7 @@ func (m Model) viewDetailPanel() string {
|
|||||||
up, len(hist.Statuses))
|
up, len(hist.Statuses))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
b.WriteString(" " + latencySparkline(hist.Latencies, hist.Statuses, sparkWidth))
|
b.WriteString(" " + latencySparkline(hist.Latencies, hist.Statuses, sparkWidth, ""))
|
||||||
var minL, maxL, total time.Duration
|
var minL, maxL, total time.Duration
|
||||||
count := 0
|
count := 0
|
||||||
for i, l := range hist.Latencies {
|
for i, l := range hist.Latencies {
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ func computeHistoryStats(changes []models.StateChange) historyStats {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var stateChangeChars = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
|
||||||
|
|
||||||
func stateChangeSparkline(changes []models.StateChange, width int) string {
|
func stateChangeSparkline(changes []models.StateChange, width int) string {
|
||||||
if len(changes) < 2 || width < 4 {
|
if len(changes) < 2 || width < 4 {
|
||||||
return ""
|
return ""
|
||||||
@@ -91,7 +93,7 @@ func stateChangeSparkline(changes []models.StateChange, width int) string {
|
|||||||
if idx > 7 {
|
if idx > 7 {
|
||||||
idx = 7
|
idx = 7
|
||||||
}
|
}
|
||||||
ch := string(sparkChars[idx])
|
ch := string(stateChangeChars[idx])
|
||||||
switch {
|
switch {
|
||||||
case v >= 3:
|
case v >= 3:
|
||||||
sb.WriteString(dangerStyle.Render(ch))
|
sb.WriteString(dangerStyle.Render(ch))
|
||||||
|
|||||||
Reference in New Issue
Block a user