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:
2026-05-14 11:05:10 -04:00
commit 02f0a39d97
25 changed files with 2834 additions and 0 deletions
+155
View File
@@ -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
}
+5
View File
@@ -0,0 +1,5 @@
package tui
func (m Model) viewLogsTab() string {
return "\n" + m.logViewport.View()
}
+363
View File
@@ -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
}
+72
View File
@@ -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
}
+426
View File
@@ -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
}