Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/beta.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ jobs:
# Delete existing beta release and tag if present
gh release delete beta --yes 2>/dev/null || true
git push origin :refs/tags/beta 2>/dev/null || true
git tag -d beta 2>/dev/null || true

# Create new pre-release
gh release create beta \
Expand Down
117 changes: 105 additions & 12 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,19 +83,49 @@ func getEnvInt(key string, def int64) int64 {
return def
}

type period struct {
up bool
start time.Time
count int
}

type stats struct {
count int
failures int
total time.Duration
min time.Duration
max time.Duration
last time.Duration
blocks []string // individual blocks for proper width handling
col int // current column position on bar line
lastPrinted int // last block index printed
braille bool // braille mode enabled
pendingRTT time.Duration // pending RTT for braille pairing (-1 = failure, 0 = none)
hasPending bool // whether there's a pending RTT
count int
failures int
total time.Duration
min time.Duration
max time.Duration
last time.Duration
blocks []string // individual blocks for proper width handling
col int // current column position on bar line
lastPrinted int // last block index printed
braille bool // braille mode enabled
pendingRTT time.Duration // pending RTT for braille pairing (-1 = failure, 0 = none)
hasPending bool // whether there's a pending RTT
periods []period // completed UP/DOWN periods
currentPeriod *period // active period (nil until first request)
}

func recordPeriod(s *stats, up bool) {
now := time.Now()
if s.currentPeriod == nil {
s.currentPeriod = &period{up: up, start: now, count: 1}
return
}
if s.currentPeriod.up == up {
s.currentPeriod.count++
return
}
// State flipped — close current period and start new one
s.periods = append(s.periods, *s.currentPeriod)
s.currentPeriod = &period{up: up, start: now, count: 1}
}

func closePeriods(s *stats) {
if s.currentPeriod != nil {
s.periods = append(s.periods, *s.currentPeriod)
s.currentPeriod = nil
}
}

func main() {
Expand Down Expand Up @@ -204,6 +234,7 @@ func main() {
signal.Notify(sigCh, os.Interrupt)
go func() {
<-sigCh
closePeriods(s)
if !*silent {
printFinal(displayURL, s)
}
Expand Down Expand Up @@ -280,6 +311,7 @@ func main() {
if err != nil {
s.failures++
consecutiveFailures++
recordPeriod(s, false)
if s.braille {
if s.hasPending {
// Pair with pending: pending=left, failure=right
Expand Down Expand Up @@ -344,6 +376,7 @@ func main() {
} else {
s.count++
consecutiveFailures = 0 // Reset on success
recordPeriod(s, true)
s.total += rtt
s.last = rtt
if rtt < s.min {
Expand All @@ -369,6 +402,7 @@ func main() {
printDisplay(s)
requestNum++
if *count > 0 && requestNum >= *count {
closePeriods(s)
if !*silent {
printFinal(displayURL, s)
}
Expand Down Expand Up @@ -676,6 +710,20 @@ func printStats(s *stats, width int) {
fmt.Printf("\n%s%s%s%s\033[%dG", col0, clearLn, statsText, up, s.col+1)
}

func fmtDuration(d time.Duration) string {
d = d.Truncate(time.Second)
h := int(d.Hours())
m := int(d.Minutes()) % 60
sec := int(d.Seconds()) % 60
if h > 0 {
return fmt.Sprintf("%dh%02dm", h, m)
}
if m > 0 {
return fmt.Sprintf("%dm%02ds", m, sec)
}
return fmt.Sprintf("%ds", sec)
}

func printFinal(url string, s *stats) {
total := s.count + s.failures
var lossPct int
Expand All @@ -698,4 +746,49 @@ func printFinal(url string, s *stats) {
if s.count > 0 {
fmt.Printf("round-trip min/avg/max = %d/%d/%d ms\n", minMs, avg.Milliseconds(), s.max.Milliseconds())
}

if s.failures > 0 && len(s.periods) > 0 {
fmt.Printf("%stimeline:%s\n", gray, reset)
periods := s.periods
truncated := 0
if len(periods) > 20 {
truncated = len(periods) - 10
head := periods[:5]
tail := periods[len(periods)-5:]
periods = append(head, tail...)
}
now := time.Now()
for i, p := range periods {
ts := p.start.Format("15:04:05")
var label, color, detail string
if p.up {
label = "UP "
color = green
detail = fmt.Sprintf("(%d ok)", p.count)
} else {
label = "DOWN"
color = red
detail = fmt.Sprintf("(%d lost)", p.count)
}
isLast := i == len(periods)-1 && truncated == 0 ||
i == len(periods)-1 && truncated > 0

if isLast {
fmt.Printf(" %s %s%s%s %s← active%s\n", ts, color, label, reset, gray, reset)
} else {
var end time.Time
if i+1 < len(periods) {
end = periods[i+1].start
} else {
end = now
}
dur := fmtDuration(end.Sub(p.start))
fmt.Printf(" %s %s%s%s %6s %s\n", ts, color, label, reset, dur, detail)
}

if truncated > 0 && i == 4 {
fmt.Printf(" %s... %d more ...%s\n", gray, truncated, reset)
}
}
}
}
148 changes: 148 additions & 0 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,154 @@ func TestTruncateToWidth_TableDriven(t *testing.T) {
}
}

// =============================================================================
// Test: fmtDuration function tests
// =============================================================================

func TestFmtDuration_TableDriven(t *testing.T) {
tests := []struct {
name string
d time.Duration
want string
}{
{"zero", 0, "0s"},
{"one-second", time.Second, "1s"},
{"seconds", 45 * time.Second, "45s"},
{"one-minute", time.Minute, "1m00s"},
{"minutes-seconds", 2*time.Minute + 13*time.Second, "2m13s"},
{"exact-minutes", 15 * time.Minute, "15m00s"},
{"one-hour", time.Hour, "1h00m"},
{"hours-minutes", time.Hour + 2*time.Minute, "1h02m"},
{"large", 3*time.Hour + 45*time.Minute, "3h45m"},
{"subsecond-truncated", 500 * time.Millisecond, "0s"},
{"59-seconds", 59 * time.Second, "59s"},
{"60-seconds", 60 * time.Second, "1m00s"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := fmtDuration(tc.d)
if got != tc.want {
t.Errorf("fmtDuration(%v) = %q; want %q", tc.d, got, tc.want)
}
})
}
}

// =============================================================================
// Test: recordPeriod function tests
// =============================================================================

func TestRecordPeriod(t *testing.T) {
t.Run("first-success-creates-up", func(t *testing.T) {
s := &stats{}
recordPeriod(s, true)
if s.currentPeriod == nil {
t.Fatal("currentPeriod is nil")
}
if !s.currentPeriod.up {
t.Error("expected UP period")
}
if s.currentPeriod.count != 1 {
t.Errorf("count = %d; want 1", s.currentPeriod.count)
}
if len(s.periods) != 0 {
t.Errorf("periods = %d; want 0", len(s.periods))
}
})

t.Run("first-failure-creates-down", func(t *testing.T) {
s := &stats{}
recordPeriod(s, false)
if s.currentPeriod == nil {
t.Fatal("currentPeriod is nil")
}
if s.currentPeriod.up {
t.Error("expected DOWN period")
}
if s.currentPeriod.count != 1 {
t.Errorf("count = %d; want 1", s.currentPeriod.count)
}
})

t.Run("consecutive-same-increments", func(t *testing.T) {
s := &stats{}
recordPeriod(s, true)
recordPeriod(s, true)
recordPeriod(s, true)
if s.currentPeriod.count != 3 {
t.Errorf("count = %d; want 3", s.currentPeriod.count)
}
if len(s.periods) != 0 {
t.Errorf("periods = %d; want 0", len(s.periods))
}
})

t.Run("transition-closes-period", func(t *testing.T) {
s := &stats{}
recordPeriod(s, true)
recordPeriod(s, true)
recordPeriod(s, false) // transition
if len(s.periods) != 1 {
t.Fatalf("periods = %d; want 1", len(s.periods))
}
if !s.periods[0].up {
t.Error("closed period should be UP")
}
if s.periods[0].count != 2 {
t.Errorf("closed period count = %d; want 2", s.periods[0].count)
}
if s.currentPeriod.up {
t.Error("current period should be DOWN")
}
if s.currentPeriod.count != 1 {
t.Errorf("current count = %d; want 1", s.currentPeriod.count)
}
})

t.Run("multiple-transitions", func(t *testing.T) {
s := &stats{}
recordPeriod(s, true) // UP
recordPeriod(s, true) // UP (2)
recordPeriod(s, false) // DOWN
recordPeriod(s, false) // DOWN (2)
recordPeriod(s, false) // DOWN (3)
recordPeriod(s, true) // UP again
if len(s.periods) != 2 {
t.Fatalf("periods = %d; want 2", len(s.periods))
}
if !s.periods[0].up || s.periods[0].count != 2 {
t.Errorf("period[0]: up=%v count=%d; want up=true count=2", s.periods[0].up, s.periods[0].count)
}
if s.periods[1].up || s.periods[1].count != 3 {
t.Errorf("period[1]: up=%v count=%d; want up=false count=3", s.periods[1].up, s.periods[1].count)
}
if !s.currentPeriod.up || s.currentPeriod.count != 1 {
t.Errorf("current: up=%v count=%d; want up=true count=1", s.currentPeriod.up, s.currentPeriod.count)
}
})

t.Run("close-periods", func(t *testing.T) {
s := &stats{}
recordPeriod(s, true)
recordPeriod(s, false)
closePeriods(s)
if len(s.periods) != 2 {
t.Fatalf("periods = %d; want 2", len(s.periods))
}
if s.currentPeriod != nil {
t.Error("currentPeriod should be nil after close")
}
})

t.Run("close-nil-noop", func(t *testing.T) {
s := &stats{}
closePeriods(s) // should not panic
if len(s.periods) != 0 {
t.Errorf("periods = %d; want 0", len(s.periods))
}
})
}

func TestGetEnvInt_TableDriven(t *testing.T) {
tests := []struct {
name string
Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/connection-timeline/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-23
49 changes: 49 additions & 0 deletions openspec/changes/connection-timeline/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
## Context

hp currently tracks total failures (`s.failures`) as a single counter. When a request fails, the counter increments and a red `!` block is appended. The final summary shows total requests, ok, failed, and loss percentage — but no temporal information about when failures occurred or how they clustered.

The `stats` struct (main.go:86-99) holds all monitoring state. The main loop (main.go:278-383) processes each request result. `printFinal()` (main.go:679-701) renders the exit summary.

## Goals / Non-Goals

**Goals:**
- Track alternating UP (successful) and DOWN (failure) periods with start times and request counts
- Display a color-coded timeline in the final summary showing the connectivity pattern
- Make intermittent outage diagnosis trivial ("drops every ~2min after recovery")
- Zero visual noise when there are no failures

**Non-Goals:**
- No new CLI flags — timeline appears automatically when relevant
- No live/inline timeline display during monitoring — summary only
- No persistence or export of timeline data
- No per-request timestamp tracking — only period boundaries

## Decisions

### 1. Period-based model over event log

Track `[]period` (alternating up/down) rather than individual request timestamps. This is O(transitions) not O(requests), keeps memory bounded for long sessions, and directly represents the pattern users care about.

Alternative: Per-request timestamps — rejected because it's wasteful (most sessions are thousands of requests) and requires post-processing to find patterns.

### 2. Inline state tracking in main loop

Add period transition logic directly in the main loop (after line 280 for failures, after line 344 for successes) rather than a separate goroutine or observer. The main loop already has the success/fail distinction and runs sequentially — no need for additional complexity.

### 3. Close active period before printFinal

Before calling `printFinal()` (at Ctrl+C handler line 208 and count-exit line 373), close the current period by appending it to `s.periods`. This ensures the last period appears in the timeline. The last period gets a special "active" label since it was ongoing at exit.

### 4. Timeline only shown when failures > 0

If all requests succeed, there's only one UP period — showing "timeline: UP 5m (300 ok)" adds no value. Only print the timeline section when `s.failures > 0`, keeping output clean for healthy targets.

### 5. Compact duration formatting

Use a custom `fmtDuration()` returning strings like "7s", "2m13s", "1h02m" rather than Go's default `Duration.String()` which produces "2m13.000000s". The compact format is more readable in the fixed-width timeline layout.

## Risks / Trade-offs

- [Minimal memory overhead] → Each period is ~25 bytes (bool + Time + int). Even with thousands of transitions, this is negligible. No mitigation needed.
- [Timeline output length for very flaky connections] → A connection flapping every second could produce hundreds of periods. → Mitigation: Cap displayed periods (e.g., first 5 + last 5 with "... N more ..." in between) if list exceeds a threshold.
- [Time zone display] → Use local time (time.Now()) which matches user expectations. Users running across time zones can set TZ env var.
Loading
Loading