fix/feat: UX polish, security fixes, groups #2

Merged
lerko merged 3 commits from fix/polish-ux-safety into develop 2026-05-15 01:17:59 +00:00
3 changed files with 164 additions and 16 deletions
Showing only changes of commit c480f519c4 - Show all commits
+39 -1
View File
@@ -243,7 +243,7 @@ func checkByID(id int) {
case "dns":
checkDNS(site)
case "group":
// groups don't perform checks
checkGroup(site)
}
}
@@ -437,6 +437,44 @@ func checkPort(site models.Site) {
handleStatusChange(updatedSite, "UP", 0, latency)
}
func checkGroup(site models.Site) {
Mutex.RLock()
status := "UP"
hasChildren := false
allPaused := true
for _, child := range LiveState {
if child.ParentID != site.ID || child.Type == "group" {
continue
}
hasChildren = true
if !child.Paused {
allPaused = false
}
if child.Paused {
continue
}
if child.Status == "DOWN" || child.Status == "SSL EXP" {
status = "DOWN"
} else if child.Status == "PENDING" && status != "DOWN" {
status = "PENDING"
}
}
Mutex.RUnlock()
if !hasChildren {
status = "PENDING"
}
Mutex.Lock()
s := LiveState[site.ID]
s.Status = status
if hasChildren && allPaused {
s.Paused = true
}
LiveState[site.ID] = s
Mutex.Unlock()
}
func checkDNS(site models.Site) {
host := site.Hostname
if host == "" {
+84 -10
View File
@@ -34,6 +34,11 @@ var (
siteBorderStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#444"))
siteGroupStyle = lipgloss.NewStyle().
Padding(0, 1).
Bold(true).
Foreground(lipgloss.Color("#7D56F4"))
)
type siteFormData struct {
@@ -50,6 +55,7 @@ type siteFormData struct {
Timeout string
Description string
IgnoreTLS bool
GroupID string
}
func latencySparkline(latencies []time.Duration, width int) string {
@@ -222,10 +228,42 @@ func (m Model) viewSitesTab() string {
selectedVisual := m.cursor - m.tableOffset
var rows [][]string
var groupRows []int
for i := m.tableOffset; i < end; i++ {
site := m.sites[i]
hist, _ := monitor.GetHistory(site.ID)
if site.Type == "group" {
groupRows = append(groupRows, i-m.tableOffset)
arrow := "▾"
if m.collapsed[site.ID] {
arrow = "▸"
}
rows = append(rows, []string{
strconv.Itoa(i + 1),
m.zones.Mark(fmt.Sprintf("site-%d", i), arrow+" "+limitStr(site.Name, 11)),
"group",
fmtStatus(site.Status, site.Paused),
subtleStyle.Render("—"),
subtleStyle.Render("—"),
subtleStyle.Render(strings.Repeat("·", sparkWidth)),
subtleStyle.Render("-"),
subtleStyle.Render("—"),
})
continue
}
name := site.Name
if site.ParentID > 0 {
prefix := "├"
if i+1 >= len(m.sites) || m.sites[i+1].ParentID != site.ParentID {
prefix = "└"
}
name = prefix + " " + limitStr(name, 11)
} else {
name = limitStr(name, 13)
}
hist, _ := monitor.GetHistory(site.ID)
var spark string
if site.Type == "push" {
spark = heartbeatSparkline(hist.Statuses, sparkWidth)
@@ -235,7 +273,7 @@ func (m Model) viewSitesTab() string {
rows = append(rows, []string{
strconv.Itoa(i + 1),
m.zones.Mark(fmt.Sprintf("site-%d", i), limitStr(site.Name, 13)),
m.zones.Mark(fmt.Sprintf("site-%d", i), name),
site.Type,
fmtStatus(site.Status, site.Paused),
fmtLatency(site.Latency),
@@ -246,6 +284,15 @@ func (m Model) viewSitesTab() string {
})
}
isGroupRow := func(row int) bool {
for _, g := range groupRows {
if g == row {
return true
}
}
return false
}
tableWidth := m.termWidth - 6
if tableWidth < 40 {
tableWidth = 40
@@ -264,6 +311,9 @@ func (m Model) viewSitesTab() string {
if row == selectedVisual {
return siteSelectedStyle
}
if isGroupRow(row) {
return siteGroupStyle
}
return siteCellStyle
})
@@ -278,6 +328,7 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
Retries: "0",
Timeout: "5",
Port: "0",
GroupID: "0",
}
if m.editID > 0 {
@@ -296,6 +347,7 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
m.siteFormData.Timeout = strconv.Itoa(site.Timeout)
m.siteFormData.Description = site.Description
m.siteFormData.IgnoreTLS = site.IgnoreTLS
m.siteFormData.GroupID = strconv.Itoa(site.ParentID)
break
}
}
@@ -311,6 +363,13 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
}
}
groupOpts := []huh.Option[string]{huh.NewOption("None", "0")}
for _, s := range m.sites {
if s.Type == "group" && s.ID != m.editID {
groupOpts = append(groupOpts, huh.NewOption(s.Name, strconv.Itoa(s.ID)))
}
}
m.huhForm = huh.NewForm(
huh.NewGroup(
huh.NewInput().Title("Monitor Name").
@@ -331,12 +390,17 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
huh.NewOption("DNS", "dns"),
huh.NewOption("Group", "group"),
).Value(&m.siteFormData.SiteType),
huh.NewSelect[string]().Title("Alert Channel").
Options(alertOpts...).
Value(&m.siteFormData.AlertID),
).Title("Monitor Settings"),
huh.NewGroup(
huh.NewInput().Title("URL").
Placeholder("https://example.com").
Description("Required for HTTP monitors").
Value(&m.siteFormData.URL).
Validate(func(s string) error {
if m.siteFormData.SiteType == "push" {
if m.siteFormData.SiteType == "push" || m.siteFormData.SiteType == "group" {
return nil
}
if s == "" {
@@ -358,6 +422,9 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
Placeholder("60").
Value(&m.siteFormData.Interval).
Validate(func(s string) error {
if m.siteFormData.SiteType == "group" {
return nil
}
v, err := strconv.Atoi(s)
if err != nil {
return fmt.Errorf("must be a number")
@@ -367,11 +434,9 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
}
return nil
}),
huh.NewSelect[string]().Title("Alert Channel").
Options(alertOpts...).
Value(&m.siteFormData.AlertID),
).Title("Monitor Settings"),
huh.NewGroup(
huh.NewSelect[string]().Title("Parent Group").
Options(groupOpts...).
Value(&m.siteFormData.GroupID),
huh.NewInput().Title("Hostname / IP").
Placeholder("10.0.0.1").
Description("Target for ping/port/DNS monitors").
@@ -394,6 +459,9 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
Placeholder("5").
Value(&m.siteFormData.Timeout).
Validate(func(s string) error {
if m.siteFormData.SiteType == "group" {
return nil
}
v, err := strconv.Atoi(s)
if err != nil {
return fmt.Errorf("must be a number")
@@ -406,7 +474,9 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
huh.NewInput().Title("Description").
Placeholder("Optional description").
Value(&m.siteFormData.Description),
).Title("Connection"),
).Title("Connection").WithHideFunc(func() bool {
return m.siteFormData.SiteType == "group"
}),
huh.NewGroup(
huh.NewConfirm().Title("Monitor SSL Certificate?").
Value(&m.siteFormData.CheckSSL),
@@ -438,7 +508,9 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
}),
huh.NewConfirm().Title("Ignore TLS Errors?").
Value(&m.siteFormData.IgnoreTLS),
).Title("Advanced"),
).Title("Advanced").WithHideFunc(func() bool {
return m.siteFormData.SiteType == "group"
}),
).WithTheme(huh.ThemeDracula())
return m.huhForm.Init()
@@ -452,6 +524,7 @@ func (m *Model) submitSiteForm() {
retries, _ := strconv.Atoi(d.Retries)
port, _ := strconv.Atoi(d.Port)
timeout, _ := strconv.Atoi(d.Timeout)
groupID, _ := strconv.Atoi(d.GroupID)
if interval < 1 {
interval = 60
}
@@ -474,6 +547,7 @@ func (m *Model) submitSiteForm() {
Timeout: timeout,
Description: d.Description,
IgnoreTLS: d.IgnoreTLS,
ParentID: groupID,
}
if m.editID > 0 {
+41 -5
View File
@@ -67,6 +67,8 @@ type Model struct {
deleteName string
deleteTab int
collapsed map[int]bool
// harmonica animation state
pulseSpring harmonica.Spring
pulsePos float64
@@ -90,6 +92,7 @@ func InitialModel(isAdmin bool) Model {
isAdmin: isAdmin,
zones: z,
pulseSpring: spring,
collapsed: make(map[int]bool),
}
}
@@ -299,6 +302,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.state = stateFormUser
return m, m.initUserHuhForm()
}
case " ":
if m.currentTab == 0 && len(m.sites) > 0 && m.sites[m.cursor].Type == "group" {
gid := m.sites[m.cursor].ID
m.collapsed[gid] = !m.collapsed[gid]
m.refreshData()
}
case "p":
if m.currentTab == 0 && len(m.sites) > 0 {
site := m.sites[m.cursor]
@@ -421,13 +430,40 @@ func (m *Model) adjustCursor(newLen int) {
func (m *Model) refreshData() {
monitor.Mutex.RLock()
var sites []models.Site
var allSites []models.Site
for _, s := range monitor.LiveState {
sites = append(sites, s)
allSites = append(allSites, s)
}
monitor.Mutex.RUnlock()
sort.Slice(sites, func(i, j int) bool { return sites[i].ID < sites[j].ID })
m.sites = sites
var groups, ungrouped []models.Site
children := make(map[int][]models.Site)
for _, s := range allSites {
if s.Type == "group" {
groups = append(groups, s)
} else if s.ParentID > 0 {
children[s.ParentID] = append(children[s.ParentID], s)
} else {
ungrouped = append(ungrouped, s)
}
}
sort.Slice(groups, func(i, j int) bool { return groups[i].ID < groups[j].ID })
for pid := range children {
c := children[pid]
sort.Slice(c, func(i, j int) bool { return c[i].ID < c[j].ID })
children[pid] = c
}
sort.Slice(ungrouped, func(i, j int) bool { return ungrouped[i].ID < ungrouped[j].ID })
var ordered []models.Site
for _, g := range groups {
ordered = append(ordered, g)
if !m.collapsed[g.ID] {
ordered = append(ordered, children[g.ID]...)
}
}
ordered = append(ordered, ungrouped...)
m.sites = ordered
if store.Get() != nil {
m.alerts = store.Get().GetAllAlerts()
if m.isAdmin {
@@ -543,7 +579,7 @@ func (m Model) viewDashboard() string {
}
}
footer := subtleStyle.Render("\n[n] New [e/Enter] Edit [d] Delete [p] Pause [Tab/Click] Switch [Ctrl+L] Clear [q] Quit")
footer := subtleStyle.Render("\n[n] New [e/Enter] Edit [d] Delete [p] Pause [Space] Collapse [Tab/Click] Switch [q] Quit")
if m.currentTab == 3 {
footer = subtleStyle.Render("\n[n] Add User [d] Revoke [Tab/Click] Switch [Ctrl+L] Clear [q] Quit")
}