@@ -0,0 +1,289 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"git.cer.sh/axodouble/quptime/internal/transport"
|
||||
)
|
||||
|
||||
// tabModel is the small surface every tab implements. Tabs share the
|
||||
// same Update/View shape so the parent can dispatch generically.
|
||||
type tabModel interface {
|
||||
Update(tea.Msg) (tabModel, tea.Cmd)
|
||||
View() string
|
||||
SetSize(width, height int)
|
||||
// Selected returns the row identifier for the row under the cursor,
|
||||
// or "" if the table is empty. For peers/nodes this is a NodeID;
|
||||
// for checks it's a CheckID; for alerts it's an AlertID.
|
||||
Selected() string
|
||||
// SelectedName returns a human-friendly label for the selected row
|
||||
// (used in confirm dialogs).
|
||||
SelectedName() string
|
||||
}
|
||||
|
||||
// peersTab — read-only view of cluster membership.
|
||||
type peersTab struct {
|
||||
tbl table.Model
|
||||
}
|
||||
|
||||
func newPeersTab() *peersTab {
|
||||
cols := []table.Column{
|
||||
{Title: "NODE_ID", Width: 38},
|
||||
{Title: "ADVERTISE", Width: 28},
|
||||
{Title: "LIVE", Width: 8},
|
||||
{Title: "LAST SEEN", Width: 22},
|
||||
}
|
||||
t := table.New(table.WithColumns(cols), table.WithFocused(true))
|
||||
t.SetStyles(tableStyles())
|
||||
return &peersTab{tbl: t}
|
||||
}
|
||||
|
||||
func (p *peersTab) Update(msg tea.Msg) (tabModel, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
p.tbl, cmd = p.tbl.Update(msg)
|
||||
return p, cmd
|
||||
}
|
||||
|
||||
func (p *peersTab) View() string { return p.tbl.View() }
|
||||
|
||||
func (p *peersTab) SetSize(w, h int) {
|
||||
p.tbl.SetWidth(w)
|
||||
p.tbl.SetHeight(h)
|
||||
}
|
||||
|
||||
func (p *peersTab) Selected() string {
|
||||
r := p.tbl.SelectedRow()
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return r[0]
|
||||
}
|
||||
|
||||
func (p *peersTab) SelectedName() string { return p.Selected() }
|
||||
|
||||
func (p *peersTab) Refresh(st transport.StatusResponse, selfID string) {
|
||||
rows := make([]table.Row, 0, len(st.Peers))
|
||||
for _, peer := range st.Peers {
|
||||
lastSeen := "-"
|
||||
if !peer.LastSeen.IsZero() {
|
||||
lastSeen = peer.LastSeen.UTC().Format(time.RFC3339)
|
||||
}
|
||||
id := peer.NodeID
|
||||
if peer.NodeID == selfID {
|
||||
id = "* " + peer.NodeID
|
||||
}
|
||||
rows = append(rows, table.Row{id, peer.Advertise, livenessText(peer.Live), lastSeen})
|
||||
}
|
||||
p.tbl.SetRows(rows)
|
||||
}
|
||||
|
||||
// checksTab — checks with state and effective alerts.
|
||||
type checksTab struct {
|
||||
tbl table.Model
|
||||
}
|
||||
|
||||
func newChecksTab() *checksTab {
|
||||
cols := []table.Column{
|
||||
{Title: "ID", Width: 38},
|
||||
{Title: "NAME", Width: 18},
|
||||
{Title: "STATE", Width: 12},
|
||||
{Title: "OK/TOTAL", Width: 10},
|
||||
{Title: "ALERTS", Width: 24},
|
||||
{Title: "DETAIL", Width: 40},
|
||||
}
|
||||
t := table.New(table.WithColumns(cols), table.WithFocused(true))
|
||||
t.SetStyles(tableStyles())
|
||||
return &checksTab{tbl: t}
|
||||
}
|
||||
|
||||
func (c *checksTab) Update(msg tea.Msg) (tabModel, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
c.tbl, cmd = c.tbl.Update(msg)
|
||||
return c, cmd
|
||||
}
|
||||
|
||||
func (c *checksTab) View() string { return c.tbl.View() }
|
||||
|
||||
func (c *checksTab) SetSize(w, h int) {
|
||||
c.tbl.SetWidth(w)
|
||||
c.tbl.SetHeight(h)
|
||||
}
|
||||
|
||||
func (c *checksTab) Selected() string {
|
||||
r := c.tbl.SelectedRow()
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return r[0]
|
||||
}
|
||||
|
||||
func (c *checksTab) SelectedName() string {
|
||||
r := c.tbl.SelectedRow()
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return r[1]
|
||||
}
|
||||
|
||||
func (c *checksTab) Refresh(st transport.StatusResponse) {
|
||||
rows := make([]table.Row, 0, len(st.Checks))
|
||||
for _, ch := range st.Checks {
|
||||
okTotal := lipgloss.NewStyle().Render("0/0")
|
||||
if ch.Total > 0 {
|
||||
okTotal = lipgloss.NewStyle().Render(itoa(ch.OKCount) + "/" + itoa(ch.Total))
|
||||
}
|
||||
alerts := strings.Join(ch.Alerts, ",")
|
||||
if alerts == "" {
|
||||
alerts = "-"
|
||||
}
|
||||
rows = append(rows, table.Row{
|
||||
ch.CheckID, ch.Name, renderState(ch.State), okTotal, alerts, truncate(ch.Detail, 38),
|
||||
})
|
||||
}
|
||||
c.tbl.SetRows(rows)
|
||||
}
|
||||
|
||||
// alertsTab — configured notification channels.
|
||||
type alertsTab struct {
|
||||
tbl table.Model
|
||||
alerts []alertRow
|
||||
}
|
||||
|
||||
type alertRow struct {
|
||||
ID string
|
||||
Name string
|
||||
Type string
|
||||
Default bool
|
||||
HasTmpl bool
|
||||
Endpoint string
|
||||
}
|
||||
|
||||
func newAlertsTab() *alertsTab {
|
||||
cols := []table.Column{
|
||||
{Title: "ID", Width: 38},
|
||||
{Title: "NAME", Width: 16},
|
||||
{Title: "TYPE", Width: 10},
|
||||
{Title: "DEFAULT", Width: 8},
|
||||
{Title: "CUSTOM-MSG", Width: 11},
|
||||
{Title: "ENDPOINT", Width: 36},
|
||||
}
|
||||
t := table.New(table.WithColumns(cols), table.WithFocused(true))
|
||||
t.SetStyles(tableStyles())
|
||||
return &alertsTab{tbl: t}
|
||||
}
|
||||
|
||||
func (a *alertsTab) Update(msg tea.Msg) (tabModel, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
a.tbl, cmd = a.tbl.Update(msg)
|
||||
return a, cmd
|
||||
}
|
||||
|
||||
func (a *alertsTab) View() string { return a.tbl.View() }
|
||||
|
||||
func (a *alertsTab) SetSize(w, h int) {
|
||||
a.tbl.SetWidth(w)
|
||||
a.tbl.SetHeight(h)
|
||||
}
|
||||
|
||||
func (a *alertsTab) Selected() string {
|
||||
r := a.tbl.SelectedRow()
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return r[0]
|
||||
}
|
||||
|
||||
func (a *alertsTab) SelectedName() string {
|
||||
r := a.tbl.SelectedRow()
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return r[1]
|
||||
}
|
||||
|
||||
// SelectedAlert returns the row metadata for the cursor, so the parent
|
||||
// can flip the default flag without a roundtrip.
|
||||
func (a *alertsTab) SelectedAlert() *alertRow {
|
||||
idx := a.tbl.Cursor()
|
||||
if idx < 0 || idx >= len(a.alerts) {
|
||||
return nil
|
||||
}
|
||||
cp := a.alerts[idx]
|
||||
return &cp
|
||||
}
|
||||
|
||||
func (a *alertsTab) Refresh(alerts []alertRow) {
|
||||
a.alerts = alerts
|
||||
rows := make([]table.Row, 0, len(alerts))
|
||||
for _, r := range alerts {
|
||||
def := "-"
|
||||
if r.Default {
|
||||
def = "yes"
|
||||
}
|
||||
tmpl := "-"
|
||||
if r.HasTmpl {
|
||||
tmpl = "yes"
|
||||
}
|
||||
rows = append(rows, table.Row{r.ID, r.Name, r.Type, def, tmpl, truncate(r.Endpoint, 34)})
|
||||
}
|
||||
a.tbl.SetRows(rows)
|
||||
}
|
||||
|
||||
func tableStyles() table.Styles {
|
||||
s := table.DefaultStyles()
|
||||
s.Header = s.Header.
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderForeground(colorBorder).
|
||||
BorderBottom(true).
|
||||
Bold(true)
|
||||
s.Selected = s.Selected.
|
||||
Foreground(lipgloss.Color("230")).
|
||||
Background(colorAccent).
|
||||
Bold(true)
|
||||
return s
|
||||
}
|
||||
|
||||
func livenessText(live bool) string {
|
||||
if live {
|
||||
return "live"
|
||||
}
|
||||
return "dead"
|
||||
}
|
||||
|
||||
func itoa(i int) string {
|
||||
// avoid pulling fmt in the hot path of refresh
|
||||
if i == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := i < 0
|
||||
if neg {
|
||||
i = -i
|
||||
}
|
||||
var buf [20]byte
|
||||
pos := len(buf)
|
||||
for i > 0 {
|
||||
pos--
|
||||
buf[pos] = byte('0' + i%10)
|
||||
i /= 10
|
||||
}
|
||||
if neg {
|
||||
pos--
|
||||
buf[pos] = '-'
|
||||
}
|
||||
return string(buf[pos:])
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if max <= 0 || len(s) <= max {
|
||||
return s
|
||||
}
|
||||
if max < 4 {
|
||||
return s[:max]
|
||||
}
|
||||
return s[:max-1] + "…"
|
||||
}
|
||||
Reference in New Issue
Block a user