package tui import ( "fmt" "math" "strings" "time" "github.com/charmbracelet/lipgloss" ) func sinApprox(x float64) float64 { return math.Sin(x) } func (m Model) pulseIndicator() string { hasDown := false for _, s := range m.sites { if !s.Paused && !m.isMonitorInMaintenance(s.ID) && (s.Status == "DOWN" || s.Status == "SSL EXP") { hasDown = true break } } if m.demoMode { c := m.theme.Success if hasDown { c = m.theme.Danger } return lipgloss.NewStyle().Foreground(c).Render("●") } frame := m.tickCount % len(pulseFrames) brightness := int(m.pulsePos*155) + 100 if brightness > 255 { brightness = 255 } var color string if hasDown { color = fmt.Sprintf("#%02x%02x%02x", brightness, brightness/4, brightness/4) } else { 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 stateConfirmDelete: kind := "monitor" switch m.deleteTab { case 1: kind = "alert" case 4: kind = "maintenance window" case 5: kind = "user" } msg := dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName)) hint := subtleStyle.Render("[y] Confirm [n] Cancel") box := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(m.theme.Danger). Padding(1, 3). Render(msg + "\n\n" + hint) return lipgloss.NewStyle().Padding(2, 4).Render(box) case stateFormSite, stateFormAlert, stateFormUser, stateFormMaint: 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" if m.editID > 0 { title = fmt.Sprintf("Edit User #%d", m.editID) } case stateFormMaint: title = "New Maintenance Window" } formHeight := m.termHeight - 7 if formHeight < 5 { formHeight = 5 } m.huhForm.WithHeight(formHeight) 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 "" case stateDetail: return m.viewDetailPanel() case stateAlertDetail: return m.viewAlertDetailPanel() default: return m.zones.Scan(m.viewDashboard()) } } type dashboardStats struct { totalMonitors int downCount int lateCount int offlineNodes int activeMaint int } func (m Model) computeStats() dashboardStats { allSites := m.engine.GetAllSites() var s dashboardStats for _, site := range allSites { if site.Type == "group" { continue } s.totalMonitors++ if site.Paused || m.isMonitorInMaintenance(site.ID) { continue } switch site.Status { case "DOWN", "SSL EXP": s.downCount++ case "LATE": s.lateCount++ } } for _, n := range m.nodes { if !n.LastSeen.IsZero() && time.Since(n.LastSeen) > 5*time.Minute { s.offlineNodes++ } } for _, mw := range m.maintenanceWindows { now := time.Now() if !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now)) { s.activeMaint++ } } return s } func (m Model) viewDashboard() string { stats := m.computeStats() header := m.renderTabBar(stats) header = m.pulseIndicator() + " " + header var content string switch m.currentTab { case 0: content = m.viewSitesTab() case 1: content = m.viewAlertsTab() case 2: content = m.viewLogsTab() case 3: content = m.viewNodesTab() case 4: content = m.viewMaintTab() case 5: if m.isAdmin { content = m.viewUsersTab() } } footer := m.renderFooter(stats) s := lipgloss.NewStyle().Padding(1, 2) if m.termHeight > 0 { s = s.MaxHeight(m.termHeight) } return s.Render(header + "\n" + content + "\n" + footer) } func (m Model) renderTabBar(stats dashboardStats) string { var sitesLabel string if stats.downCount > 0 { sitesLabel = fmt.Sprintf("Sites (%d↓)", stats.downCount) } else if stats.lateCount > 0 { sitesLabel = fmt.Sprintf("Sites (%d⚠)", stats.lateCount) } else if stats.totalMonitors > 0 { sitesLabel = fmt.Sprintf("Sites (%d)", stats.totalMonitors) } else { sitesLabel = "Sites" } var nodesLabel string if stats.offlineNodes > 0 { nodesLabel = fmt.Sprintf("Nodes (%d!)", stats.offlineNodes) } else if len(m.nodes) > 0 { nodesLabel = fmt.Sprintf("Nodes (%d)", len(m.nodes)) } else { nodesLabel = "Nodes" } var maintLabel string if stats.activeMaint > 0 { maintLabel = fmt.Sprintf("Maint (%d)", stats.activeMaint) } else { maintLabel = "Maint" } tabs := []string{sitesLabel, "Alerts", "Logs", nodesLabel, maintLabel} 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)) } return lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...) } func (m Model) renderFooter(stats dashboardStats) string { if m.filterMode { cursor := lipgloss.NewStyle().Foreground(m.theme.Accent).Render("│") return "\n" + titleStyle.Render("/") + " " + m.filterText + cursor + " " + subtleStyle.Render("[Enter]Apply [Esc]Clear") } upCount := stats.totalMonitors - stats.downCount - stats.lateCount var upStr string if stats.downCount > 0 { upStr = dangerStyle.Render(fmt.Sprintf("%d/%d UP", upCount, stats.totalMonitors)) } else if stats.lateCount > 0 { upStr = warnStyle.Render(fmt.Sprintf("%d/%d UP", upCount, stats.totalMonitors)) } else { upStr = specialStyle.Render(fmt.Sprintf("%d/%d UP", upCount, stats.totalMonitors)) } statusParts := []string{upStr} if stats.lateCount > 0 { statusParts = append(statusParts, warnStyle.Render(fmt.Sprintf("%d LATE", stats.lateCount))) } if len(m.nodes) > 0 { online := 0 for _, n := range m.nodes { if !n.LastSeen.IsZero() && time.Since(n.LastSeen) < 60*time.Second { online++ } } probeLabel := "probes" if online == 1 { probeLabel = "probe" } statusParts = append(statusParts, fmt.Sprintf("%d %s", online, probeLabel)) } statusLine := strings.Join(statusParts, subtleStyle.Render(" · ")) var keys string switch m.currentTab { case 0: keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [T]Theme [Tab]Switch [q]Quit" case 1: keys = "[n]New [e]Edit [i]Info [d]Del [t]Test [T]Theme [Tab]Switch [q]Quit" case 2: keys = "[f]Filter [T]Theme [Tab]Switch [q]Quit" case 4: keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit" case 5: keys = "[n]Add [d]Revoke [T]Theme [Tab]Switch [q]Quit" default: keys = "[T]Theme [Tab]Switch [q]Quit" } footer := "\n" + statusLine + " " + subtleStyle.Render(keys) if m.filterText != "" && m.currentTab == 0 { footer = "\n" + subtleStyle.Render(fmt.Sprintf("filter: %s", m.filterText)) + " " + statusLine + " " + subtleStyle.Render(keys) } return footer }