Merge pull request 'feat(tui): DOWN-first sort, health pulse, filter, and sparkline fixes' (#11) from feat/tui-polish-2 into develop
This commit was merged in pull request #11.
This commit is contained in:
@@ -2,7 +2,7 @@ package monitor
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
const maxHistoryLen = 30
|
const maxHistoryLen = 60
|
||||||
|
|
||||||
type SiteHistory struct {
|
type SiteHistory struct {
|
||||||
Latencies []time.Duration
|
Latencies []time.Duration
|
||||||
|
|||||||
+31
-20
@@ -61,6 +61,9 @@ func latencySparkline(latencies []time.Duration, width int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
if remaining := width - len(samples); remaining > 0 {
|
||||||
|
sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining)))
|
||||||
|
}
|
||||||
spread := maxL - minL
|
spread := maxL - minL
|
||||||
for _, l := range samples {
|
for _, l := range samples {
|
||||||
idx := 0
|
idx := 0
|
||||||
@@ -80,10 +83,6 @@ func latencySparkline(latencies []time.Duration, width int) string {
|
|||||||
sb.WriteString(dangerStyle.Render(ch))
|
sb.WriteString(dangerStyle.Render(ch))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if remaining := width - len(samples); remaining > 0 {
|
|
||||||
sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining)))
|
|
||||||
}
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +97,9 @@ func heartbeatSparkline(statuses []bool, width int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
if remaining := width - len(samples); remaining > 0 {
|
||||||
|
sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining)))
|
||||||
|
}
|
||||||
for _, up := range samples {
|
for _, up := range samples {
|
||||||
if up {
|
if up {
|
||||||
sb.WriteString(specialStyle.Render("▁"))
|
sb.WriteString(specialStyle.Render("▁"))
|
||||||
@@ -105,10 +107,6 @@ func heartbeatSparkline(statuses []bool, width int) string {
|
|||||||
sb.WriteString(dangerStyle.Render("█"))
|
sb.WriteString(dangerStyle.Render("█"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if remaining := width - len(samples); remaining > 0 {
|
|
||||||
sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining)))
|
|
||||||
}
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,19 +193,31 @@ func fmtStatus(status string, paused bool) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) nameWidth() int {
|
func (m Model) dynamicWidths() (nameW, sparkW int) {
|
||||||
w := m.termWidth - 105
|
fixed := 6 + 10 + 10 + 8 + 8 + 7 + 9 // #, TYPE, STATUS, LATENCY, UPTIME, SSL, RETRY
|
||||||
if w < 13 {
|
overhead := 30 // cell padding + borders
|
||||||
w = 13
|
avail := m.termWidth - 6 - fixed - overhead
|
||||||
|
if avail < 30 {
|
||||||
|
avail = 30
|
||||||
}
|
}
|
||||||
if w > 40 {
|
nameW = avail / 2
|
||||||
w = 40
|
sparkW = avail - nameW - 2 // -2 for spark column padding
|
||||||
|
if nameW < 13 {
|
||||||
|
nameW = 13
|
||||||
}
|
}
|
||||||
return w
|
if nameW > 40 {
|
||||||
|
nameW = 40
|
||||||
|
}
|
||||||
|
if sparkW < 10 {
|
||||||
|
sparkW = 10
|
||||||
|
}
|
||||||
|
if sparkW > 60 {
|
||||||
|
sparkW = 60
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) viewSitesTab() string {
|
func (m Model) viewSitesTab() string {
|
||||||
const sparkWidth = 20
|
|
||||||
|
|
||||||
if len(m.sites) == 0 {
|
if len(m.sites) == 0 {
|
||||||
welcome := lipgloss.NewStyle().
|
welcome := lipgloss.NewStyle().
|
||||||
@@ -222,7 +232,8 @@ func (m Model) viewSitesTab() string {
|
|||||||
return "\n" + welcome
|
return "\n" + welcome
|
||||||
}
|
}
|
||||||
|
|
||||||
colWidths := []int{6, 0, 10, 10, 8, 8, sparkWidth + 4, 7, 9}
|
nameW, sparkWidth := m.dynamicWidths()
|
||||||
|
colWidths := []int{6, 0, 10, 10, 8, 8, sparkWidth + 2, 7, 9}
|
||||||
|
|
||||||
var groupRows map[int]bool
|
var groupRows map[int]bool
|
||||||
return m.renderTable(
|
return m.renderTable(
|
||||||
@@ -242,7 +253,7 @@ func (m Model) viewSitesTab() string {
|
|||||||
}
|
}
|
||||||
rows = append(rows, []string{
|
rows = append(rows, []string{
|
||||||
strconv.Itoa(i + 1),
|
strconv.Itoa(i + 1),
|
||||||
m.zones.Mark(fmt.Sprintf("site-%d", i), arrow+" "+limitStr(site.Name, m.nameWidth()-2)),
|
m.zones.Mark(fmt.Sprintf("site-%d", i), arrow+" "+limitStr(site.Name, nameW-2)),
|
||||||
"group",
|
"group",
|
||||||
fmtStatus(site.Status, site.Paused),
|
fmtStatus(site.Status, site.Paused),
|
||||||
subtleStyle.Render("—"),
|
subtleStyle.Render("—"),
|
||||||
@@ -260,9 +271,9 @@ func (m Model) viewSitesTab() string {
|
|||||||
if i+1 >= len(m.sites) || m.sites[i+1].ParentID != site.ParentID {
|
if i+1 >= len(m.sites) || m.sites[i+1].ParentID != site.ParentID {
|
||||||
prefix = "└"
|
prefix = "└"
|
||||||
}
|
}
|
||||||
name = prefix + " " + limitStr(name, m.nameWidth()-2)
|
name = prefix + " " + limitStr(name, nameW-2)
|
||||||
} else {
|
} else {
|
||||||
name = limitStr(name, m.nameWidth())
|
name = limitStr(name, nameW)
|
||||||
}
|
}
|
||||||
|
|
||||||
hist, _ := m.engine.GetHistory(site.ID)
|
hist, _ := m.engine.GetHistory(site.ID)
|
||||||
|
|||||||
+88
-3
@@ -82,6 +82,9 @@ type Model struct {
|
|||||||
alerts []models.AlertConfig
|
alerts []models.AlertConfig
|
||||||
users []models.User
|
users []models.User
|
||||||
nodes []models.ProbeNode
|
nodes []models.ProbeNode
|
||||||
|
|
||||||
|
filterMode bool
|
||||||
|
filterText string
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model {
|
func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model {
|
||||||
@@ -247,6 +250,36 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, tea.ClearScreen
|
return m, tea.ClearScreen
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.filterMode {
|
||||||
|
switch msg.String() {
|
||||||
|
case "esc":
|
||||||
|
m.filterMode = false
|
||||||
|
m.filterText = ""
|
||||||
|
m.cursor = 0
|
||||||
|
m.tableOffset = 0
|
||||||
|
m.refreshData()
|
||||||
|
case "enter":
|
||||||
|
m.filterMode = false
|
||||||
|
case "backspace":
|
||||||
|
if len(m.filterText) > 0 {
|
||||||
|
m.filterText = m.filterText[:len(m.filterText)-1]
|
||||||
|
m.cursor = 0
|
||||||
|
m.tableOffset = 0
|
||||||
|
m.refreshData()
|
||||||
|
}
|
||||||
|
case "ctrl+c":
|
||||||
|
return m, tea.Quit
|
||||||
|
default:
|
||||||
|
if len(msg.String()) == 1 {
|
||||||
|
m.filterText += msg.String()
|
||||||
|
m.cursor = 0
|
||||||
|
m.tableOffset = 0
|
||||||
|
m.refreshData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
switch m.state {
|
switch m.state {
|
||||||
case stateDetail:
|
case stateDetail:
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
@@ -260,6 +293,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "q":
|
case "q":
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
|
case "/":
|
||||||
|
if m.currentTab == 0 {
|
||||||
|
m.filterMode = true
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
case "tab":
|
case "tab":
|
||||||
m.switchTab(m.currentTab + 1)
|
m.switchTab(m.currentTab + 1)
|
||||||
case "pgup", "pgdown":
|
case "pgup", "pgdown":
|
||||||
@@ -471,9 +509,11 @@ func (m *Model) refreshData() {
|
|||||||
for pid := range children {
|
for pid := range children {
|
||||||
c := children[pid]
|
c := children[pid]
|
||||||
sort.Slice(c, func(i, j int) bool { return c[i].ID < c[j].ID })
|
sort.Slice(c, func(i, j int) bool { return c[i].ID < c[j].ID })
|
||||||
|
sort.SliceStable(c, func(i, j int) bool { return siteOrder(c[i]) < siteOrder(c[j]) })
|
||||||
children[pid] = c
|
children[pid] = c
|
||||||
}
|
}
|
||||||
sort.Slice(ungrouped, func(i, j int) bool { return ungrouped[i].ID < ungrouped[j].ID })
|
sort.Slice(ungrouped, func(i, j int) bool { return ungrouped[i].ID < ungrouped[j].ID })
|
||||||
|
sort.SliceStable(ungrouped, func(i, j int) bool { return siteOrder(ungrouped[i]) < siteOrder(ungrouped[j]) })
|
||||||
|
|
||||||
var ordered []models.Site
|
var ordered []models.Site
|
||||||
for _, g := range groups {
|
for _, g := range groups {
|
||||||
@@ -483,6 +523,16 @@ func (m *Model) refreshData() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ordered = append(ordered, ungrouped...)
|
ordered = append(ordered, ungrouped...)
|
||||||
|
if m.filterText != "" {
|
||||||
|
var filtered []models.Site
|
||||||
|
needle := strings.ToLower(m.filterText)
|
||||||
|
for _, s := range ordered {
|
||||||
|
if strings.Contains(strings.ToLower(s.Name), needle) {
|
||||||
|
filtered = append(filtered, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ordered = filtered
|
||||||
|
}
|
||||||
m.sites = ordered
|
m.sites = ordered
|
||||||
if alerts, err := m.store.GetAllAlerts(); err == nil {
|
if alerts, err := m.store.GetAllAlerts(); err == nil {
|
||||||
m.alerts = alerts
|
m.alerts = alerts
|
||||||
@@ -536,7 +586,19 @@ func (m Model) pulseIndicator() string {
|
|||||||
if brightness > 255 {
|
if brightness > 255 {
|
||||||
brightness = 255
|
brightness = 255
|
||||||
}
|
}
|
||||||
color := fmt.Sprintf("#%02x%02x%02x", brightness/3, brightness, brightness/2)
|
hasDown := false
|
||||||
|
for _, s := range m.sites {
|
||||||
|
if !s.Paused && (s.Status == "DOWN" || s.Status == "SSL EXP") {
|
||||||
|
hasDown = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var color string
|
||||||
|
if hasDown {
|
||||||
|
color = fmt.Sprintf("#%02x%02x%02x", brightness, brightness/4, brightness/4)
|
||||||
|
} else {
|
||||||
|
color = fmt.Sprintf("#%02x%02x%02x", brightness/3, brightness, brightness/2)
|
||||||
|
}
|
||||||
return lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(pulseFrames[frame])
|
return lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(pulseFrames[frame])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -674,16 +736,25 @@ func (m Model) viewDashboard() string {
|
|||||||
}
|
}
|
||||||
statusLine := strings.Join(statusParts, subtleStyle.Render(" · "))
|
statusLine := strings.Join(statusParts, subtleStyle.Render(" · "))
|
||||||
|
|
||||||
|
var footer string
|
||||||
|
if m.filterMode {
|
||||||
|
cursor := lipgloss.NewStyle().Foreground(lipgloss.Color("#7D56F4")).Render("│")
|
||||||
|
footer = "\n" + titleStyle.Render("/") + " " + m.filterText + cursor + " " + subtleStyle.Render("[Enter]Apply [Esc]Clear")
|
||||||
|
} else {
|
||||||
var keys string
|
var keys string
|
||||||
switch m.currentTab {
|
switch m.currentTab {
|
||||||
case 0:
|
case 0:
|
||||||
keys = "[n]New [e]Edit [i]Info [d]Del [p]Pause [Tab]Switch [q]Quit"
|
keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [Tab]Switch [q]Quit"
|
||||||
case 4:
|
case 4:
|
||||||
keys = "[n]Add [d]Revoke [Tab]Switch [q]Quit"
|
keys = "[n]Add [d]Revoke [Tab]Switch [q]Quit"
|
||||||
default:
|
default:
|
||||||
keys = "[Tab]Switch [q]Quit"
|
keys = "[Tab]Switch [q]Quit"
|
||||||
}
|
}
|
||||||
footer := "\n" + statusLine + " " + subtleStyle.Render(keys)
|
footer = "\n" + statusLine + " " + subtleStyle.Render(keys)
|
||||||
|
if m.filterText != "" && m.currentTab == 0 {
|
||||||
|
footer = "\n" + subtleStyle.Render(fmt.Sprintf("filter: %s", m.filterText)) + " " + statusLine + " " + subtleStyle.Render(keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
s := lipgloss.NewStyle().Padding(1, 2)
|
s := lipgloss.NewStyle().Padding(1, 2)
|
||||||
if m.termHeight > 0 {
|
if m.termHeight > 0 {
|
||||||
s = s.MaxHeight(m.termHeight)
|
s = s.MaxHeight(m.termHeight)
|
||||||
@@ -691,6 +762,20 @@ func (m Model) viewDashboard() string {
|
|||||||
return s.Render(header + "\n" + content + "\n" + footer)
|
return s.Render(header + "\n" + content + "\n" + footer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func siteOrder(s models.Site) int {
|
||||||
|
if s.Paused {
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
switch s.Status {
|
||||||
|
case "DOWN", "SSL EXP":
|
||||||
|
return 0
|
||||||
|
case "PENDING":
|
||||||
|
return 2
|
||||||
|
default:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func limitStr(text string, max int) string {
|
func limitStr(text string, max int) string {
|
||||||
if len(text) > max {
|
if len(text) > max {
|
||||||
return text[:max-3] + "..."
|
return text[:max-3] + "..."
|
||||||
|
|||||||
Reference in New Issue
Block a user