diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 0672cbc..3f925dc 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -550,3 +550,85 @@ func (m *Model) submitSiteForm() { } m.state = stateDashboard } + +func (m Model) viewDetailPanel() string { + if m.cursor >= len(m.sites) { + return "" + } + site := m.sites[m.cursor] + hist, _ := m.engine.GetHistory(site.ID) + + var b strings.Builder + + title := titleStyle.Render(fmt.Sprintf(" %s", site.Name)) + b.WriteString(title + "\n\n") + + row := func(label, value string) { + b.WriteString(fmt.Sprintf(" %-16s %s\n", subtleStyle.Render(label), value)) + } + + row("Status", fmtStatus(site.Status, site.Paused)) + row("Type", site.Type) + if site.URL != "" { + row("URL", site.URL) + } + if site.Hostname != "" { + row("Host", site.Hostname) + } + if site.Port > 0 { + row("Port", strconv.Itoa(site.Port)) + } + row("Interval", fmt.Sprintf("%ds", site.Interval)) + row("Timeout", fmt.Sprintf("%ds", site.Timeout)) + row("Latency", fmtLatency(site.Latency)) + row("Uptime", fmtUptime(hist.TotalChecks, hist.UpChecks)) + + if site.Type == "http" { + row("Method", site.Method) + row("Codes", site.AcceptedCodes) + row("SSL", fmtSSL(site)) + if site.IgnoreTLS { + row("TLS Verify", dangerStyle.Render("disabled")) + } + } + + if site.MaxRetries > 0 { + row("Retries", fmtRetries(site)) + } + if site.Regions != "" { + row("Regions", site.Regions) + } + if site.Description != "" { + row("Description", site.Description) + } + if !site.LastCheck.IsZero() { + row("Last Check", site.LastCheck.Format("15:04:05")) + } + + probeResults := m.engine.GetProbeResults(site.ID) + if len(probeResults) > 0 { + b.WriteString("\n" + subtleStyle.Render(" PROBE RESULTS") + "\n") + for nodeID, result := range probeResults { + status := specialStyle.Render("UP") + if !result.IsUp { + status = dangerStyle.Render("DN") + } + latency := time.Duration(result.LatencyNs).Milliseconds() + ago := time.Since(result.CheckedAt).Truncate(time.Second) + b.WriteString(fmt.Sprintf(" %-14s %s %dms %s ago\n", nodeID, status, latency, ago)) + } + } + + b.WriteString("\n") + const sparkWidth = 40 + if site.Type == "push" { + b.WriteString(" " + heartbeatSparkline(hist.Statuses, sparkWidth)) + } else { + b.WriteString(" " + latencySparkline(hist.Latencies, sparkWidth)) + } + + b.WriteString("\n\n") + b.WriteString(subtleStyle.Render(" [i/Esc] Back [e] Edit [q] Quit")) + + return lipgloss.NewStyle().Padding(1, 2).Render(b.String()) +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index f188254..b7af72b 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -37,6 +37,7 @@ const ( stateDashboard sessionState = iota stateLogs stateUsers + stateDetail stateFormSite stateFormAlert stateFormUser @@ -247,6 +248,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } switch m.state { + case stateDetail: + switch msg.String() { + case "i", "esc": + m.state = stateDashboard + case "q": + return m, tea.Quit + } + return m, nil case stateDashboard, stateLogs, stateUsers: switch msg.String() { case "q": @@ -330,6 +339,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { _ = m.store.UpdateSitePaused(site.ID, site.Paused) m.refreshData() } + case "i": + if m.currentTab == 0 && len(m.sites) > 0 { + m.state = stateDetail + } case "d", "backspace": if m.currentTab == 0 && len(m.sites) > 0 { m.deleteID = m.sites[m.cursor].ID @@ -564,13 +577,37 @@ func (m Model) View() string { return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n\n" + m.huhForm.View() + "\n" + footer) } return "" + case stateDetail: + return m.viewDetailPanel() default: return m.zones.Scan(m.viewDashboard()) } } func (m Model) viewDashboard() string { - tabs := []string{"Sites", "Alerts", "Logs", "Nodes"} + downCount := 0 + for _, s := range m.sites { + if !s.Paused && (s.Status == "DOWN" || s.Status == "SSL EXP") { + downCount++ + } + } + offlineNodes := 0 + for _, n := range m.nodes { + if !n.LastSeen.IsZero() && time.Since(n.LastSeen) > 5*time.Minute { + offlineNodes++ + } + } + + sitesLabel := "Sites" + if downCount > 0 { + sitesLabel = fmt.Sprintf("Sites (%d↓)", downCount) + } + nodesLabel := "Nodes" + if offlineNodes > 0 { + nodesLabel = fmt.Sprintf("Nodes (%d!)", offlineNodes) + } + + tabs := []string{sitesLabel, "Alerts", "Logs", nodesLabel} if m.isAdmin { tabs = append(tabs, "Users") } @@ -605,10 +642,29 @@ func (m Model) viewDashboard() string { } } - footer := subtleStyle.Render("\n[n] New [e/Enter] Edit [d] Delete [p] Pause [Space] Collapse [Tab/Click] Switch [q] Quit") - if m.currentTab == 4 { - footer = subtleStyle.Render("\n[n] Add User [d] Revoke [Tab/Click] Switch [Ctrl+L] Clear [q] Quit") + upCount := len(m.sites) - downCount + statusParts := []string{fmt.Sprintf("%d/%d UP", upCount, len(m.sites))} + if len(m.nodes) > 0 { + online := 0 + for _, n := range m.nodes { + if !n.LastSeen.IsZero() && time.Since(n.LastSeen) < 60*time.Second { + online++ + } + } + statusParts = append(statusParts, fmt.Sprintf("%d probes", online)) } + statusLine := subtleStyle.Render(strings.Join(statusParts, " · ")) + + var keys string + switch m.currentTab { + case 0: + keys = "[n]New [e]Edit [i]Info [d]Del [p]Pause [Tab]Switch [q]Quit" + case 4: + keys = "[n]Add [d]Revoke [Tab]Switch [q]Quit" + default: + keys = "[Tab]Switch [q]Quit" + } + footer := "\n" + statusLine + " " + subtleStyle.Render(keys) s := lipgloss.NewStyle().Padding(1, 2) if m.termHeight > 0 { s = s.MaxHeight(m.termHeight)