feat: initial commit — uptime monitor (forked from go-upkeep)
Go-based uptime monitor with SQLite/Postgres storage, TUI dashboard, SSH server, alerting, and clustering support.
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go-upkeep/internal/store"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
type alertFormData struct {
|
||||
Name string
|
||||
AlertType string
|
||||
WebhookURL string
|
||||
SMTPHost string
|
||||
SMTPPort string
|
||||
SMTPUser string
|
||||
SMTPPass string
|
||||
EmailFrom string
|
||||
EmailTo string
|
||||
}
|
||||
|
||||
func (m Model) viewAlertsTab() string {
|
||||
var content string
|
||||
content += fmt.Sprintf("\n%-3s %-15s %-10s %s\n", "ID", "NAME", "TYPE", "CONFIG")
|
||||
content += subtleStyle.Render("----------------------------------------------------------------") + "\n"
|
||||
end := m.tableOffset + m.maxTableRows
|
||||
if end > len(m.alerts) {
|
||||
end = len(m.alerts)
|
||||
}
|
||||
for i := m.tableOffset; i < end; i++ {
|
||||
alert := m.alerts[i]
|
||||
cursor := " "
|
||||
if m.cursor == i {
|
||||
cursor = ">"
|
||||
}
|
||||
confStr := "settings..."
|
||||
if val, ok := alert.Settings["url"]; ok {
|
||||
confStr = limitStr(val, 30)
|
||||
}
|
||||
if alert.Type == "email" {
|
||||
confStr = fmt.Sprintf("SMTP: %s", alert.Settings["host"])
|
||||
}
|
||||
row := fmt.Sprintf("%s %-3d %-15s %-10s %s", cursor, alert.ID, limitStr(alert.Name, 15), alert.Type, confStr)
|
||||
if m.cursor == i {
|
||||
row = lipgloss.NewStyle().Bold(true).Render(row)
|
||||
}
|
||||
content += row + "\n"
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func (m *Model) initAlertHuhForm() tea.Cmd {
|
||||
m.alertFormData = &alertFormData{
|
||||
AlertType: "discord",
|
||||
}
|
||||
|
||||
if m.editID > 0 {
|
||||
for _, alert := range m.alerts {
|
||||
if alert.ID == m.editID {
|
||||
m.alertFormData.Name = alert.Name
|
||||
m.alertFormData.AlertType = alert.Type
|
||||
if url, ok := alert.Settings["url"]; ok {
|
||||
m.alertFormData.WebhookURL = url
|
||||
}
|
||||
if alert.Type == "email" {
|
||||
m.alertFormData.SMTPHost = alert.Settings["host"]
|
||||
m.alertFormData.SMTPPort = alert.Settings["port"]
|
||||
m.alertFormData.SMTPUser = alert.Settings["user"]
|
||||
m.alertFormData.SMTPPass = alert.Settings["pass"]
|
||||
m.alertFormData.EmailFrom = alert.Settings["from"]
|
||||
m.alertFormData.EmailTo = alert.Settings["to"]
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
m.huhForm = huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().Title("Alert Name").
|
||||
Placeholder("My Alert Channel").
|
||||
Value(&m.alertFormData.Name).
|
||||
Validate(func(s string) error {
|
||||
if s == "" {
|
||||
return fmt.Errorf("name is required")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
huh.NewSelect[string]().Title("Alert Type").
|
||||
Options(
|
||||
huh.NewOption("Discord", "discord"),
|
||||
huh.NewOption("Slack", "slack"),
|
||||
huh.NewOption("Webhook", "webhook"),
|
||||
huh.NewOption("Email (SMTP)", "email"),
|
||||
).Value(&m.alertFormData.AlertType),
|
||||
).Title("Alert Config"),
|
||||
huh.NewGroup(
|
||||
huh.NewInput().Title("Webhook URL").
|
||||
Placeholder("https://discord.com/api/webhooks/...").
|
||||
Value(&m.alertFormData.WebhookURL),
|
||||
).Title("Webhook").WithHideFunc(func() bool {
|
||||
return m.alertFormData.AlertType == "email"
|
||||
}),
|
||||
huh.NewGroup(
|
||||
huh.NewInput().Title("SMTP Host").
|
||||
Placeholder("smtp.gmail.com").
|
||||
Value(&m.alertFormData.SMTPHost),
|
||||
huh.NewInput().Title("SMTP Port").
|
||||
Placeholder("587").
|
||||
Value(&m.alertFormData.SMTPPort),
|
||||
huh.NewInput().Title("SMTP User").
|
||||
Placeholder("user@gmail.com").
|
||||
Value(&m.alertFormData.SMTPUser),
|
||||
huh.NewInput().Title("SMTP Password").
|
||||
EchoMode(huh.EchoModePassword).
|
||||
Value(&m.alertFormData.SMTPPass),
|
||||
huh.NewInput().Title("From Email").
|
||||
Placeholder("alerts@domain.com").
|
||||
Value(&m.alertFormData.EmailFrom),
|
||||
huh.NewInput().Title("To Email").
|
||||
Placeholder("oncall@domain.com").
|
||||
Value(&m.alertFormData.EmailTo),
|
||||
).Title("Email Settings").WithHideFunc(func() bool {
|
||||
return m.alertFormData.AlertType != "email"
|
||||
}),
|
||||
).WithTheme(huh.ThemeDracula())
|
||||
|
||||
return m.huhForm.Init()
|
||||
}
|
||||
|
||||
func (m *Model) submitAlertForm() {
|
||||
d := m.alertFormData
|
||||
settings := make(map[string]string)
|
||||
|
||||
if d.AlertType == "email" {
|
||||
settings["host"] = d.SMTPHost
|
||||
settings["port"] = d.SMTPPort
|
||||
settings["user"] = d.SMTPUser
|
||||
settings["pass"] = d.SMTPPass
|
||||
settings["from"] = d.EmailFrom
|
||||
settings["to"] = d.EmailTo
|
||||
} else {
|
||||
settings["url"] = d.WebhookURL
|
||||
}
|
||||
|
||||
if m.editID > 0 {
|
||||
store.Get().UpdateAlert(m.editID, d.Name, d.AlertType, settings)
|
||||
} else {
|
||||
store.Get().AddAlert(d.Name, d.AlertType, settings)
|
||||
}
|
||||
m.state = stateDashboard
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package tui
|
||||
|
||||
func (m Model) viewLogsTab() string {
|
||||
return "\n" + m.logViewport.View()
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go-upkeep/internal/models"
|
||||
"go-upkeep/internal/monitor"
|
||||
"go-upkeep/internal/store"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/lipgloss/table"
|
||||
)
|
||||
|
||||
var sparkChars = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
|
||||
|
||||
var (
|
||||
siteHeaderStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#7D56F4")).
|
||||
Bold(true).
|
||||
Padding(0, 1)
|
||||
|
||||
siteCellStyle = lipgloss.NewStyle().Padding(0, 1)
|
||||
|
||||
siteSelectedStyle = lipgloss.NewStyle().
|
||||
Padding(0, 1).
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#ffffff")).
|
||||
Background(lipgloss.Color("#3b3b5c"))
|
||||
|
||||
siteBorderStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#444"))
|
||||
|
||||
siteColWidths = []int{4, 16, 8, 9, 8, 22, 10, 6}
|
||||
)
|
||||
|
||||
type siteFormData struct {
|
||||
Name string
|
||||
SiteType string
|
||||
URL string
|
||||
Interval string
|
||||
AlertID string
|
||||
CheckSSL bool
|
||||
Threshold string
|
||||
Retries string
|
||||
}
|
||||
|
||||
func latencySparkline(latencies []time.Duration, width int) string {
|
||||
if len(latencies) == 0 {
|
||||
return subtleStyle.Render(strings.Repeat("·", width))
|
||||
}
|
||||
|
||||
samples := latencies
|
||||
if len(samples) > width {
|
||||
samples = samples[len(samples)-width:]
|
||||
}
|
||||
|
||||
minL, maxL := samples[0], samples[0]
|
||||
for _, l := range samples {
|
||||
if l < minL {
|
||||
minL = l
|
||||
}
|
||||
if l > maxL {
|
||||
maxL = l
|
||||
}
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
spread := maxL - minL
|
||||
for _, l := range samples {
|
||||
idx := 0
|
||||
if spread > 0 {
|
||||
idx = int(float64(l-minL) / float64(spread) * 7)
|
||||
if idx > 7 {
|
||||
idx = 7
|
||||
}
|
||||
}
|
||||
ch := string(sparkChars[idx])
|
||||
ms := l.Milliseconds()
|
||||
if ms < 200 {
|
||||
sb.WriteString(specialStyle.Render(ch))
|
||||
} else if ms < 500 {
|
||||
sb.WriteString(warnStyle.Render(ch))
|
||||
} else {
|
||||
sb.WriteString(dangerStyle.Render(ch))
|
||||
}
|
||||
}
|
||||
|
||||
if remaining := width - len(samples); remaining > 0 {
|
||||
sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining)))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func heartbeatSparkline(statuses []bool, width int) string {
|
||||
if len(statuses) == 0 {
|
||||
return subtleStyle.Render(strings.Repeat("·", width))
|
||||
}
|
||||
|
||||
samples := statuses
|
||||
if len(samples) > width {
|
||||
samples = samples[len(samples)-width:]
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for _, up := range samples {
|
||||
if up {
|
||||
sb.WriteString(specialStyle.Render("▁"))
|
||||
} else {
|
||||
sb.WriteString(dangerStyle.Render("█"))
|
||||
}
|
||||
}
|
||||
|
||||
if remaining := width - len(samples); remaining > 0 {
|
||||
sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining)))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func fmtLatency(d time.Duration) string {
|
||||
ms := d.Milliseconds()
|
||||
if ms == 0 {
|
||||
return subtleStyle.Render("—")
|
||||
}
|
||||
var s string
|
||||
if ms < 1000 {
|
||||
s = fmt.Sprintf("%dms", ms)
|
||||
} else {
|
||||
s = fmt.Sprintf("%.1fs", float64(ms)/1000)
|
||||
}
|
||||
if ms < 200 {
|
||||
return specialStyle.Render(s)
|
||||
}
|
||||
if ms < 500 {
|
||||
return warnStyle.Render(s)
|
||||
}
|
||||
return dangerStyle.Render(s)
|
||||
}
|
||||
|
||||
func fmtUptime(total, up int) string {
|
||||
if total == 0 {
|
||||
return subtleStyle.Render("—")
|
||||
}
|
||||
pct := float64(up) / float64(total) * 100
|
||||
s := fmt.Sprintf("%.1f%%", pct)
|
||||
if pct >= 99 {
|
||||
return specialStyle.Render(s)
|
||||
}
|
||||
if pct >= 95 {
|
||||
return warnStyle.Render(s)
|
||||
}
|
||||
return dangerStyle.Render(s)
|
||||
}
|
||||
|
||||
func fmtSSL(site models.Site) string {
|
||||
if site.Type != "http" || !site.CheckSSL || !site.HasSSL {
|
||||
return subtleStyle.Render("-")
|
||||
}
|
||||
days := int(time.Until(site.CertExpiry).Hours() / 24)
|
||||
s := fmt.Sprintf("%dd", days)
|
||||
if days <= 0 {
|
||||
return dangerStyle.Render("EXPIRED")
|
||||
}
|
||||
if days <= site.ExpiryThreshold {
|
||||
return warnStyle.Render(s)
|
||||
}
|
||||
return specialStyle.Render(s)
|
||||
}
|
||||
|
||||
func fmtRetries(site models.Site) string {
|
||||
retriesDone := site.FailureCount - 1
|
||||
if retriesDone < 0 {
|
||||
retriesDone = 0
|
||||
}
|
||||
dispCount := retriesDone
|
||||
if dispCount > site.MaxRetries {
|
||||
dispCount = site.MaxRetries
|
||||
}
|
||||
s := fmt.Sprintf("%d/%d", dispCount, site.MaxRetries)
|
||||
if site.Status == "DOWN" {
|
||||
return dangerStyle.Render(s)
|
||||
}
|
||||
if site.Status == "UP" && site.FailureCount > 0 {
|
||||
return warnStyle.Render(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func fmtStatus(status string) string {
|
||||
switch {
|
||||
case status == "DOWN" || status == "SSL EXP":
|
||||
return dangerStyle.Render(status)
|
||||
case status == "PENDING":
|
||||
return subtleStyle.Render(status)
|
||||
default:
|
||||
return specialStyle.Render(status)
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) viewSitesTab() string {
|
||||
const sparkWidth = 20
|
||||
|
||||
if len(m.sites) == 0 {
|
||||
return "\n No sites configured. Press [n] to add one."
|
||||
}
|
||||
|
||||
end := m.tableOffset + m.maxTableRows
|
||||
if end > len(m.sites) {
|
||||
end = len(m.sites)
|
||||
}
|
||||
|
||||
selectedVisual := m.cursor - m.tableOffset
|
||||
|
||||
var rows [][]string
|
||||
for i := m.tableOffset; i < end; i++ {
|
||||
site := m.sites[i]
|
||||
hist, _ := monitor.GetHistory(site.ID)
|
||||
|
||||
var spark string
|
||||
if site.Type == "push" {
|
||||
spark = heartbeatSparkline(hist.Statuses, sparkWidth)
|
||||
} else {
|
||||
spark = latencySparkline(hist.Latencies, sparkWidth)
|
||||
}
|
||||
|
||||
rows = append(rows, []string{
|
||||
strconv.Itoa(site.ID),
|
||||
m.zones.Mark(fmt.Sprintf("site-%d", i), limitStr(site.Name, 15)),
|
||||
fmtStatus(site.Status),
|
||||
fmtLatency(site.Latency),
|
||||
fmtUptime(hist.TotalChecks, hist.UpChecks),
|
||||
spark,
|
||||
fmtSSL(site),
|
||||
fmtRetries(site),
|
||||
})
|
||||
}
|
||||
|
||||
t := table.New().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderStyle(siteBorderStyle).
|
||||
Headers("ID", "NAME", "STATUS", "LATENCY", "UPTIME", "HISTORY", "SSL", "RETRY").
|
||||
Rows(rows...).
|
||||
StyleFunc(func(row, col int) lipgloss.Style {
|
||||
if row == table.HeaderRow {
|
||||
s := siteHeaderStyle
|
||||
if col < len(siteColWidths) {
|
||||
s = s.Width(siteColWidths[col])
|
||||
}
|
||||
return s
|
||||
}
|
||||
s := siteCellStyle
|
||||
if row == selectedVisual {
|
||||
s = siteSelectedStyle
|
||||
}
|
||||
if col < len(siteColWidths) {
|
||||
s = s.Width(siteColWidths[col])
|
||||
}
|
||||
return s
|
||||
})
|
||||
|
||||
return "\n" + t.Render()
|
||||
}
|
||||
|
||||
func (m *Model) initSiteHuhForm() tea.Cmd {
|
||||
m.siteFormData = &siteFormData{
|
||||
SiteType: "http",
|
||||
Interval: "60",
|
||||
Threshold: "7",
|
||||
Retries: "0",
|
||||
}
|
||||
|
||||
if m.editID > 0 {
|
||||
for _, site := range m.sites {
|
||||
if site.ID == m.editID {
|
||||
m.siteFormData.Name = site.Name
|
||||
m.siteFormData.SiteType = site.Type
|
||||
m.siteFormData.URL = site.URL
|
||||
m.siteFormData.Interval = strconv.Itoa(site.Interval)
|
||||
m.siteFormData.AlertID = strconv.Itoa(site.AlertID)
|
||||
m.siteFormData.CheckSSL = site.CheckSSL
|
||||
m.siteFormData.Threshold = strconv.Itoa(site.ExpiryThreshold)
|
||||
m.siteFormData.Retries = strconv.Itoa(site.MaxRetries)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
alertOpts := []huh.Option[string]{huh.NewOption("None", "0")}
|
||||
if store.Get() != nil {
|
||||
for _, a := range store.Get().GetAllAlerts() {
|
||||
alertOpts = append(alertOpts, huh.NewOption(
|
||||
fmt.Sprintf("%s (%s)", a.Name, a.Type),
|
||||
strconv.Itoa(a.ID),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
m.huhForm = huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().Title("Monitor Name").
|
||||
Placeholder("My Service").
|
||||
Value(&m.siteFormData.Name).
|
||||
Validate(func(s string) error {
|
||||
if s == "" {
|
||||
return fmt.Errorf("name is required")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
huh.NewSelect[string]().Title("Monitor Type").
|
||||
Options(
|
||||
huh.NewOption("HTTP/HTTPS", "http"),
|
||||
huh.NewOption("Push / Heartbeat", "push"),
|
||||
).Value(&m.siteFormData.SiteType),
|
||||
huh.NewInput().Title("URL").
|
||||
Placeholder("https://example.com").
|
||||
Description("Required for HTTP monitors").
|
||||
Value(&m.siteFormData.URL),
|
||||
huh.NewInput().Title("Check Interval (seconds)").
|
||||
Placeholder("60").
|
||||
Value(&m.siteFormData.Interval),
|
||||
huh.NewSelect[string]().Title("Alert Channel").
|
||||
Options(alertOpts...).
|
||||
Value(&m.siteFormData.AlertID),
|
||||
).Title("Monitor Settings"),
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().Title("Monitor SSL Certificate?").
|
||||
Value(&m.siteFormData.CheckSSL),
|
||||
huh.NewInput().Title("SSL Warning Threshold (days)").
|
||||
Placeholder("7").
|
||||
Value(&m.siteFormData.Threshold),
|
||||
huh.NewInput().Title("Max Retries Before Alert").
|
||||
Placeholder("0").
|
||||
Value(&m.siteFormData.Retries),
|
||||
).Title("Advanced"),
|
||||
).WithTheme(huh.ThemeDracula())
|
||||
|
||||
return m.huhForm.Init()
|
||||
}
|
||||
|
||||
func (m *Model) submitSiteForm() {
|
||||
d := m.siteFormData
|
||||
interval, _ := strconv.Atoi(d.Interval)
|
||||
alertID, _ := strconv.Atoi(d.AlertID)
|
||||
threshold, _ := strconv.Atoi(d.Threshold)
|
||||
retries, _ := strconv.Atoi(d.Retries)
|
||||
if interval < 1 {
|
||||
interval = 60
|
||||
}
|
||||
if threshold < 1 {
|
||||
threshold = 7
|
||||
}
|
||||
|
||||
if m.editID > 0 {
|
||||
store.Get().UpdateSite(m.editID, d.Name, d.URL, d.SiteType, interval, alertID, d.CheckSSL, threshold, retries)
|
||||
monitor.UpdateSiteConfig(m.editID, d.Name, d.URL, d.SiteType, interval, alertID, d.CheckSSL, threshold, retries)
|
||||
} else {
|
||||
store.Get().AddSite(d.Name, d.URL, d.SiteType, interval, alertID, d.CheckSSL, threshold, retries)
|
||||
}
|
||||
m.state = stateDashboard
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go-upkeep/internal/store"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type userFormData struct {
|
||||
Username string
|
||||
PublicKey string
|
||||
}
|
||||
|
||||
func (m Model) viewUsersTab() string {
|
||||
var content string
|
||||
content += fmt.Sprintf("\n%-3s %-15s %-10s %s\n", "ID", "USER", "ROLE", "KEY")
|
||||
content += subtleStyle.Render("----------------------------------------------------------------") + "\n"
|
||||
end := m.tableOffset + m.maxTableRows
|
||||
if end > len(m.users) {
|
||||
end = len(m.users)
|
||||
}
|
||||
for i := m.tableOffset; i < end; i++ {
|
||||
u := m.users[i]
|
||||
cursor := " "
|
||||
if m.cursor == i {
|
||||
cursor = ">"
|
||||
}
|
||||
row := fmt.Sprintf("%s %-3d %-15s %-10s %s", cursor, u.ID, limitStr(u.Username, 15), u.Role, limitStr(u.PublicKey, 40))
|
||||
if m.cursor == i {
|
||||
row = lipgloss.NewStyle().Bold(true).Render(row)
|
||||
}
|
||||
content += row + "\n"
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func (m *Model) initUserHuhForm() tea.Cmd {
|
||||
m.userFormData = &userFormData{}
|
||||
|
||||
m.huhForm = huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().Title("Username").
|
||||
Placeholder("admin").
|
||||
Value(&m.userFormData.Username).
|
||||
Validate(func(s string) error {
|
||||
if s == "" {
|
||||
return fmt.Errorf("username is required")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
huh.NewInput().Title("SSH Public Key").
|
||||
Placeholder("ssh-ed25519 AAAA...").
|
||||
Value(&m.userFormData.PublicKey).
|
||||
Validate(func(s string) error {
|
||||
if s == "" {
|
||||
return fmt.Errorf("public key is required")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
).Title("SSH Access"),
|
||||
).WithTheme(huh.ThemeDracula())
|
||||
|
||||
return m.huhForm.Init()
|
||||
}
|
||||
|
||||
func (m *Model) submitUserForm() {
|
||||
store.Get().AddUser(m.userFormData.Username, m.userFormData.PublicKey, "user")
|
||||
m.state = stateUsers
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go-upkeep/internal/models"
|
||||
"go-upkeep/internal/monitor"
|
||||
"go-upkeep/internal/store"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/harmonica"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
zone "github.com/lrstanley/bubblezone"
|
||||
)
|
||||
|
||||
var (
|
||||
subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"})
|
||||
specialStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"})
|
||||
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#F0E442", Dark: "#F0E442"})
|
||||
dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#F25D94", Dark: "#F25D94"})
|
||||
titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#7D56F4")).Bold(true)
|
||||
|
||||
activeTab = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, true, false).BorderForeground(lipgloss.Color("#7D56F4")).Foreground(lipgloss.Color("#7D56F4")).Bold(true).Padding(0, 1)
|
||||
inactiveTab = lipgloss.NewStyle().Padding(0, 1).Foreground(lipgloss.AdaptiveColor{Light: "#AAA", Dark: "#555"})
|
||||
)
|
||||
|
||||
var pulseFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
|
||||
type sessionState int
|
||||
|
||||
const (
|
||||
stateDashboard sessionState = iota
|
||||
stateLogs
|
||||
stateUsers
|
||||
stateFormSite
|
||||
stateFormAlert
|
||||
stateFormUser
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
state sessionState
|
||||
currentTab int
|
||||
cursor int
|
||||
tableOffset int
|
||||
maxTableRows int
|
||||
editID int
|
||||
editToken string
|
||||
|
||||
huhForm *huh.Form
|
||||
siteFormData *siteFormData
|
||||
alertFormData *alertFormData
|
||||
userFormData *userFormData
|
||||
|
||||
logViewport viewport.Model
|
||||
isAdmin bool
|
||||
zones *zone.Manager
|
||||
|
||||
// harmonica animation state
|
||||
pulseSpring harmonica.Spring
|
||||
pulsePos float64
|
||||
pulseVel float64
|
||||
tickCount int
|
||||
|
||||
sites []models.Site
|
||||
alerts []models.AlertConfig
|
||||
users []models.User
|
||||
}
|
||||
|
||||
func InitialModel(isAdmin bool) Model {
|
||||
vpLogs := viewport.New(100, 20)
|
||||
vpLogs.SetContent("Waiting for logs...")
|
||||
z := zone.New()
|
||||
spring := harmonica.NewSpring(harmonica.FPS(10), 6.0, 0.4)
|
||||
return Model{
|
||||
state: stateDashboard,
|
||||
logViewport: vpLogs,
|
||||
maxTableRows: 5,
|
||||
isAdmin: isAdmin,
|
||||
zones: z,
|
||||
pulseSpring: spring,
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return tea.Batch(tea.ClearScreen, tea.Tick(time.Second, func(t time.Time) tea.Msg { return t }))
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
// Form state: forward ALL messages to huh (keys, timers, resize, etc.)
|
||||
if m.state == stateFormSite || m.state == stateFormAlert || m.state == stateFormUser {
|
||||
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
||||
if keyMsg.String() == "ctrl+c" {
|
||||
return m, tea.Quit
|
||||
}
|
||||
if keyMsg.String() == "esc" {
|
||||
m.huhForm = nil
|
||||
m.state = stateDashboard
|
||||
if m.currentTab == 3 {
|
||||
m.state = stateUsers
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
if m.huhForm != nil {
|
||||
form, formCmd := m.huhForm.Update(msg)
|
||||
if f, ok := form.(*huh.Form); ok {
|
||||
m.huhForm = f
|
||||
}
|
||||
if m.huhForm.State == huh.StateCompleted {
|
||||
m.submitForm()
|
||||
m.refreshData()
|
||||
m.huhForm = nil
|
||||
return m, nil
|
||||
}
|
||||
return m, formCmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.maxTableRows = msg.Height - 12
|
||||
if m.maxTableRows < 1 {
|
||||
m.maxTableRows = 1
|
||||
}
|
||||
m.logViewport.Width = msg.Width
|
||||
m.logViewport.Height = msg.Height - 6
|
||||
return m, tea.ClearScreen
|
||||
|
||||
case time.Time:
|
||||
m.refreshData()
|
||||
m.tickCount++
|
||||
target := math.Sin(float64(m.tickCount)*0.3)*0.5 + 0.5
|
||||
m.pulsePos, m.pulseVel = m.pulseSpring.Update(m.pulsePos, m.pulseVel, target)
|
||||
return m, tea.Tick(time.Second, func(t time.Time) tea.Msg { return t })
|
||||
|
||||
case tea.MouseMsg:
|
||||
if m.state == stateDashboard || m.state == stateLogs || m.state == stateUsers {
|
||||
if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft {
|
||||
return m.handleClick(msg)
|
||||
}
|
||||
}
|
||||
|
||||
case tea.KeyMsg:
|
||||
if msg.String() == "ctrl+c" {
|
||||
return m, tea.Quit
|
||||
}
|
||||
if msg.String() == "ctrl+l" {
|
||||
return m, tea.ClearScreen
|
||||
}
|
||||
|
||||
switch m.state {
|
||||
case stateDashboard, stateLogs, stateUsers:
|
||||
switch msg.String() {
|
||||
case "q":
|
||||
return m, tea.Quit
|
||||
case "tab":
|
||||
m.switchTab(m.currentTab + 1)
|
||||
case "pgup", "pgdown":
|
||||
if m.state == stateLogs {
|
||||
m.logViewport, cmd = m.logViewport.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
case "up", "k":
|
||||
if m.state == stateLogs {
|
||||
m.logViewport.LineUp(1)
|
||||
} else if m.cursor > 0 {
|
||||
m.cursor--
|
||||
if m.cursor < m.tableOffset {
|
||||
m.tableOffset = m.cursor
|
||||
}
|
||||
}
|
||||
case "down", "j":
|
||||
if m.state == stateLogs {
|
||||
m.logViewport.LineDown(1)
|
||||
} else {
|
||||
max := len(m.sites) - 1
|
||||
if m.currentTab == 1 {
|
||||
max = len(m.alerts) - 1
|
||||
}
|
||||
if m.currentTab == 3 {
|
||||
max = len(m.users) - 1
|
||||
}
|
||||
if m.cursor < max {
|
||||
m.cursor++
|
||||
if m.cursor >= m.tableOffset+m.maxTableRows {
|
||||
m.tableOffset++
|
||||
}
|
||||
}
|
||||
}
|
||||
case "n":
|
||||
m.editID = 0
|
||||
m.editToken = ""
|
||||
if m.currentTab == 0 {
|
||||
m.state = stateFormSite
|
||||
return m, m.initSiteHuhForm()
|
||||
} else if m.currentTab == 1 {
|
||||
m.state = stateFormAlert
|
||||
return m, m.initAlertHuhForm()
|
||||
} else if m.currentTab == 3 && m.isAdmin {
|
||||
m.state = stateFormUser
|
||||
return m, m.initUserHuhForm()
|
||||
}
|
||||
case "e", "enter":
|
||||
if m.currentTab == 0 && len(m.sites) > 0 {
|
||||
m.editID = m.sites[m.cursor].ID
|
||||
m.editToken = m.sites[m.cursor].Token
|
||||
m.state = stateFormSite
|
||||
return m, m.initSiteHuhForm()
|
||||
} else if m.currentTab == 1 && len(m.alerts) > 0 {
|
||||
m.editID = m.alerts[m.cursor].ID
|
||||
m.state = stateFormAlert
|
||||
return m, m.initAlertHuhForm()
|
||||
}
|
||||
case "d", "backspace":
|
||||
if m.currentTab == 1 && len(m.alerts) > 0 {
|
||||
store.Get().DeleteAlert(m.alerts[m.cursor].ID)
|
||||
m.adjustCursor(len(m.alerts) - 1)
|
||||
} else if m.currentTab == 0 && len(m.sites) > 0 {
|
||||
id := m.sites[m.cursor].ID
|
||||
store.Get().DeleteSite(id)
|
||||
monitor.RemoveSite(id)
|
||||
m.adjustCursor(len(m.sites) - 1)
|
||||
} else if m.currentTab == 3 && m.isAdmin && len(m.users) > 0 {
|
||||
store.Get().DeleteUser(m.users[m.cursor].ID)
|
||||
m.adjustCursor(len(m.users) - 1)
|
||||
}
|
||||
m.refreshData()
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
maxTabs := 3
|
||||
if !m.isAdmin {
|
||||
maxTabs = 2
|
||||
}
|
||||
for i := 0; i <= maxTabs; i++ {
|
||||
if m.zones.Get(fmt.Sprintf("tab-%d", i)).InBounds(msg) {
|
||||
m.switchTab(i)
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
if m.currentTab == 0 {
|
||||
end := m.tableOffset + m.maxTableRows
|
||||
if end > len(m.sites) {
|
||||
end = len(m.sites)
|
||||
}
|
||||
for i := m.tableOffset; i < end; i++ {
|
||||
if m.zones.Get(fmt.Sprintf("site-%d", i)).InBounds(msg) {
|
||||
m.cursor = i
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) switchTab(idx int) {
|
||||
maxTabs := 2
|
||||
if m.isAdmin {
|
||||
maxTabs = 3
|
||||
}
|
||||
if idx > maxTabs {
|
||||
idx = 0
|
||||
}
|
||||
m.currentTab = idx
|
||||
m.cursor = 0
|
||||
m.tableOffset = 0
|
||||
switch idx {
|
||||
case 2:
|
||||
m.state = stateLogs
|
||||
case 3:
|
||||
m.state = stateUsers
|
||||
default:
|
||||
m.state = stateDashboard
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) adjustCursor(newLen int) {
|
||||
if m.cursor >= newLen && m.cursor > 0 {
|
||||
m.cursor--
|
||||
}
|
||||
if m.cursor < m.tableOffset {
|
||||
m.tableOffset = m.cursor
|
||||
if m.tableOffset < 0 {
|
||||
m.tableOffset = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) refreshData() {
|
||||
monitor.Mutex.RLock()
|
||||
var sites []models.Site
|
||||
for _, s := range monitor.LiveState {
|
||||
sites = append(sites, s)
|
||||
}
|
||||
monitor.Mutex.RUnlock()
|
||||
sort.Slice(sites, func(i, j int) bool { return sites[i].ID < sites[j].ID })
|
||||
m.sites = sites
|
||||
if store.Get() != nil {
|
||||
m.alerts = store.Get().GetAllAlerts()
|
||||
if m.isAdmin {
|
||||
m.users = store.Get().GetAllUsers()
|
||||
}
|
||||
}
|
||||
m.logViewport.SetContent(strings.Join(monitor.GetLogs(), "\n"))
|
||||
}
|
||||
|
||||
func (m *Model) submitForm() {
|
||||
if store.Get() == nil {
|
||||
return
|
||||
}
|
||||
switch m.state {
|
||||
case stateFormSite:
|
||||
if m.siteFormData != nil {
|
||||
m.submitSiteForm()
|
||||
}
|
||||
case stateFormAlert:
|
||||
if m.alertFormData != nil {
|
||||
m.submitAlertForm()
|
||||
}
|
||||
case stateFormUser:
|
||||
if m.userFormData != nil {
|
||||
m.submitUserForm()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) pulseIndicator() string {
|
||||
frame := m.tickCount % len(pulseFrames)
|
||||
brightness := int(m.pulsePos*155) + 100
|
||||
if brightness > 255 {
|
||||
brightness = 255
|
||||
}
|
||||
color := fmt.Sprintf("#%02x%02x%02x", brightness/3, brightness, brightness/2)
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(pulseFrames[frame])
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
switch m.state {
|
||||
case stateFormSite, stateFormAlert, stateFormUser:
|
||||
if m.huhForm != nil {
|
||||
title := ""
|
||||
switch m.state {
|
||||
case stateFormSite:
|
||||
title = "Add Monitor"
|
||||
if m.editID > 0 {
|
||||
title = fmt.Sprintf("Edit Monitor #%d", m.editID)
|
||||
}
|
||||
case stateFormAlert:
|
||||
title = "Add Alert"
|
||||
if m.editID > 0 {
|
||||
title = fmt.Sprintf("Edit Alert #%d", m.editID)
|
||||
}
|
||||
case stateFormUser:
|
||||
title = "Add User"
|
||||
}
|
||||
header := titleStyle.Render(title)
|
||||
footer := subtleStyle.Render("\n[Esc] Cancel")
|
||||
return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n\n" + m.huhForm.View() + "\n" + footer)
|
||||
}
|
||||
return ""
|
||||
default:
|
||||
return m.zones.Scan(m.viewDashboard())
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) viewDashboard() string {
|
||||
tabs := []string{"Sites", "Alerts", "Logs"}
|
||||
if m.isAdmin {
|
||||
tabs = append(tabs, "Users")
|
||||
}
|
||||
var renderedTabs []string
|
||||
for i, t := range tabs {
|
||||
var rendered string
|
||||
if i == m.currentTab {
|
||||
rendered = activeTab.Render(t)
|
||||
} else {
|
||||
rendered = inactiveTab.Render(t)
|
||||
}
|
||||
renderedTabs = append(renderedTabs, m.zones.Mark(fmt.Sprintf("tab-%d", i), rendered))
|
||||
}
|
||||
header := lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...)
|
||||
|
||||
pulse := m.pulseIndicator()
|
||||
header = pulse + " " + header
|
||||
|
||||
var content string
|
||||
switch m.currentTab {
|
||||
case 0:
|
||||
content = m.viewSitesTab()
|
||||
case 1:
|
||||
content = m.viewAlertsTab()
|
||||
case 2:
|
||||
content = m.viewLogsTab()
|
||||
case 3:
|
||||
if m.isAdmin {
|
||||
content = m.viewUsersTab()
|
||||
}
|
||||
}
|
||||
|
||||
footer := subtleStyle.Render("\n[n] New [e/Enter] Edit [d] Delete [Tab/Click] Switch [Ctrl+L] Clear [q] Quit")
|
||||
if m.currentTab == 3 {
|
||||
footer = subtleStyle.Render("\n[n] Add User [d] Revoke [Tab/Click] Switch [Ctrl+L] Clear [q] Quit")
|
||||
}
|
||||
return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n" + content + "\n" + footer)
|
||||
}
|
||||
|
||||
func limitStr(text string, max int) string {
|
||||
if len(text) > max {
|
||||
return text[:max-3] + "..."
|
||||
}
|
||||
return text
|
||||
}
|
||||
Reference in New Issue
Block a user