@@ -0,0 +1,87 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"git.cer.sh/axodouble/quptime/internal/config"
|
||||
"git.cer.sh/axodouble/quptime/internal/daemon"
|
||||
)
|
||||
|
||||
// callDaemon is the same protocol the cli package uses against the
|
||||
// local control socket — duplicated here so the TUI doesn't have to
|
||||
// import the cli package (which would cycle).
|
||||
func callDaemon(ctx context.Context, method string, body any) (json.RawMessage, error) {
|
||||
var rawBody json.RawMessage
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rawBody = b
|
||||
}
|
||||
req := daemon.CtrlRequest{Method: method, Body: rawBody}
|
||||
reqBytes, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sock := config.SocketPath()
|
||||
d := net.Dialer{}
|
||||
conn, err := d.DialContext(ctx, "unix", sock)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial daemon socket %s: %w", sock, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if dl, ok := ctx.Deadline(); ok {
|
||||
_ = conn.SetDeadline(dl)
|
||||
} else {
|
||||
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
|
||||
}
|
||||
|
||||
if err := writeFrame(conn, reqBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBytes, err := readFrame(conn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp daemon.CtrlResponse
|
||||
if err := json.Unmarshal(respBytes, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Error != "" {
|
||||
return nil, errors.New(resp.Error)
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
func writeFrame(w io.Writer, body []byte) error {
|
||||
var hdr [4]byte
|
||||
binary.BigEndian.PutUint32(hdr[:], uint32(len(body)))
|
||||
if _, err := w.Write(hdr[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := w.Write(body)
|
||||
return err
|
||||
}
|
||||
|
||||
func readFrame(r io.Reader) ([]byte, error) {
|
||||
var hdr [4]byte
|
||||
if _, err := io.ReadFull(r, hdr[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := binary.BigEndian.Uint32(hdr[:])
|
||||
buf := make([]byte, n)
|
||||
if _, err := io.ReadFull(r, buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.cer.sh/axodouble/quptime/internal/config"
|
||||
"git.cer.sh/axodouble/quptime/internal/daemon"
|
||||
"git.cer.sh/axodouble/quptime/internal/transport"
|
||||
)
|
||||
|
||||
// modalDone tells the parent the modal is finished. Flash, when set,
|
||||
// is shown as a one-shot status line; level controls the color.
|
||||
type modalDone struct {
|
||||
flash string
|
||||
level flashLevel
|
||||
}
|
||||
|
||||
type flashLevel int
|
||||
|
||||
const (
|
||||
flashInfo flashLevel = iota
|
||||
flashWarn
|
||||
flashError
|
||||
)
|
||||
|
||||
// modal is implemented by every pop-up form/dialog. Parent passes all
|
||||
// input to the modal's Update; when the modal completes it returns
|
||||
// modalDone as a tea.Msg via its tea.Cmd.
|
||||
type modal interface {
|
||||
Update(tea.Msg) (modal, tea.Cmd)
|
||||
View() string
|
||||
Title() string
|
||||
}
|
||||
|
||||
func modalDoneCmd(flash string, level flashLevel) tea.Cmd {
|
||||
return func() tea.Msg { return modalDone{flash: flash, level: level} }
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// Generic field-based form (used by check/alert/node add flows).
|
||||
// =============================================================
|
||||
|
||||
type formField struct {
|
||||
label string
|
||||
input textinput.Model
|
||||
required bool
|
||||
hint string
|
||||
}
|
||||
|
||||
type form struct {
|
||||
title string
|
||||
fields []formField
|
||||
cursor int
|
||||
busy bool
|
||||
err string
|
||||
|
||||
submit func(values []string) tea.Cmd
|
||||
}
|
||||
|
||||
func newForm(title string, fields []formField, submit func([]string) tea.Cmd) *form {
|
||||
for i := range fields {
|
||||
fields[i].input.Prompt = ""
|
||||
fields[i].input.CharLimit = 256
|
||||
if i == 0 {
|
||||
fields[i].input.Focus()
|
||||
} else {
|
||||
fields[i].input.Blur()
|
||||
}
|
||||
}
|
||||
return &form{title: title, fields: fields, submit: submit}
|
||||
}
|
||||
|
||||
func textField(label, hint string, required bool) formField {
|
||||
ti := textinput.New()
|
||||
ti.Width = 40
|
||||
ti.Placeholder = hint
|
||||
return formField{label: label, hint: hint, required: required, input: ti}
|
||||
}
|
||||
|
||||
func passwordField(label, hint string) formField {
|
||||
ti := textinput.New()
|
||||
ti.Width = 40
|
||||
ti.Placeholder = hint
|
||||
ti.EchoMode = textinput.EchoPassword
|
||||
ti.EchoCharacter = '•'
|
||||
return formField{label: label, hint: hint, input: ti}
|
||||
}
|
||||
|
||||
func (f *form) Title() string { return f.title }
|
||||
|
||||
func (f *form) View() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%s\n\n", titleStyle.Render(f.title))
|
||||
for i, fld := range f.fields {
|
||||
marker := " "
|
||||
labelStyle := subtleStyle
|
||||
if i == f.cursor {
|
||||
marker = "▸ "
|
||||
labelStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true)
|
||||
}
|
||||
fmt.Fprintf(&b, "%s%s\n", marker, labelStyle.Render(fld.label))
|
||||
fmt.Fprintf(&b, " %s\n", fld.input.View())
|
||||
if i == f.cursor && fld.hint != "" {
|
||||
fmt.Fprintf(&b, " %s\n", helpStyle.Render(fld.hint))
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
if f.err != "" {
|
||||
fmt.Fprintf(&b, "%s\n\n", flashErrorStyle.Render("error: "+f.err))
|
||||
}
|
||||
if f.busy {
|
||||
fmt.Fprintf(&b, "%s\n", flashWarnStyle.Render("working…"))
|
||||
} else {
|
||||
fmt.Fprintf(&b, "%s\n", helpStyle.Render("↑↓ field enter next/submit esc cancel"))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (f *form) Update(msg tea.Msg) (modal, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case formSubmitErr:
|
||||
f.busy = false
|
||||
f.err = string(msg)
|
||||
return f, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
return f, modalDoneCmd("", flashInfo)
|
||||
case "tab", "down":
|
||||
f.advance(1)
|
||||
return f, nil
|
||||
case "shift+tab", "up":
|
||||
f.advance(-1)
|
||||
return f, nil
|
||||
case "enter":
|
||||
if f.busy {
|
||||
return f, nil
|
||||
}
|
||||
if f.cursor < len(f.fields)-1 {
|
||||
f.advance(1)
|
||||
return f, nil
|
||||
}
|
||||
vals := make([]string, len(f.fields))
|
||||
for i, fld := range f.fields {
|
||||
vals[i] = fld.input.Value()
|
||||
}
|
||||
for i, fld := range f.fields {
|
||||
if fld.required && strings.TrimSpace(vals[i]) == "" {
|
||||
f.err = fld.label + " is required"
|
||||
f.cursor = i
|
||||
f.focusOnly(i)
|
||||
return f, nil
|
||||
}
|
||||
}
|
||||
f.busy = true
|
||||
f.err = ""
|
||||
return f, f.submit(vals)
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
f.fields[f.cursor].input, cmd = f.fields[f.cursor].input.Update(msg)
|
||||
return f, cmd
|
||||
}
|
||||
|
||||
func (f *form) advance(delta int) {
|
||||
n := len(f.fields)
|
||||
if n == 0 {
|
||||
return
|
||||
}
|
||||
f.cursor = (f.cursor + delta + n) % n
|
||||
f.focusOnly(f.cursor)
|
||||
}
|
||||
|
||||
func (f *form) focusOnly(i int) {
|
||||
for j := range f.fields {
|
||||
if j == i {
|
||||
f.fields[j].input.Focus()
|
||||
} else {
|
||||
f.fields[j].input.Blur()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// formSubmitErr is a tea.Msg the submit cmd returns to surface an
|
||||
// error inline without closing the form.
|
||||
type formSubmitErr string
|
||||
|
||||
func submitErr(err error) tea.Cmd {
|
||||
return func() tea.Msg { return formSubmitErr(err.Error()) }
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// Specific forms.
|
||||
// =============================================================
|
||||
|
||||
func newAddCheckForm(checkType config.CheckType) *form {
|
||||
fields := []formField{
|
||||
textField("Name", "human-friendly identifier", true),
|
||||
textField("Target", targetHint(checkType), true),
|
||||
textField("Interval", "e.g. 30s, 1m", false),
|
||||
textField("Timeout", "e.g. 10s", false),
|
||||
textField("Alerts", "comma-separated alert IDs/names (optional)", false),
|
||||
}
|
||||
if checkType == config.CheckHTTP {
|
||||
fields = append(fields,
|
||||
textField("Expect status", "e.g. 200 (HTTP only)", false),
|
||||
textField("Body match", "substring required (HTTP only)", false),
|
||||
)
|
||||
}
|
||||
return newForm("Add "+strings.ToUpper(string(checkType))+" check", fields, func(vals []string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ch := config.Check{
|
||||
ID: uuid.NewString(),
|
||||
Name: strings.TrimSpace(vals[0]),
|
||||
Type: checkType,
|
||||
Target: strings.TrimSpace(vals[1]),
|
||||
Interval: parseDurationOr(vals[2], 30*time.Second),
|
||||
Timeout: parseDurationOr(vals[3], 10*time.Second),
|
||||
}
|
||||
if a := strings.TrimSpace(vals[4]); a != "" {
|
||||
for _, p := range strings.Split(a, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
ch.AlertIDs = append(ch.AlertIDs, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
if checkType == config.CheckHTTP {
|
||||
ch.ExpectStatus = atoiOr(vals[5], 200)
|
||||
ch.BodyMatch = strings.TrimSpace(vals[6])
|
||||
}
|
||||
if err := mutateAdd(transport.MutationAddCheck, ch); err != nil {
|
||||
return formSubmitErr(err.Error())
|
||||
}
|
||||
return modalDone{flash: "added check " + ch.Name, level: flashInfo}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func newAddDiscordForm() *form {
|
||||
fields := []formField{
|
||||
textField("Name", "human-friendly identifier", true),
|
||||
textField("Webhook URL", "https://discord.com/api/webhooks/...", true),
|
||||
textField("Default", "yes/no — attach to every check automatically", false),
|
||||
textField("Body template", "leave empty for default formatting", false),
|
||||
}
|
||||
return newForm("Add Discord alert", fields, func(vals []string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
a := config.Alert{
|
||||
ID: uuid.NewString(),
|
||||
Name: strings.TrimSpace(vals[0]),
|
||||
Type: config.AlertDiscord,
|
||||
DiscordWebhook: strings.TrimSpace(vals[1]),
|
||||
Default: parseBool(vals[2]),
|
||||
BodyTemplate: vals[3],
|
||||
}
|
||||
if err := mutateAdd(transport.MutationAddAlert, a); err != nil {
|
||||
return formSubmitErr(err.Error())
|
||||
}
|
||||
return modalDone{flash: "added discord alert " + a.Name, level: flashInfo}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func newAddSMTPForm() *form {
|
||||
fields := []formField{
|
||||
textField("Name", "human-friendly identifier", true),
|
||||
textField("Host", "smtp.example.com", true),
|
||||
textField("Port", "default 587", false),
|
||||
textField("User", "leave empty for anonymous", false),
|
||||
passwordField("Password", "smtp auth password"),
|
||||
textField("From", "envelope From address", true),
|
||||
textField("To", "comma-separated recipient addresses", true),
|
||||
textField("StartTLS", "yes/no — default yes", false),
|
||||
textField("Default", "yes/no — attach to every check", false),
|
||||
textField("Subject template", "optional", false),
|
||||
textField("Body template", "optional", false),
|
||||
}
|
||||
return newForm("Add SMTP alert", fields, func(vals []string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
to := strings.Split(strings.TrimSpace(vals[6]), ",")
|
||||
for i := range to {
|
||||
to[i] = strings.TrimSpace(to[i])
|
||||
}
|
||||
a := config.Alert{
|
||||
ID: uuid.NewString(),
|
||||
Name: strings.TrimSpace(vals[0]),
|
||||
Type: config.AlertSMTP,
|
||||
SMTPHost: strings.TrimSpace(vals[1]),
|
||||
SMTPPort: atoiOr(vals[2], 587),
|
||||
SMTPUser: strings.TrimSpace(vals[3]),
|
||||
SMTPPassword: vals[4],
|
||||
SMTPFrom: strings.TrimSpace(vals[5]),
|
||||
SMTPTo: to,
|
||||
SMTPStartTLS: parseBoolOr(vals[7], true),
|
||||
Default: parseBool(vals[8]),
|
||||
SubjectTemplate: vals[9],
|
||||
BodyTemplate: vals[10],
|
||||
}
|
||||
if err := mutateAdd(transport.MutationAddAlert, a); err != nil {
|
||||
return formSubmitErr(err.Error())
|
||||
}
|
||||
return modalDone{flash: "added smtp alert " + a.Name, level: flashInfo}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func newAddNodeForm() *form {
|
||||
fields := []formField{
|
||||
textField("Address", "host:9901 of the peer to invite", true),
|
||||
}
|
||||
return newForm("Add node (TOFU)", fields, func(vals []string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
addr := strings.TrimSpace(vals[0])
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
raw, err := callDaemon(ctx, daemon.CtrlNodeProbe, daemon.NodeProbeBody{Address: addr})
|
||||
if err != nil {
|
||||
return formSubmitErr(fmt.Sprintf("probe: %v", err))
|
||||
}
|
||||
var probe daemon.NodeProbeResult
|
||||
if err := json.Unmarshal(raw, &probe); err != nil {
|
||||
return formSubmitErr(err.Error())
|
||||
}
|
||||
// auto-accept the fingerprint we just observed. The cluster
|
||||
// secret check on the remote side already prevents random
|
||||
// hosts from being trusted.
|
||||
raw, err = callDaemon(ctx, daemon.CtrlNodeAdd, daemon.NodeAddBody{
|
||||
Address: addr,
|
||||
Fingerprint: probe.Fingerprint,
|
||||
})
|
||||
if err != nil {
|
||||
return formSubmitErr(fmt.Sprintf("add: %v", err))
|
||||
}
|
||||
var res daemon.NodeAddResult
|
||||
_ = json.Unmarshal(raw, &res)
|
||||
return modalDone{
|
||||
flash: fmt.Sprintf("added node %s — cluster version %d", res.NodeID, res.Version),
|
||||
level: flashInfo,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// Pickers and confirmations.
|
||||
// =============================================================
|
||||
|
||||
type pickerOption struct {
|
||||
label string
|
||||
hint string
|
||||
choose func() modal
|
||||
act func() tea.Cmd // if non-nil, picker returns this cmd directly instead of opening another modal
|
||||
}
|
||||
|
||||
type picker struct {
|
||||
title string
|
||||
options []pickerOption
|
||||
cursor int
|
||||
}
|
||||
|
||||
func newPicker(title string, options []pickerOption) *picker {
|
||||
return &picker{title: title, options: options}
|
||||
}
|
||||
|
||||
func (p *picker) Title() string { return p.title }
|
||||
|
||||
func (p *picker) View() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%s\n\n", titleStyle.Render(p.title))
|
||||
for i, o := range p.options {
|
||||
marker := " "
|
||||
style := subtleStyle
|
||||
if i == p.cursor {
|
||||
marker = "▸ "
|
||||
style = lipgloss.NewStyle().Foreground(colorAccent).Bold(true)
|
||||
}
|
||||
fmt.Fprintf(&b, "%s%s\n", marker, style.Render(o.label))
|
||||
if o.hint != "" {
|
||||
fmt.Fprintf(&b, " %s\n", helpStyle.Render(o.hint))
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&b, "\n%s\n", helpStyle.Render("↑↓ select enter pick esc cancel"))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (p *picker) Update(msg tea.Msg) (modal, tea.Cmd) {
|
||||
km, ok := msg.(tea.KeyMsg)
|
||||
if !ok {
|
||||
return p, nil
|
||||
}
|
||||
switch km.String() {
|
||||
case "esc":
|
||||
return p, modalDoneCmd("", flashInfo)
|
||||
case "up", "k":
|
||||
if p.cursor > 0 {
|
||||
p.cursor--
|
||||
}
|
||||
return p, nil
|
||||
case "down", "j":
|
||||
if p.cursor < len(p.options)-1 {
|
||||
p.cursor++
|
||||
}
|
||||
return p, nil
|
||||
case "enter":
|
||||
if p.cursor < 0 || p.cursor >= len(p.options) {
|
||||
return p, nil
|
||||
}
|
||||
opt := p.options[p.cursor]
|
||||
if opt.act != nil {
|
||||
return p, opt.act()
|
||||
}
|
||||
if opt.choose != nil {
|
||||
return opt.choose(), nil
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// confirm asks yes/no and runs onConfirm if the user picks yes.
|
||||
type confirm struct {
|
||||
prompt string
|
||||
onConfirm func() tea.Cmd
|
||||
choice int // 0=no, 1=yes
|
||||
busy bool
|
||||
err string
|
||||
}
|
||||
|
||||
func newConfirm(prompt string, onConfirm func() tea.Cmd) *confirm {
|
||||
return &confirm{prompt: prompt, onConfirm: onConfirm}
|
||||
}
|
||||
|
||||
func (c *confirm) Title() string { return "Confirm" }
|
||||
|
||||
func (c *confirm) View() string {
|
||||
noStyle, yesStyle := subtleStyle, subtleStyle
|
||||
if c.choice == 0 {
|
||||
noStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true)
|
||||
} else {
|
||||
yesStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true)
|
||||
}
|
||||
body := fmt.Sprintf("%s\n\n [%s] [%s]\n",
|
||||
c.prompt,
|
||||
noStyle.Render("No"),
|
||||
yesStyle.Render("Yes"),
|
||||
)
|
||||
if c.err != "" {
|
||||
body += "\n" + flashErrorStyle.Render("error: "+c.err) + "\n"
|
||||
}
|
||||
if c.busy {
|
||||
body += "\n" + flashWarnStyle.Render("working…") + "\n"
|
||||
} else {
|
||||
body += "\n" + helpStyle.Render("←→ or h/l select enter confirm esc cancel")
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (c *confirm) Update(msg tea.Msg) (modal, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case formSubmitErr:
|
||||
c.busy = false
|
||||
c.err = string(msg)
|
||||
return c, nil
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
return c, modalDoneCmd("", flashInfo)
|
||||
case "left", "right", "h", "l", "tab":
|
||||
c.choice = 1 - c.choice
|
||||
return c, nil
|
||||
case "y", "Y":
|
||||
c.choice = 1
|
||||
return c.commit()
|
||||
case "n", "N":
|
||||
return c, modalDoneCmd("cancelled", flashInfo)
|
||||
case "enter":
|
||||
if c.choice == 1 {
|
||||
return c.commit()
|
||||
}
|
||||
return c, modalDoneCmd("cancelled", flashInfo)
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *confirm) commit() (modal, tea.Cmd) {
|
||||
if c.busy {
|
||||
return c, nil
|
||||
}
|
||||
c.busy = true
|
||||
c.err = ""
|
||||
return c, c.onConfirm()
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// Helpers shared by submit closures.
|
||||
// =============================================================
|
||||
|
||||
func mutateAdd(kind transport.MutationKind, payload any) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := daemon.MutateBody{Kind: kind, Payload: raw}
|
||||
_, err = callDaemon(ctx, daemon.CtrlMutate, body)
|
||||
return err
|
||||
}
|
||||
|
||||
func mutateRemove(kind transport.MutationKind, idOrName string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
raw, err := json.Marshal(idOrName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := daemon.MutateBody{Kind: kind, Payload: raw}
|
||||
_, err = callDaemon(ctx, daemon.CtrlMutate, body)
|
||||
return err
|
||||
}
|
||||
|
||||
func targetHint(t config.CheckType) string {
|
||||
switch t {
|
||||
case config.CheckHTTP:
|
||||
return "https://example.com/health"
|
||||
case config.CheckTCP:
|
||||
return "db.internal:5432"
|
||||
case config.CheckICMP:
|
||||
return "10.0.0.1"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseDurationOr(s string, fallback time.Duration) time.Duration {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return fallback
|
||||
}
|
||||
d, err := time.ParseDuration(s)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func atoiOr(s string, fallback int) int {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return fallback
|
||||
}
|
||||
var n int
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return fallback
|
||||
}
|
||||
n = n*10 + int(r-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func parseBool(s string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "yes", "y", "true", "t", "on", "1":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseBoolOr(s string, fallback bool) bool {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
if s == "" {
|
||||
return fallback
|
||||
}
|
||||
switch s {
|
||||
case "yes", "y", "true", "t", "on", "1":
|
||||
return true
|
||||
case "no", "n", "false", "f", "off", "0":
|
||||
return false
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package tui
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
var (
|
||||
colorBorder = lipgloss.Color("63") // soft purple
|
||||
colorAccent = lipgloss.Color("212") // pink
|
||||
colorMuted = lipgloss.Color("241") // gray
|
||||
colorSuccess = lipgloss.Color("42") // green
|
||||
colorWarn = lipgloss.Color("214") // orange
|
||||
colorError = lipgloss.Color("196") // red
|
||||
colorTabActive = lipgloss.Color("212")
|
||||
colorTabIdle = lipgloss.Color("241")
|
||||
)
|
||||
|
||||
var (
|
||||
titleStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("230")).
|
||||
Background(colorAccent).
|
||||
Bold(true).
|
||||
Padding(0, 1)
|
||||
|
||||
subtleStyle = lipgloss.NewStyle().Foreground(colorMuted)
|
||||
|
||||
headerStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(colorBorder).
|
||||
Padding(0, 1)
|
||||
|
||||
tabActiveStyle = lipgloss.NewStyle().
|
||||
Foreground(colorTabActive).
|
||||
Bold(true).
|
||||
Underline(true).
|
||||
Padding(0, 1)
|
||||
|
||||
tabIdleStyle = lipgloss.NewStyle().
|
||||
Foreground(colorTabIdle).
|
||||
Padding(0, 1)
|
||||
|
||||
bodyStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(colorBorder).
|
||||
Padding(0, 1)
|
||||
|
||||
helpStyle = lipgloss.NewStyle().Foreground(colorMuted)
|
||||
|
||||
flashInfoStyle = lipgloss.NewStyle().Foreground(colorSuccess).Bold(true)
|
||||
flashErrorStyle = lipgloss.NewStyle().Foreground(colorError).Bold(true)
|
||||
flashWarnStyle = lipgloss.NewStyle().Foreground(colorWarn).Bold(true)
|
||||
|
||||
modalStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.DoubleBorder()).
|
||||
BorderForeground(colorAccent).
|
||||
Padding(1, 2)
|
||||
|
||||
stateUpStyle = lipgloss.NewStyle().Foreground(colorSuccess).Bold(true)
|
||||
stateDownStyle = lipgloss.NewStyle().Foreground(colorError).Bold(true)
|
||||
stateUnknownStyle = lipgloss.NewStyle().Foreground(colorMuted)
|
||||
)
|
||||
|
||||
func renderState(s string) string {
|
||||
switch s {
|
||||
case "up":
|
||||
return stateUpStyle.Render("● up")
|
||||
case "down":
|
||||
return stateDownStyle.Render("● down")
|
||||
default:
|
||||
return stateUnknownStyle.Render("○ unknown")
|
||||
}
|
||||
}
|
||||
|
||||
func renderLive(live bool) string {
|
||||
if live {
|
||||
return stateUpStyle.Render("● live")
|
||||
}
|
||||
return stateDownStyle.Render("● dead")
|
||||
}
|
||||
@@ -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] + "…"
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
// Package tui implements the interactive overview/control surface
|
||||
// reachable via `qu tui`. It is a thin bubbletea client over the same
|
||||
// unix control socket the CLI uses; nothing here talks to peers
|
||||
// directly.
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"git.cer.sh/axodouble/quptime/internal/config"
|
||||
"git.cer.sh/axodouble/quptime/internal/daemon"
|
||||
"git.cer.sh/axodouble/quptime/internal/transport"
|
||||
)
|
||||
|
||||
const refreshInterval = 2 * time.Second
|
||||
|
||||
// Run starts the bubbletea program. Blocks until the user quits.
|
||||
func Run() error {
|
||||
m := initialModel()
|
||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||
_, err := p.Run()
|
||||
return err
|
||||
}
|
||||
|
||||
type tabIndex int
|
||||
|
||||
const (
|
||||
tabPeers tabIndex = iota
|
||||
tabChecks
|
||||
tabAlerts
|
||||
)
|
||||
|
||||
var tabNames = []string{"Peers", "Checks", "Alerts"}
|
||||
|
||||
type model struct {
|
||||
width, height int
|
||||
|
||||
status transport.StatusResponse
|
||||
statusLoaded bool
|
||||
statusErr string
|
||||
|
||||
// Cached alerts come from cluster.yaml directly (the daemon status
|
||||
// only ships per-check effective alert names). We need full Alert
|
||||
// records to render the alerts tab and to support default-toggle.
|
||||
alerts []config.Alert
|
||||
|
||||
active tabIndex
|
||||
peers *peersTab
|
||||
checks *checksTab
|
||||
alertsT *alertsTab
|
||||
|
||||
modal modal
|
||||
|
||||
flash string
|
||||
flashLevel flashLevel
|
||||
flashUntil time.Time
|
||||
}
|
||||
|
||||
func initialModel() model {
|
||||
return model{
|
||||
peers: newPeersTab(),
|
||||
checks: newChecksTab(),
|
||||
alertsT: newAlertsTab(),
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// Bubbletea lifecycle.
|
||||
// =============================================================
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
return tea.Batch(loadStatusCmd(), loadAlertsCmd(), tickCmd())
|
||||
}
|
||||
|
||||
type tickMsg time.Time
|
||||
|
||||
type statusMsg struct {
|
||||
st transport.StatusResponse
|
||||
err error
|
||||
}
|
||||
|
||||
type alertsMsg struct {
|
||||
alerts []config.Alert
|
||||
err error
|
||||
}
|
||||
|
||||
func tickCmd() tea.Cmd {
|
||||
return tea.Tick(refreshInterval, func(t time.Time) tea.Msg { return tickMsg(t) })
|
||||
}
|
||||
|
||||
func loadStatusCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||
defer cancel()
|
||||
raw, err := callDaemon(ctx, daemon.CtrlStatus, nil)
|
||||
if err != nil {
|
||||
return statusMsg{err: err}
|
||||
}
|
||||
var st transport.StatusResponse
|
||||
if err := json.Unmarshal(raw, &st); err != nil {
|
||||
return statusMsg{err: err}
|
||||
}
|
||||
return statusMsg{st: st}
|
||||
}
|
||||
}
|
||||
|
||||
func loadAlertsCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
cfg, err := config.LoadClusterConfig()
|
||||
if err != nil {
|
||||
return alertsMsg{err: err}
|
||||
}
|
||||
snap := cfg.Snapshot()
|
||||
return alertsMsg{alerts: snap.Alerts}
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width, m.height = msg.Width, msg.Height
|
||||
m.resizeTabs()
|
||||
return m, nil
|
||||
|
||||
case tickMsg:
|
||||
return m, tea.Batch(loadStatusCmd(), loadAlertsCmd(), tickCmd())
|
||||
|
||||
case statusMsg:
|
||||
if msg.err != nil {
|
||||
m.statusErr = msg.err.Error()
|
||||
} else {
|
||||
m.statusErr = ""
|
||||
m.status = msg.st
|
||||
m.statusLoaded = true
|
||||
m.peers.Refresh(msg.st, msg.st.NodeID)
|
||||
m.checks.Refresh(msg.st)
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case alertsMsg:
|
||||
if msg.err == nil {
|
||||
m.alerts = msg.alerts
|
||||
m.alertsT.Refresh(toAlertRows(msg.alerts))
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case modalDone:
|
||||
m.modal = nil
|
||||
if msg.flash != "" {
|
||||
m.setFlash(msg.flash, msg.level)
|
||||
}
|
||||
// Force-refresh in case the modal mutated cluster state.
|
||||
return m, tea.Batch(loadStatusCmd(), loadAlertsCmd())
|
||||
}
|
||||
|
||||
// Modal grabs all input while open.
|
||||
if m.modal != nil {
|
||||
newModal, cmd := m.modal.Update(msg)
|
||||
m.modal = newModal
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
if km, ok := msg.(tea.KeyMsg); ok {
|
||||
return m.handleKey(km)
|
||||
}
|
||||
|
||||
// Pass through to the active tab so j/k/PgUp/PgDn scroll the table.
|
||||
switch m.active {
|
||||
case tabPeers:
|
||||
_, cmd := m.peers.Update(msg)
|
||||
return m, cmd
|
||||
case tabChecks:
|
||||
_, cmd := m.checks.Update(msg)
|
||||
return m, cmd
|
||||
case tabAlerts:
|
||||
_, cmd := m.alertsT.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) handleKey(km tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch km.String() {
|
||||
case "ctrl+c", "q":
|
||||
return m, tea.Quit
|
||||
case "tab", "right", "L":
|
||||
m.active = (m.active + 1) % 3
|
||||
return m, nil
|
||||
case "shift+tab", "left", "H":
|
||||
m.active = (m.active + 2) % 3
|
||||
return m, nil
|
||||
case "1":
|
||||
m.active = tabPeers
|
||||
return m, nil
|
||||
case "2":
|
||||
m.active = tabChecks
|
||||
return m, nil
|
||||
case "3":
|
||||
m.active = tabAlerts
|
||||
return m, nil
|
||||
case "r":
|
||||
m.setFlash("refreshing…", flashInfo)
|
||||
return m, tea.Batch(loadStatusCmd(), loadAlertsCmd())
|
||||
case "a":
|
||||
m.modal = m.openAddPicker()
|
||||
return m, nil
|
||||
case "d":
|
||||
return m.openRemoveConfirm()
|
||||
case "t":
|
||||
if m.active == tabAlerts {
|
||||
return m.testSelectedAlert()
|
||||
}
|
||||
case "D":
|
||||
if m.active == tabAlerts {
|
||||
return m.toggleSelectedDefault()
|
||||
}
|
||||
}
|
||||
|
||||
// Forward everything else (arrow keys etc.) to the active tab.
|
||||
switch m.active {
|
||||
case tabPeers:
|
||||
_, cmd := m.peers.Update(km)
|
||||
return m, cmd
|
||||
case tabChecks:
|
||||
_, cmd := m.checks.Update(km)
|
||||
return m, cmd
|
||||
case tabAlerts:
|
||||
_, cmd := m.alertsT.Update(km)
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// View.
|
||||
// =============================================================
|
||||
|
||||
func (m model) View() string {
|
||||
if m.width == 0 {
|
||||
return "loading…"
|
||||
}
|
||||
header := m.renderHeader()
|
||||
tabs := m.renderTabs()
|
||||
body := m.renderActiveTab()
|
||||
help := m.renderHelp()
|
||||
|
||||
page := lipgloss.JoinVertical(lipgloss.Left, header, tabs, body, m.renderFlash(), help)
|
||||
|
||||
if m.modal != nil {
|
||||
overlay := modalStyle.Render(m.modal.View())
|
||||
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, overlay, lipgloss.WithWhitespaceChars(" "))
|
||||
}
|
||||
return page
|
||||
}
|
||||
|
||||
func (m model) renderHeader() string {
|
||||
if !m.statusLoaded {
|
||||
msg := "connecting to daemon…"
|
||||
if m.statusErr != "" {
|
||||
msg = "daemon: " + m.statusErr
|
||||
}
|
||||
return headerStyle.Width(m.width - 2).Render(titleStyle.Render("QUptime") + " " + helpStyle.Render(msg))
|
||||
}
|
||||
st := m.status
|
||||
quorum := stateDownStyle.Render("● no quorum")
|
||||
if st.HasQuorum {
|
||||
quorum = stateUpStyle.Render(fmt.Sprintf("● quorum %d/%d", liveCount(st.Peers), st.QuorumSize))
|
||||
}
|
||||
master := stateUnknownStyle.Render("master: —")
|
||||
if st.MasterID != "" {
|
||||
master = "master: " + shortID(st.MasterID)
|
||||
}
|
||||
role := ""
|
||||
if st.NodeID == st.MasterID {
|
||||
role = stateUpStyle.Render("(you are master)")
|
||||
} else {
|
||||
role = subtleStyle.Render("(follower)")
|
||||
}
|
||||
left := lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
titleStyle.Render("QUptime"),
|
||||
" ",
|
||||
"node: "+shortID(st.NodeID),
|
||||
" ",
|
||||
master,
|
||||
" ",
|
||||
role,
|
||||
)
|
||||
right := lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
quorum,
|
||||
" ",
|
||||
subtleStyle.Render(fmt.Sprintf("term %d ver %d", st.Term, st.Version)),
|
||||
)
|
||||
width := m.width - 2
|
||||
if width < 20 {
|
||||
width = 20
|
||||
}
|
||||
gap := width - lipgloss.Width(left) - lipgloss.Width(right)
|
||||
if gap < 1 {
|
||||
gap = 1
|
||||
}
|
||||
row := left + strings.Repeat(" ", gap) + right
|
||||
return headerStyle.Width(width).Render(row)
|
||||
}
|
||||
|
||||
func (m model) renderTabs() string {
|
||||
parts := make([]string, len(tabNames))
|
||||
for i, name := range tabNames {
|
||||
count := ""
|
||||
switch tabIndex(i) {
|
||||
case tabPeers:
|
||||
count = fmt.Sprintf(" (%d)", len(m.status.Peers))
|
||||
case tabChecks:
|
||||
count = fmt.Sprintf(" (%d)", len(m.status.Checks))
|
||||
case tabAlerts:
|
||||
count = fmt.Sprintf(" (%d)", len(m.alerts))
|
||||
}
|
||||
label := name + count
|
||||
if tabIndex(i) == m.active {
|
||||
parts[i] = tabActiveStyle.Render(label)
|
||||
} else {
|
||||
parts[i] = tabIdleStyle.Render(fmt.Sprintf("[%d] %s", i+1, label))
|
||||
}
|
||||
}
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
|
||||
}
|
||||
|
||||
func (m model) renderActiveTab() string {
|
||||
var view string
|
||||
switch m.active {
|
||||
case tabPeers:
|
||||
view = m.peers.View()
|
||||
case tabChecks:
|
||||
view = m.checks.View()
|
||||
case tabAlerts:
|
||||
view = m.alertsT.View()
|
||||
}
|
||||
return bodyStyle.Width(m.width - 2).Render(view)
|
||||
}
|
||||
|
||||
func (m model) renderHelp() string {
|
||||
specific := ""
|
||||
switch m.active {
|
||||
case tabPeers:
|
||||
specific = "a add node d remove node"
|
||||
case tabChecks:
|
||||
specific = "a add check d remove check"
|
||||
case tabAlerts:
|
||||
specific = "a add alert d remove alert t test D toggle default"
|
||||
}
|
||||
return helpStyle.Render(fmt.Sprintf("↑↓ navigate ⇥ next tab 1/2/3 jump r refresh %s q quit", specific))
|
||||
}
|
||||
|
||||
func (m model) renderFlash() string {
|
||||
if m.flash == "" || time.Now().After(m.flashUntil) {
|
||||
return ""
|
||||
}
|
||||
switch m.flashLevel {
|
||||
case flashError:
|
||||
return flashErrorStyle.Render(m.flash)
|
||||
case flashWarn:
|
||||
return flashWarnStyle.Render(m.flash)
|
||||
default:
|
||||
return flashInfoStyle.Render(m.flash)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// Actions.
|
||||
// =============================================================
|
||||
|
||||
func (m model) openAddPicker() modal {
|
||||
switch m.active {
|
||||
case tabPeers:
|
||||
return newAddNodeForm()
|
||||
case tabChecks:
|
||||
return newPicker("Add check — pick type", []pickerOption{
|
||||
{label: "HTTP", hint: "url + status code", choose: func() modal { return newAddCheckForm(config.CheckHTTP) }},
|
||||
{label: "TCP", hint: "host:port connect", choose: func() modal { return newAddCheckForm(config.CheckTCP) }},
|
||||
{label: "ICMP", hint: "ping a host", choose: func() modal { return newAddCheckForm(config.CheckICMP) }},
|
||||
})
|
||||
case tabAlerts:
|
||||
return newPicker("Add alert — pick type", []pickerOption{
|
||||
{label: "Discord", hint: "webhook URL", choose: func() modal { return newAddDiscordForm() }},
|
||||
{label: "SMTP", hint: "email via relay", choose: func() modal { return newAddSMTPForm() }},
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m model) openRemoveConfirm() (tea.Model, tea.Cmd) {
|
||||
var prompt string
|
||||
var run func() tea.Cmd
|
||||
switch m.active {
|
||||
case tabPeers:
|
||||
id := m.peers.Selected()
|
||||
name := strings.TrimPrefix(m.peers.SelectedName(), "* ")
|
||||
if id == "" {
|
||||
return m, nil
|
||||
}
|
||||
id = strings.TrimPrefix(id, "* ")
|
||||
prompt = fmt.Sprintf("Remove peer %s from the cluster?\nThis revokes trust and updates cluster.yaml.", shortID(name))
|
||||
run = func() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if _, err := callDaemon(ctx, daemon.CtrlNodeRemove, daemon.NodeRemoveBody{NodeID: id}); err != nil {
|
||||
return formSubmitErr(err.Error())
|
||||
}
|
||||
return modalDone{flash: "removed node " + shortID(id), level: flashInfo}
|
||||
}
|
||||
}
|
||||
case tabChecks:
|
||||
id := m.checks.Selected()
|
||||
name := m.checks.SelectedName()
|
||||
if id == "" {
|
||||
return m, nil
|
||||
}
|
||||
prompt = fmt.Sprintf("Remove check %q?", name)
|
||||
run = func() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if err := mutateRemove(transport.MutationRemoveCheck, id); err != nil {
|
||||
return formSubmitErr(err.Error())
|
||||
}
|
||||
return modalDone{flash: "removed check " + name, level: flashInfo}
|
||||
}
|
||||
}
|
||||
case tabAlerts:
|
||||
id := m.alertsT.Selected()
|
||||
name := m.alertsT.SelectedName()
|
||||
if id == "" {
|
||||
return m, nil
|
||||
}
|
||||
prompt = fmt.Sprintf("Remove alert %q?", name)
|
||||
run = func() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if err := mutateRemove(transport.MutationRemoveAlert, id); err != nil {
|
||||
return formSubmitErr(err.Error())
|
||||
}
|
||||
return modalDone{flash: "removed alert " + name, level: flashInfo}
|
||||
}
|
||||
}
|
||||
default:
|
||||
return m, nil
|
||||
}
|
||||
m.modal = newConfirm(prompt, run)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) testSelectedAlert() (tea.Model, tea.Cmd) {
|
||||
id := m.alertsT.Selected()
|
||||
if id == "" {
|
||||
return m, nil
|
||||
}
|
||||
name := m.alertsT.SelectedName()
|
||||
m.setFlash("sending test to "+name+"…", flashInfo)
|
||||
return m, func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if _, err := callDaemon(ctx, daemon.CtrlAlertTest, daemon.AlertTestBody{AlertID: id}); err != nil {
|
||||
return modalDone{flash: "test failed: " + err.Error(), level: flashError}
|
||||
}
|
||||
return modalDone{flash: "test sent via " + name, level: flashInfo}
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) toggleSelectedDefault() (tea.Model, tea.Cmd) {
|
||||
row := m.alertsT.SelectedAlert()
|
||||
if row == nil {
|
||||
return m, nil
|
||||
}
|
||||
var target *config.Alert
|
||||
for i := range m.alerts {
|
||||
if m.alerts[i].ID == row.ID {
|
||||
cp := m.alerts[i]
|
||||
target = &cp
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
m.setFlash("alert not found in local cluster.yaml", flashError)
|
||||
return m, nil
|
||||
}
|
||||
target.Default = !target.Default
|
||||
name := target.Name
|
||||
newState := target.Default
|
||||
return m, func() tea.Msg {
|
||||
if err := mutateAdd(transport.MutationAddAlert, target); err != nil {
|
||||
return modalDone{flash: "toggle failed: " + err.Error(), level: flashError}
|
||||
}
|
||||
state := "off"
|
||||
if newState {
|
||||
state = "on"
|
||||
}
|
||||
return modalDone{flash: fmt.Sprintf("alert %s default=%s", name, state), level: flashInfo}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// Small helpers.
|
||||
// =============================================================
|
||||
|
||||
func (m *model) setFlash(s string, level flashLevel) {
|
||||
m.flash = s
|
||||
m.flashLevel = level
|
||||
m.flashUntil = time.Now().Add(4 * time.Second)
|
||||
}
|
||||
|
||||
func (m *model) resizeTabs() {
|
||||
bodyH := m.height - 8
|
||||
if bodyH < 5 {
|
||||
bodyH = 5
|
||||
}
|
||||
bodyW := m.width - 4
|
||||
if bodyW < 20 {
|
||||
bodyW = 20
|
||||
}
|
||||
m.peers.SetSize(bodyW, bodyH)
|
||||
m.checks.SetSize(bodyW, bodyH)
|
||||
m.alertsT.SetSize(bodyW, bodyH)
|
||||
}
|
||||
|
||||
func toAlertRows(alerts []config.Alert) []alertRow {
|
||||
out := make([]alertRow, 0, len(alerts))
|
||||
for _, a := range alerts {
|
||||
endpoint := ""
|
||||
switch a.Type {
|
||||
case config.AlertDiscord:
|
||||
endpoint = a.DiscordWebhook
|
||||
case config.AlertSMTP:
|
||||
endpoint = fmt.Sprintf("%s:%d → %s", a.SMTPHost, a.SMTPPort, strings.Join(a.SMTPTo, ","))
|
||||
}
|
||||
out = append(out, alertRow{
|
||||
ID: a.ID,
|
||||
Name: a.Name,
|
||||
Type: string(a.Type),
|
||||
Default: a.Default,
|
||||
HasTmpl: a.SubjectTemplate != "" || a.BodyTemplate != "",
|
||||
Endpoint: endpoint,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func liveCount(peers []transport.PeerLiveness) int {
|
||||
n := 0
|
||||
for _, p := range peers {
|
||||
if p.Live {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func shortID(id string) string {
|
||||
if len(id) <= 8 {
|
||||
return id
|
||||
}
|
||||
return id[:8]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user