290 lines
6.1 KiB
Go
290 lines
6.1 KiB
Go
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] + "…"
|
|
}
|