fix(tui): group selection highlight, layout constants, group history graphs
Group rows now show selection background when navigated to. Layout chrome extracted to named constants to prevent viewport drift. Groups display aggregate history as dot sparkline (●) distinct from site bar sparklines, with uptime computed from active children only. Paused and maintenance children excluded from group aggregates.
This commit is contained in:
@@ -132,6 +132,93 @@ func heartbeatSparkline(statuses []bool, width int) string {
|
|||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m Model) groupSparkline(groupID int, width int) string {
|
||||||
|
allSites := m.engine.GetAllSites()
|
||||||
|
var childStatuses [][]bool
|
||||||
|
for _, s := range allSites {
|
||||||
|
if s.ParentID == groupID && !s.Paused && !m.isMonitorInMaintenance(s.ID) {
|
||||||
|
hist, _ := m.engine.GetHistory(s.ID)
|
||||||
|
if len(hist.Statuses) > 0 {
|
||||||
|
childStatuses = append(childStatuses, hist.Statuses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(childStatuses) == 0 {
|
||||||
|
return subtleStyle.Render(strings.Repeat("·", width))
|
||||||
|
}
|
||||||
|
|
||||||
|
maxLen := 0
|
||||||
|
for _, s := range childStatuses {
|
||||||
|
if len(s) > maxLen {
|
||||||
|
maxLen = len(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if maxLen > width {
|
||||||
|
maxLen = width
|
||||||
|
}
|
||||||
|
|
||||||
|
aggregated := make([]bool, maxLen)
|
||||||
|
for i := 0; i < maxLen; i++ {
|
||||||
|
allUp := true
|
||||||
|
for _, statuses := range childStatuses {
|
||||||
|
idx := len(statuses) - maxLen + i
|
||||||
|
if idx >= 0 && !statuses[idx] {
|
||||||
|
allUp = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
aggregated[i] = allUp
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
if remaining := width - len(aggregated); remaining > 0 {
|
||||||
|
sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining)))
|
||||||
|
}
|
||||||
|
for _, up := range aggregated {
|
||||||
|
if up {
|
||||||
|
sb.WriteString(specialStyle.Render("●"))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(dangerStyle.Render("●"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) groupUptime(groupID int) string {
|
||||||
|
allSites := m.engine.GetAllSites()
|
||||||
|
var allStatuses [][]bool
|
||||||
|
for _, s := range allSites {
|
||||||
|
if s.ParentID == groupID && !s.Paused && !m.isMonitorInMaintenance(s.ID) {
|
||||||
|
hist, _ := m.engine.GetHistory(s.ID)
|
||||||
|
if len(hist.Statuses) > 0 {
|
||||||
|
allStatuses = append(allStatuses, hist.Statuses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(allStatuses) == 0 {
|
||||||
|
return subtleStyle.Render("—")
|
||||||
|
}
|
||||||
|
total, up := 0, 0
|
||||||
|
for _, statuses := range allStatuses {
|
||||||
|
for _, s := range statuses {
|
||||||
|
total++
|
||||||
|
if s {
|
||||||
|
up++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmtUptime(func() []bool {
|
||||||
|
out := make([]bool, total)
|
||||||
|
idx := 0
|
||||||
|
for _, statuses := range allStatuses {
|
||||||
|
copy(out[idx:], statuses)
|
||||||
|
idx += len(statuses)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}())
|
||||||
|
}
|
||||||
|
|
||||||
func fmtLatency(d time.Duration) string {
|
func fmtLatency(d time.Duration) string {
|
||||||
ms := d.Milliseconds()
|
ms := d.Milliseconds()
|
||||||
if ms == 0 {
|
if ms == 0 {
|
||||||
@@ -227,7 +314,7 @@ func fmtStatus(status string, paused bool, inMaint bool) string {
|
|||||||
func (m Model) dynamicWidths() (nameW, sparkW int) {
|
func (m Model) dynamicWidths() (nameW, sparkW int) {
|
||||||
fixed := 6 + 10 + 10 + 8 + 8 + 7 + 9 // #, TYPE, STATUS, LATENCY, UPTIME, SSL, RETRY
|
fixed := 6 + 10 + 10 + 8 + 8 + 7 + 9 // #, TYPE, STATUS, LATENCY, UPTIME, SSL, RETRY
|
||||||
overhead := 30 // cell padding + borders
|
overhead := 30 // cell padding + borders
|
||||||
avail := m.termWidth - 6 - fixed - overhead
|
avail := m.termWidth - chromePadH - 2 - fixed - overhead
|
||||||
if avail < 30 {
|
if avail < 30 {
|
||||||
avail = 30
|
avail = 30
|
||||||
}
|
}
|
||||||
@@ -285,8 +372,8 @@ func (m Model) viewSitesTab() string {
|
|||||||
"group",
|
"group",
|
||||||
fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)),
|
fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)),
|
||||||
subtleStyle.Render("—"),
|
subtleStyle.Render("—"),
|
||||||
subtleStyle.Render("—"),
|
m.groupUptime(site.ID),
|
||||||
subtleStyle.Render(strings.Repeat("·", sparkWidth)),
|
m.groupSparkline(site.ID, sparkWidth),
|
||||||
subtleStyle.Render("-"),
|
subtleStyle.Render("-"),
|
||||||
subtleStyle.Render("—"),
|
subtleStyle.Render("—"),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
|
|||||||
selectedVisual := m.cursor - m.tableOffset
|
selectedVisual := m.cursor - m.tableOffset
|
||||||
rows := buildRows(m.tableOffset, end)
|
rows := buildRows(m.tableOffset, end)
|
||||||
|
|
||||||
tableWidth := m.termWidth - 6
|
tableWidth := m.termWidth - chromePadH - 2
|
||||||
if tableWidth < 40 {
|
if tableWidth < 40 {
|
||||||
tableWidth = 40
|
tableWidth = 40
|
||||||
}
|
}
|
||||||
@@ -53,16 +53,21 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
|
|||||||
if row == table.HeaderRow {
|
if row == table.HeaderRow {
|
||||||
return tableHeaderStyle
|
return tableHeaderStyle
|
||||||
}
|
}
|
||||||
|
isSelected := row == selectedVisual
|
||||||
if styleOverride != nil {
|
if styleOverride != nil {
|
||||||
if s := styleOverride(row, col); s != nil {
|
if s := styleOverride(row, col); s != nil {
|
||||||
if col < len(colWidths) && colWidths[col] > 0 {
|
style := *s
|
||||||
return s.Width(colWidths[col])
|
if isSelected {
|
||||||
|
style = tableSelectedStyle.Foreground(s.GetForeground())
|
||||||
}
|
}
|
||||||
return *s
|
if col < len(colWidths) && colWidths[col] > 0 {
|
||||||
|
style = style.Width(colWidths[col])
|
||||||
|
}
|
||||||
|
return style
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
base := tableCellStyle
|
base := tableCellStyle
|
||||||
if row == selectedVisual {
|
if isSelected {
|
||||||
base = tableSelectedStyle
|
base = tableSelectedStyle
|
||||||
}
|
}
|
||||||
if col < len(colWidths) && colWidths[col] > 0 {
|
if col < len(colWidths) && colWidths[col] > 0 {
|
||||||
|
|||||||
+14
-5
@@ -31,6 +31,16 @@ var (
|
|||||||
|
|
||||||
var pulseFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
var pulseFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||||
|
|
||||||
|
const (
|
||||||
|
chromePadV = 2 // outer Padding(1,2): 1 top + 1 bottom
|
||||||
|
chromePadH = 4 // outer Padding(1,2): 2 left + 2 right
|
||||||
|
chromeHeader = 1 // tab bar line
|
||||||
|
chromeGaps = 2 // "\n" separators: before content + before footer
|
||||||
|
chromeFooter = 2 // footer: "\n" prefix + text line
|
||||||
|
chromeTable = 3 // renderTable "\n" prefix + top border + header + bottom border (lipgloss collapses two into three rendered lines)
|
||||||
|
chromeBase = chromePadV + chromeHeader + chromeGaps + chromeFooter + chromeTable
|
||||||
|
)
|
||||||
|
|
||||||
type sessionState int
|
type sessionState int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -198,17 +208,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.termWidth = msg.Width
|
m.termWidth = msg.Width
|
||||||
m.termHeight = msg.Height
|
m.termHeight = msg.Height
|
||||||
// Chrome: 1 top pad + 1 tabs + 2 newlines + 3 table borders + 1 table header + 1 footer + 1 bottom pad = 10
|
chrome := chromeBase
|
||||||
chrome := 10
|
if m.filterMode || m.filterText != "" {
|
||||||
if m.filterText != "" {
|
|
||||||
chrome++
|
chrome++
|
||||||
}
|
}
|
||||||
m.maxTableRows = msg.Height - chrome
|
m.maxTableRows = msg.Height - chrome
|
||||||
if m.maxTableRows < 1 {
|
if m.maxTableRows < 1 {
|
||||||
m.maxTableRows = 1
|
m.maxTableRows = 1
|
||||||
}
|
}
|
||||||
m.logViewport.Width = msg.Width - 4
|
m.logViewport.Width = msg.Width - chromePadH
|
||||||
m.logViewport.Height = msg.Height - 8
|
m.logViewport.Height = msg.Height - (chromePadV + chromeHeader + chromeGaps + chromeFooter)
|
||||||
return m, tea.ClearScreen
|
return m, tea.ClearScreen
|
||||||
|
|
||||||
case time.Time:
|
case time.Time:
|
||||||
|
|||||||
Reference in New Issue
Block a user