Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
01dd53241a
|
|||
|
81f8c71b6f
|
|||
|
7109b6fa1c
|
|||
|
e5ac4a1fec
|
|||
|
065d5d74bb
|
|||
|
08f14f3af8
|
|||
|
5720fabdbc
|
|||
|
54299583d6
|
|||
|
c9bd9a5a2e
|
|||
|
66b0681a76
|
@@ -86,6 +86,11 @@ type ProbeNode struct {
|
|||||||
Version string
|
Version string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LogEntry struct {
|
||||||
|
Message string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
// AlertHealthRecord is the persisted send health of an alert channel. It lets the
|
// AlertHealthRecord is the persisted send health of an alert channel. It lets the
|
||||||
// "last sent" / health indicators survive restarts instead of resetting to "never".
|
// "last sent" / health indicators survive restarts instead of resetting to "never".
|
||||||
type AlertHealthRecord struct {
|
type AlertHealthRecord struct {
|
||||||
|
|||||||
+13
-11
@@ -40,7 +40,7 @@ type Engine struct {
|
|||||||
liveState map[int]models.Site
|
liveState map[int]models.Site
|
||||||
|
|
||||||
logMu sync.RWMutex
|
logMu sync.RWMutex
|
||||||
logStore []string
|
logStore []models.LogEntry
|
||||||
|
|
||||||
activeMu sync.RWMutex
|
activeMu sync.RWMutex
|
||||||
isActive bool
|
isActive bool
|
||||||
@@ -152,11 +152,13 @@ func fmtDurationShort(d time.Duration) string {
|
|||||||
// appendLog adds a timestamped entry to the in-memory ring buffer and returns
|
// appendLog adds a timestamped entry to the in-memory ring buffer and returns
|
||||||
// it. It never touches the database, so it is safe to call from the db-write
|
// it. It never touches the database, so it is safe to call from the db-write
|
||||||
// drop/error path without recursing back through the write queue.
|
// drop/error path without recursing back through the write queue.
|
||||||
func (e *Engine) appendLog(msg string) string {
|
func (e *Engine) appendLog(msg string) models.LogEntry {
|
||||||
ts := time.Now().Format("15:04:05")
|
entry := models.LogEntry{
|
||||||
entry := fmt.Sprintf("[%s] %s", ts, sanitizeLog(msg))
|
Message: sanitizeLog(msg),
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
e.logMu.Lock()
|
e.logMu.Lock()
|
||||||
e.logStore = append([]string{entry}, e.logStore...)
|
e.logStore = append([]models.LogEntry{entry}, e.logStore...)
|
||||||
if len(e.logStore) > maxLogEntries {
|
if len(e.logStore) > maxLogEntries {
|
||||||
e.logStore = e.logStore[:maxLogEntries]
|
e.logStore = e.logStore[:maxLogEntries]
|
||||||
}
|
}
|
||||||
@@ -166,7 +168,7 @@ func (e *Engine) appendLog(msg string) string {
|
|||||||
|
|
||||||
func (e *Engine) AddLog(msg string) {
|
func (e *Engine) AddLog(msg string) {
|
||||||
entry := e.appendLog(msg)
|
entry := e.appendLog(msg)
|
||||||
e.enqueueWrite(writeLog{message: entry})
|
e.enqueueWrite(writeLog{message: entry.Message})
|
||||||
}
|
}
|
||||||
|
|
||||||
// enqueueWrite hands a persistence task to the writer goroutine without
|
// enqueueWrite hands a persistence task to the writer goroutine without
|
||||||
@@ -246,16 +248,16 @@ func (e *Engine) Stop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) InitLogs() {
|
func (e *Engine) InitLogs() {
|
||||||
logs, err := e.db.LoadLogs(context.Background(), maxLogEntries)
|
entries, err := e.db.LoadLogs(context.Background(), maxLogEntries)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(logs) == 0 {
|
if len(entries) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
e.logMu.Lock()
|
e.logMu.Lock()
|
||||||
defer e.logMu.Unlock()
|
defer e.logMu.Unlock()
|
||||||
e.logStore = logs
|
e.logStore = entries
|
||||||
}
|
}
|
||||||
|
|
||||||
// InitAlertHealth restores persisted alert send health so the dashboard shows real
|
// InitAlertHealth restores persisted alert send health so the dashboard shows real
|
||||||
@@ -278,10 +280,10 @@ func (e *Engine) InitAlertHealth() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) GetLogs() []string {
|
func (e *Engine) GetLogs() []models.LogEntry {
|
||||||
e.logMu.RLock()
|
e.logMu.RLock()
|
||||||
defer e.logMu.RUnlock()
|
defer e.logMu.RUnlock()
|
||||||
logs := make([]string, len(e.logStore))
|
logs := make([]models.LogEntry, len(e.logStore))
|
||||||
copy(logs, e.logStore)
|
copy(logs, e.logStore)
|
||||||
return logs
|
return logs
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ type mockStore struct {
|
|||||||
sites []models.SiteConfig
|
sites []models.SiteConfig
|
||||||
alerts map[int]models.AlertConfig
|
alerts map[int]models.AlertConfig
|
||||||
maintenance map[int]bool
|
maintenance map[int]bool
|
||||||
logs []string
|
logs []models.LogEntry
|
||||||
history map[int][]models.CheckRecord
|
history map[int][]models.CheckRecord
|
||||||
savedChecks []savedCheck
|
savedChecks []savedCheck
|
||||||
savedLogs []string
|
savedLogs []string
|
||||||
@@ -103,7 +103,7 @@ func (m *mockStore) SaveLog(_ context.Context, msg string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockStore) LoadLogs(_ context.Context, _ int) ([]string, error) {
|
func (m *mockStore) LoadLogs(_ context.Context, _ int) ([]models.LogEntry, error) {
|
||||||
return m.logs, nil
|
return m.logs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,7 +330,7 @@ func TestHandleStatusChange_AlertSuppressedMaintenance(t *testing.T) {
|
|||||||
logs := e.GetLogs()
|
logs := e.GetLogs()
|
||||||
found := false
|
found := false
|
||||||
for _, l := range logs {
|
for _, l := range logs {
|
||||||
if containsStr(l, "suppressed") {
|
if containsStr(l.Message, "suppressed") {
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -973,14 +973,17 @@ func TestAddLog_PrependAndCap(t *testing.T) {
|
|||||||
if len(logs) != 100 {
|
if len(logs) != 100 {
|
||||||
t.Errorf("expected 100 logs, got %d", len(logs))
|
t.Errorf("expected 100 logs, got %d", len(logs))
|
||||||
}
|
}
|
||||||
if !containsStr(logs[0], "log-104") {
|
if !containsStr(logs[0].Message, "log-104") {
|
||||||
t.Errorf("expected newest log first, got %s", logs[0])
|
t.Errorf("expected newest log first, got %s", logs[0].Message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInitLogs_LoadsFromDB(t *testing.T) {
|
func TestInitLogs_LoadsFromDB(t *testing.T) {
|
||||||
ms := newMockStore()
|
ms := newMockStore()
|
||||||
ms.logs = []string{"old-log-1", "old-log-2"}
|
ms.logs = []models.LogEntry{
|
||||||
|
{Message: "old-log-1"},
|
||||||
|
{Message: "old-log-2"},
|
||||||
|
}
|
||||||
e := newTestEngine(ms)
|
e := newTestEngine(ms)
|
||||||
e.InitLogs()
|
e.InitLogs()
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
@@ -554,21 +555,27 @@ func (s *SQLStore) PruneLogs(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLStore) LoadLogs(ctx context.Context, limit int) ([]string, error) {
|
func (s *SQLStore) LoadLogs(ctx context.Context, limit int) ([]models.LogEntry, error) {
|
||||||
rows, err := s.db.QueryContext(ctx, s.q("SELECT message FROM logs ORDER BY created_at DESC LIMIT ?"), limit)
|
rows, err := s.db.QueryContext(ctx, s.q("SELECT message, created_at FROM logs ORDER BY created_at DESC, id DESC LIMIT ?"), limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var logs []string
|
var entries []models.LogEntry
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var msg string
|
var e models.LogEntry
|
||||||
if err := rows.Scan(&msg); err != nil {
|
if err := rows.Scan(&e.Message, &e.CreatedAt); err != nil {
|
||||||
return logs, err
|
return entries, err
|
||||||
}
|
}
|
||||||
logs = append(logs, msg)
|
// Strip legacy [HH:MM] or [HH:MM:SS] prefix from pre-refactor entries.
|
||||||
|
if len(e.Message) > 3 && e.Message[0] == '[' {
|
||||||
|
if idx := strings.Index(e.Message, "]"); idx > 0 && idx < 12 {
|
||||||
|
e.Message = strings.TrimSpace(e.Message[idx+1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries = append(entries, e)
|
||||||
}
|
}
|
||||||
return logs, rows.Err()
|
return entries, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLStore) LoadAllHistory(ctx context.Context, limit int) (map[int][]models.CheckRecord, error) {
|
func (s *SQLStore) LoadAllHistory(ctx context.Context, limit int) (map[int][]models.CheckRecord, error) {
|
||||||
|
|||||||
@@ -407,7 +407,7 @@ func TestPruneLogs(t *testing.T) {
|
|||||||
// LoadLogs ordering ties when rows share a created_at second).
|
// LoadLogs ordering ties when rows share a created_at second).
|
||||||
present := make(map[string]bool, len(logs))
|
present := make(map[string]bool, len(logs))
|
||||||
for _, l := range logs {
|
for _, l := range logs {
|
||||||
present[l] = true
|
present[l.Message] = true
|
||||||
}
|
}
|
||||||
if !present[fmt.Sprintf("log %d", maxLogRows+50-1)] {
|
if !present[fmt.Sprintf("log %d", maxLogRows+50-1)] {
|
||||||
t.Error("newest log was pruned")
|
t.Error("newest log was pruned")
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ type Store interface {
|
|||||||
|
|
||||||
// Logs
|
// Logs
|
||||||
SaveLog(ctx context.Context, message string) error
|
SaveLog(ctx context.Context, message string) error
|
||||||
LoadLogs(ctx context.Context, limit int) ([]string, error)
|
LoadLogs(ctx context.Context, limit int) ([]models.LogEntry, error)
|
||||||
PruneLogs(ctx context.Context) error
|
PruneLogs(ctx context.Context) error
|
||||||
|
|
||||||
// Maintenance Windows
|
// Maintenance Windows
|
||||||
|
|||||||
@@ -212,8 +212,8 @@ func (m *BaseMock) SaveLog(ctx context.Context, message string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *BaseMock) LoadLogs(_ context.Context, _ int) ([]string, error) { return nil, nil }
|
func (m *BaseMock) LoadLogs(_ context.Context, _ int) ([]models.LogEntry, error) { return nil, nil }
|
||||||
func (m *BaseMock) PruneLogs(_ context.Context) error { return nil }
|
func (m *BaseMock) PruneLogs(_ context.Context) error { return nil }
|
||||||
|
|
||||||
func (m *BaseMock) GetActiveMaintenanceWindows(ctx context.Context) ([]models.MaintenanceWindow, error) {
|
func (m *BaseMock) GetActiveMaintenanceWindows(ctx context.Context) ([]models.MaintenanceWindow, error) {
|
||||||
if m.GetActiveMaintenanceWindowsFunc != nil {
|
if m.GetActiveMaintenanceWindowsFunc != nil {
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m Model) titledPanel(title, content string, width int, focused bool) string {
|
||||||
|
borderColor := m.theme.Border
|
||||||
|
titleColor := m.theme.Muted
|
||||||
|
if focused {
|
||||||
|
borderColor = m.theme.Accent
|
||||||
|
titleColor = m.theme.Accent
|
||||||
|
}
|
||||||
|
|
||||||
|
bc := lipgloss.NewStyle().Foreground(borderColor)
|
||||||
|
tc := lipgloss.NewStyle().Foreground(titleColor).Bold(true)
|
||||||
|
|
||||||
|
innerW := width - 2
|
||||||
|
if innerW < 10 {
|
||||||
|
innerW = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
titleRendered := tc.Render(" " + title + " ")
|
||||||
|
titleLen := len([]rune(title)) + 2
|
||||||
|
fillLen := innerW - titleLen - 1
|
||||||
|
if fillLen < 0 {
|
||||||
|
fillLen = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
top := bc.Render("╭─") + titleRendered + bc.Render(strings.Repeat("─", fillLen)+"╮")
|
||||||
|
|
||||||
|
contentStyle := lipgloss.NewStyle().Width(innerW).MaxWidth(innerW)
|
||||||
|
inner := contentStyle.Render(content)
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
lines = append(lines, top)
|
||||||
|
for _, line := range strings.Split(inner, "\n") {
|
||||||
|
lines = append(lines, bc.Render("│")+line+strings.Repeat(" ", max(0, innerW-lipgloss.Width(line)))+bc.Render("│"))
|
||||||
|
}
|
||||||
|
lines = append(lines, bc.Render("╰"+strings.Repeat("─", innerW)+"╯"))
|
||||||
|
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func max(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
+12
-25
@@ -3,6 +3,8 @@ package tui
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type logSeverity int
|
type logSeverity int
|
||||||
@@ -15,8 +17,8 @@ const (
|
|||||||
severitySystem
|
severitySystem
|
||||||
)
|
)
|
||||||
|
|
||||||
func classifyLog(line string) logSeverity {
|
func classifyLog(msg string) logSeverity {
|
||||||
lower := strings.ToLower(line)
|
lower := strings.ToLower(msg)
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(lower, "confirmed down"),
|
case strings.Contains(lower, "confirmed down"),
|
||||||
strings.Contains(lower, "is down"),
|
strings.Contains(lower, "is down"),
|
||||||
@@ -63,44 +65,29 @@ func (m Model) renderLogTag(sev logSeverity) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) renderLogLine(line string) string {
|
func (m Model) renderLogLine(entry models.LogEntry) string {
|
||||||
sev := classifyLog(line)
|
sev := classifyLog(entry.Message)
|
||||||
tag := m.renderLogTag(sev)
|
tag := m.renderLogTag(sev)
|
||||||
|
ts := m.st.subtleStyle.Render(entry.CreatedAt.Local().Format("01/02 15:04"))
|
||||||
ts := ""
|
return fmt.Sprintf(" %s %s %s", ts, tag, entry.Message)
|
||||||
msg := line
|
|
||||||
if len(line) > 10 && line[0] == '[' {
|
|
||||||
if idx := strings.Index(line, "]"); idx > 0 && idx < 12 {
|
|
||||||
ts = m.st.subtleStyle.Render(line[1:idx])
|
|
||||||
msg = strings.TrimSpace(line[idx+1:])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ts != "" {
|
|
||||||
return fmt.Sprintf(" %s %s %s", ts, tag, msg)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf(" %s %s", tag, msg)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// refreshLogContent rebuilds the log viewport from the full engine log list,
|
|
||||||
// filtering before windowing so the entry count and "(n hidden)" reflect all
|
|
||||||
// logs, not just the visible viewport slice.
|
|
||||||
func (m *Model) refreshLogContent() {
|
func (m *Model) refreshLogContent() {
|
||||||
var rendered []string
|
var rendered []string
|
||||||
total := 0
|
total := 0
|
||||||
shown := 0
|
shown := 0
|
||||||
|
|
||||||
for _, line := range m.engine.GetLogs() {
|
for _, entry := range m.engine.GetLogs() {
|
||||||
if strings.TrimSpace(line) == "" {
|
if strings.TrimSpace(entry.Message) == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
total++
|
total++
|
||||||
sev := classifyLog(line)
|
sev := classifyLog(entry.Message)
|
||||||
if m.logFilterImportant && !isImportantLog(sev) {
|
if m.logFilterImportant && !isImportantLog(sev) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
shown++
|
shown++
|
||||||
rendered = append(rendered, m.renderLogLine(line))
|
rendered = append(rendered, m.renderLogLine(entry))
|
||||||
}
|
}
|
||||||
|
|
||||||
m.logTotal = total
|
m.logTotal = total
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ package tui
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) renderCompactLogLine(line string, maxW int) string {
|
func (m Model) renderCompactLogLine(entry models.LogEntry, maxW int) string {
|
||||||
sev := classifyLog(line)
|
sev := classifyLog(entry.Message)
|
||||||
|
|
||||||
var tag string
|
var tag string
|
||||||
switch sev {
|
switch sev {
|
||||||
@@ -23,36 +24,23 @@ func (m Model) renderCompactLogLine(line string, maxW int) string {
|
|||||||
tag = m.st.subtleStyle.Render("·")
|
tag = m.st.subtleStyle.Render("·")
|
||||||
}
|
}
|
||||||
|
|
||||||
ts := ""
|
ts := entry.CreatedAt.Local().Format("01/02 15:04")
|
||||||
msg := line
|
|
||||||
if len(line) > 10 && line[0] == '[' {
|
|
||||||
if idx := strings.Index(line, "]"); idx > 0 && idx < 12 {
|
|
||||||
ts = line[1:idx]
|
|
||||||
msg = strings.TrimSpace(line[idx+1:])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
msg := entry.Message
|
||||||
msg = strings.TrimPrefix(msg, "Monitor ")
|
msg = strings.TrimPrefix(msg, "Monitor ")
|
||||||
msg = strings.TrimPrefix(msg, "Push ")
|
msg = strings.TrimPrefix(msg, "Push ")
|
||||||
|
|
||||||
// prefix: " HH:MM ● " = 9 visible chars, or " ● " = 3 without timestamp
|
prefixW := len(ts) + 4
|
||||||
prefixW := 3
|
|
||||||
if ts != "" {
|
|
||||||
prefixW = len(ts) + 4
|
|
||||||
}
|
|
||||||
msgW := maxW - prefixW
|
msgW := maxW - prefixW
|
||||||
if msgW < 5 {
|
if msgW < 5 {
|
||||||
msgW = 5
|
msgW = 5
|
||||||
}
|
}
|
||||||
msg = limitStr(msg, msgW)
|
msg = limitStr(msg, msgW)
|
||||||
|
|
||||||
if ts != "" {
|
return " " + m.st.subtleStyle.Render(ts) + " " + tag + " " + msg
|
||||||
return " " + m.st.subtleStyle.Render(ts) + " " + tag + " " + msg
|
|
||||||
}
|
|
||||||
return " " + tag + " " + msg
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) viewLogsSidebar(width int) string {
|
func (m Model) viewLogsSidebar(width, maxLines int) string {
|
||||||
logs := m.engine.GetLogs()
|
logs := m.engine.GetLogs()
|
||||||
if len(logs) == 0 {
|
if len(logs) == 0 {
|
||||||
return m.st.subtleStyle.Render(" No logs yet")
|
return m.st.subtleStyle.Render(" No logs yet")
|
||||||
@@ -60,16 +48,51 @@ func (m Model) viewLogsSidebar(width int) string {
|
|||||||
|
|
||||||
sidebarStyle := lipgloss.NewStyle().Width(width).MaxWidth(width)
|
sidebarStyle := lipgloss.NewStyle().Width(width).MaxWidth(width)
|
||||||
|
|
||||||
var lines []string
|
var all []string
|
||||||
for _, line := range logs {
|
for _, entry := range logs {
|
||||||
if strings.TrimSpace(line) == "" {
|
if strings.TrimSpace(entry.Message) == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if m.logFilterImportant && !isImportantLog(classifyLog(line)) {
|
if m.logFilterImportant && !isImportantLog(classifyLog(entry.Message)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
lines = append(lines, m.renderCompactLogLine(line, width))
|
all = append(all, m.renderCompactLogLine(entry, width))
|
||||||
}
|
}
|
||||||
|
|
||||||
return "\n" + sidebarStyle.Render(strings.Join(lines, "\n"))
|
start := m.logScrollOffset
|
||||||
|
if start > len(all) {
|
||||||
|
start = len(all)
|
||||||
|
}
|
||||||
|
end := start + maxLines
|
||||||
|
if end > len(all) {
|
||||||
|
end = len(all)
|
||||||
|
}
|
||||||
|
visible := all[start:end]
|
||||||
|
|
||||||
|
return sidebarStyle.Render(strings.Join(visible, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) scrollLogs(delta int) {
|
||||||
|
logs := m.engine.GetLogs()
|
||||||
|
total := 0
|
||||||
|
for _, entry := range logs {
|
||||||
|
if strings.TrimSpace(entry.Message) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m.logFilterImportant && !isImportantLog(classifyLog(entry.Message)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total++
|
||||||
|
}
|
||||||
|
|
||||||
|
m.logScrollOffset += delta
|
||||||
|
if m.logScrollOffset < 0 {
|
||||||
|
m.logScrollOffset = 0
|
||||||
|
}
|
||||||
|
if m.logScrollOffset > total-1 {
|
||||||
|
m.logScrollOffset = total - 1
|
||||||
|
}
|
||||||
|
if m.logScrollOffset < 0 {
|
||||||
|
m.logScrollOffset = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,8 +52,7 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
|
|||||||
}
|
}
|
||||||
|
|
||||||
t := table.New().
|
t := table.New().
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.HiddenBorder()).
|
||||||
BorderStyle(m.st.tableBorderStyle).
|
|
||||||
Width(tableWidth).
|
Width(tableWidth).
|
||||||
Headers(headers...).
|
Headers(headers...).
|
||||||
Rows(rows...).
|
Rows(rows...).
|
||||||
@@ -94,5 +93,5 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
|
|||||||
return base
|
return base
|
||||||
})
|
})
|
||||||
|
|
||||||
return "\n" + t.Render()
|
return t.Render()
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-1
@@ -96,6 +96,12 @@ const (
|
|||||||
sectionUsers = 2
|
sectionUsers = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
panelMonitors = 0
|
||||||
|
panelLogs = 1
|
||||||
|
panelDetail = 2
|
||||||
|
)
|
||||||
|
|
||||||
type sessionState int
|
type sessionState int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -124,6 +130,8 @@ type Model struct {
|
|||||||
termWidth int
|
termWidth int
|
||||||
termHeight int
|
termHeight int
|
||||||
contentWidth int
|
contentWidth int
|
||||||
|
focusedPanel int
|
||||||
|
logScrollOffset int
|
||||||
editID int
|
editID int
|
||||||
editToken string
|
editToken string
|
||||||
|
|
||||||
@@ -179,7 +187,7 @@ type Model struct {
|
|||||||
lastTabLoad time.Time // last dispatch of loadTabDataCmd (throttle)
|
lastTabLoad time.Time // last dispatch of loadTabDataCmd (throttle)
|
||||||
tabSeq int // seq of the newest issued tab-data load
|
tabSeq int // seq of the newest issued tab-data load
|
||||||
|
|
||||||
// detail-panel state-change history, loaded on enter so View does no DB IO
|
detailOpen bool
|
||||||
detailChanges []models.StateChange
|
detailChanges []models.StateChange
|
||||||
detailChangesSiteID int
|
detailChangesSiteID int
|
||||||
|
|
||||||
@@ -211,6 +219,8 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
detailPref, _ := s.GetPreference(context.Background(), "detail_open")
|
||||||
|
|
||||||
return Model{
|
return Model{
|
||||||
state: stateDashboard,
|
state: stateDashboard,
|
||||||
logViewport: vpLogs,
|
logViewport: vpLogs,
|
||||||
@@ -224,6 +234,7 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version stri
|
|||||||
theme: theme,
|
theme: theme,
|
||||||
themeIndex: themeIdx,
|
themeIndex: themeIdx,
|
||||||
st: newStyles(theme),
|
st: newStyles(theme),
|
||||||
|
detailOpen: detailPref == "true",
|
||||||
demoMode: os.Getenv("UPTOP_DEMO") == "1",
|
demoMode: os.Getenv("UPTOP_DEMO") == "1",
|
||||||
version: version,
|
version: version,
|
||||||
sparkTooltipIdx: -1,
|
sparkTooltipIdx: -1,
|
||||||
|
|||||||
+116
-5
@@ -142,11 +142,16 @@ func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const detailInlineHeight = 8
|
||||||
|
|
||||||
func (m *Model) recalcLayout() {
|
func (m *Model) recalcLayout() {
|
||||||
chrome := chromeBase
|
chrome := chromeBase
|
||||||
if m.filterMode || m.filterText != "" {
|
if m.filterMode || m.filterText != "" {
|
||||||
chrome++
|
chrome++
|
||||||
}
|
}
|
||||||
|
if m.detailOpen && m.currentTab == tabMonitors {
|
||||||
|
chrome += detailInlineHeight
|
||||||
|
}
|
||||||
m.maxTableRows = m.termHeight - chrome
|
m.maxTableRows = m.termHeight - chrome
|
||||||
if m.maxTableRows < 1 {
|
if m.maxTableRows < 1 {
|
||||||
m.maxTableRows = 1
|
m.maxTableRows = 1
|
||||||
@@ -269,6 +274,15 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.currentTab == tabMonitors && m.focusedPanel == panelLogs {
|
||||||
|
if msg.Button == tea.MouseButtonWheelUp {
|
||||||
|
m.scrollLogs(-3)
|
||||||
|
} else {
|
||||||
|
m.scrollLogs(3)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
listLen := m.currentListLen()
|
listLen := m.currentListLen()
|
||||||
if msg.Button == tea.MouseButtonWheelUp {
|
if msg.Button == tea.MouseButtonWheelUp {
|
||||||
if m.cursor > 0 {
|
if m.cursor > 0 {
|
||||||
@@ -286,6 +300,9 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.syncSelectedID()
|
m.syncSelectedID()
|
||||||
|
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
|
||||||
|
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,23 +534,45 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
case "tab":
|
case "tab":
|
||||||
m.switchTab(m.currentTab + 1)
|
m.switchTab(m.currentTab + 1)
|
||||||
case "left", "h":
|
case "left":
|
||||||
if m.currentTab == tabSettings {
|
if m.currentTab == tabSettings {
|
||||||
m.switchSettingsSection(m.settingsSection - 1)
|
m.switchSettingsSection(m.settingsSection - 1)
|
||||||
}
|
}
|
||||||
case "right", "l":
|
case "right":
|
||||||
if m.currentTab == tabSettings {
|
if m.currentTab == tabSettings {
|
||||||
m.switchSettingsSection(m.settingsSection + 1)
|
m.switchSettingsSection(m.settingsSection + 1)
|
||||||
}
|
}
|
||||||
|
case "l":
|
||||||
|
switch m.currentTab {
|
||||||
|
case tabSettings:
|
||||||
|
m.switchSettingsSection(m.settingsSection + 1)
|
||||||
|
case tabMonitors:
|
||||||
|
if m.focusedPanel == panelLogs {
|
||||||
|
m.focusedPanel = panelMonitors
|
||||||
|
} else {
|
||||||
|
m.focusedPanel = panelLogs
|
||||||
|
}
|
||||||
|
}
|
||||||
case "up", "k":
|
case "up", "k":
|
||||||
|
if m.currentTab == tabMonitors && m.focusedPanel == panelLogs {
|
||||||
|
m.scrollLogs(-1)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
if m.cursor > 0 {
|
if m.cursor > 0 {
|
||||||
m.cursor--
|
m.cursor--
|
||||||
if m.cursor < m.tableOffset {
|
if m.cursor < m.tableOffset {
|
||||||
m.tableOffset = m.cursor
|
m.tableOffset = m.cursor
|
||||||
}
|
}
|
||||||
m.syncSelectedID()
|
m.syncSelectedID()
|
||||||
|
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
|
||||||
|
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case "down", "j":
|
case "down", "j":
|
||||||
|
if m.currentTab == tabMonitors && m.focusedPanel == panelLogs {
|
||||||
|
m.scrollLogs(1)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
max := m.currentListLen() - 1
|
max := m.currentListLen() - 1
|
||||||
if m.cursor < max {
|
if m.cursor < max {
|
||||||
m.cursor++
|
m.cursor++
|
||||||
@@ -541,10 +580,19 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.tableOffset++
|
m.tableOffset++
|
||||||
}
|
}
|
||||||
m.syncSelectedID()
|
m.syncSelectedID()
|
||||||
|
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
|
||||||
|
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case "n":
|
case "n":
|
||||||
return m.handleNewItem()
|
return m.handleNewItem()
|
||||||
case "e", "enter":
|
case "enter":
|
||||||
|
if m.currentTab == tabMonitors && len(m.sites) > 0 {
|
||||||
|
m.state = stateDetail
|
||||||
|
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||||
|
}
|
||||||
|
return m.handleEditItem()
|
||||||
|
case "e":
|
||||||
return m.handleEditItem()
|
return m.handleEditItem()
|
||||||
case "t":
|
case "t":
|
||||||
if m.currentTab == tabSettings && m.settingsSection == sectionAlerts && len(m.alerts) > 0 {
|
if m.currentTab == tabSettings && m.settingsSection == sectionAlerts && len(m.alerts) > 0 {
|
||||||
@@ -574,11 +622,59 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
case "i":
|
case "i":
|
||||||
if m.currentTab == tabMonitors && len(m.sites) > 0 {
|
if m.currentTab == tabMonitors && len(m.sites) > 0 {
|
||||||
m.state = stateDetail
|
m.detailOpen = !m.detailOpen
|
||||||
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
m.recalcLayout()
|
||||||
|
st := m.store
|
||||||
|
open := m.detailOpen
|
||||||
|
var cmd tea.Cmd
|
||||||
|
if m.detailOpen {
|
||||||
|
cmd = m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||||
|
}
|
||||||
|
saveCmd := writeCmd("Save detail preference", func() error {
|
||||||
|
v := "false"
|
||||||
|
if open {
|
||||||
|
v = "true"
|
||||||
|
}
|
||||||
|
return st.SetPreference(context.Background(), "detail_open", v)
|
||||||
|
})
|
||||||
|
if cmd != nil {
|
||||||
|
return m, tea.Batch(cmd, saveCmd)
|
||||||
|
}
|
||||||
|
return m, saveCmd
|
||||||
} else if m.currentTab == tabSettings && m.settingsSection == sectionAlerts && len(m.alerts) > 0 {
|
} else if m.currentTab == tabSettings && m.settingsSection == sectionAlerts && len(m.alerts) > 0 {
|
||||||
m.state = stateAlertDetail
|
m.state = stateAlertDetail
|
||||||
}
|
}
|
||||||
|
case "esc":
|
||||||
|
if m.currentTab == tabMonitors {
|
||||||
|
if m.focusedPanel != panelMonitors {
|
||||||
|
m.focusedPanel = panelMonitors
|
||||||
|
} else if m.detailOpen {
|
||||||
|
m.detailOpen = false
|
||||||
|
m.recalcLayout()
|
||||||
|
st := m.store
|
||||||
|
return m, writeCmd("Save detail preference", func() error {
|
||||||
|
return st.SetPreference(context.Background(), "detail_open", "false")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "h":
|
||||||
|
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
|
||||||
|
site := m.sites[m.cursor]
|
||||||
|
m.historySiteName = site.Name
|
||||||
|
m.historySiteID = site.ID
|
||||||
|
m.historyChanges = nil
|
||||||
|
m.historyViewport = viewport.New(
|
||||||
|
m.termWidth-chromePadH,
|
||||||
|
m.termHeight-10,
|
||||||
|
)
|
||||||
|
m.historyViewport.SetContent("\n Loading state history...")
|
||||||
|
m.state = stateHistory
|
||||||
|
return m, m.loadHistoryCmd(site.ID)
|
||||||
|
}
|
||||||
|
case "s":
|
||||||
|
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
|
||||||
|
return m, m.openSLAView(m.sites[m.cursor])
|
||||||
|
}
|
||||||
case "x":
|
case "x":
|
||||||
if m.currentTab == tabMaint && len(m.maintenanceWindows) > 0 {
|
if m.currentTab == tabMaint && len(m.maintenanceWindows) > 0 {
|
||||||
mw := m.maintenanceWindows[m.cursor]
|
mw := m.maintenanceWindows[m.cursor]
|
||||||
@@ -707,6 +803,18 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.currentTab == tabMonitors {
|
||||||
|
if m.zones.Get("panel-monitors").InBounds(msg) {
|
||||||
|
m.focusedPanel = panelMonitors
|
||||||
|
} else if m.zones.Get("panel-logs").InBounds(msg) {
|
||||||
|
m.focusedPanel = panelLogs
|
||||||
|
return m, nil
|
||||||
|
} else if m.detailOpen && m.zones.Get("panel-detail").InBounds(msg) {
|
||||||
|
m.focusedPanel = panelDetail
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
prefix, listLen := m.currentZonePrefix()
|
prefix, listLen := m.currentZonePrefix()
|
||||||
end := m.tableOffset + m.maxTableRows
|
end := m.tableOffset + m.maxTableRows
|
||||||
if end > listLen {
|
if end > listLen {
|
||||||
@@ -716,6 +824,9 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|||||||
if m.zones.Get(fmt.Sprintf("%s-%d", prefix, i)).InBounds(msg) {
|
if m.zones.Get(fmt.Sprintf("%s-%d", prefix, i)).InBounds(msg) {
|
||||||
m.cursor = i
|
m.cursor = i
|
||||||
m.syncSelectedID()
|
m.syncSelectedID()
|
||||||
|
if m.detailOpen && m.currentTab == tabMonitors {
|
||||||
|
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,7 +300,7 @@ func TestWriteDoneMsg_LogsErrorAndReloads(t *testing.T) {
|
|||||||
mm := updated.(Model)
|
mm := updated.(Model)
|
||||||
found := false
|
found := false
|
||||||
for _, line := range mm.engine.GetLogs() {
|
for _, line := range mm.engine.GetLogs() {
|
||||||
if strings.Contains(line, "Delete site failed: boom") {
|
if strings.Contains(line.Message, "Delete site failed: boom") {
|
||||||
found = true
|
found = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,15 +157,39 @@ func (m Model) viewDashboard() string {
|
|||||||
availW := m.termWidth - chromePadH
|
availW := m.termWidth - chromePadH
|
||||||
leftW := availW * 70 / 100
|
leftW := availW * 70 / 100
|
||||||
rightW := availW - leftW
|
rightW := availW - leftW
|
||||||
m.contentWidth = leftW
|
m.contentWidth = leftW - 2
|
||||||
monitors := m.viewSitesTab()
|
monitors := m.viewSitesTab()
|
||||||
left := lipgloss.NewStyle().Width(leftW).Render(monitors)
|
monPanel := m.zones.Mark("panel-monitors", m.titledPanel("Monitors", monitors, leftW, m.focusedPanel == panelMonitors))
|
||||||
sidebar := m.viewLogsSidebar(rightW)
|
sidebarContent := m.viewLogsSidebar(rightW-2, m.maxTableRows)
|
||||||
right := lipgloss.NewStyle().Width(rightW).Render(sidebar)
|
logPanel := m.zones.Mark("panel-logs", m.titledPanel("Logs", sidebarContent, rightW, m.focusedPanel == panelLogs))
|
||||||
content = lipgloss.JoinHorizontal(lipgloss.Top, left, right)
|
top := lipgloss.JoinHorizontal(lipgloss.Top, monPanel, logPanel)
|
||||||
|
if m.detailOpen {
|
||||||
|
site := ""
|
||||||
|
if m.cursor < len(m.sites) {
|
||||||
|
site = m.sites[m.cursor].Name
|
||||||
|
}
|
||||||
|
detail := m.viewDetailInline(availW - 2)
|
||||||
|
detailPanel := m.zones.Mark("panel-detail", m.titledPanel(site, detail, availW, m.focusedPanel == panelDetail))
|
||||||
|
content = top + "\n" + detailPanel
|
||||||
|
} else {
|
||||||
|
content = top
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
m.contentWidth = m.termWidth
|
m.contentWidth = m.termWidth - 2
|
||||||
content = m.viewSitesTab()
|
monitors := m.viewSitesTab()
|
||||||
|
availW := m.termWidth - chromePadH
|
||||||
|
monPanel := m.zones.Mark("panel-monitors", m.titledPanel("Monitors", monitors, availW, m.focusedPanel == panelMonitors))
|
||||||
|
if m.detailOpen {
|
||||||
|
site := ""
|
||||||
|
if m.cursor < len(m.sites) {
|
||||||
|
site = m.sites[m.cursor].Name
|
||||||
|
}
|
||||||
|
detail := m.viewDetailInline(availW - 2)
|
||||||
|
detailPanel := m.zones.Mark("panel-detail", m.titledPanel(site, detail, availW, m.focusedPanel == panelDetail))
|
||||||
|
content = monPanel + "\n" + detailPanel
|
||||||
|
} else {
|
||||||
|
content = monPanel
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case tabMaint:
|
case tabMaint:
|
||||||
m.contentWidth = m.termWidth
|
m.contentWidth = m.termWidth
|
||||||
@@ -279,7 +303,13 @@ func (m Model) renderFooter(stats dashboardStats) string {
|
|||||||
var keys string
|
var keys string
|
||||||
switch m.currentTab {
|
switch m.currentTab {
|
||||||
case tabMonitors:
|
case tabMonitors:
|
||||||
keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [Space]Collapse [T]Theme [Tab]Switch [q]Quit"
|
if m.focusedPanel == panelLogs {
|
||||||
|
keys = "[↑/↓]Scroll [l/Esc]Back [T]Theme [q]Quit"
|
||||||
|
} else if m.detailOpen {
|
||||||
|
keys = "[i]Close [Enter]Expand [h]History [s]SLA [e]Edit [l]Logs [↑/↓]Select [T]Theme [q]Quit"
|
||||||
|
} else {
|
||||||
|
keys = "[/]Filter [i]Info [Enter]Detail [n]New [e]Edit [d]Del [l]Logs [T]Theme [Tab]Switch [q]Quit"
|
||||||
|
}
|
||||||
case tabMaint:
|
case tabMaint:
|
||||||
keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit"
|
keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit"
|
||||||
case tabSettings:
|
case tabSettings:
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m Model) viewDetailInline(width int) string {
|
||||||
|
if m.cursor >= len(m.sites) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
site := m.sites[m.cursor]
|
||||||
|
hist, _ := m.engine.GetHistory(site.ID)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))
|
||||||
|
latency := m.fmtLatency(site.Latency)
|
||||||
|
uptime := m.fmtUptime(hist.Statuses)
|
||||||
|
|
||||||
|
line1Parts := []string{status}
|
||||||
|
if site.Latency > 0 {
|
||||||
|
line1Parts = append(line1Parts, latency)
|
||||||
|
}
|
||||||
|
line1Parts = append(line1Parts, fmt.Sprintf("Uptime %s", uptime))
|
||||||
|
if !site.LastCheck.IsZero() {
|
||||||
|
line1Parts = append(line1Parts, fmt.Sprintf("Checked %s", m.fmtTimeAgo(site.LastCheck)))
|
||||||
|
}
|
||||||
|
b.WriteString(" " + strings.Join(line1Parts, m.st.subtleStyle.Render(" · ")) + "\n")
|
||||||
|
|
||||||
|
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp ||
|
||||||
|
site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
|
||||||
|
errW := width - 12
|
||||||
|
if errW < 20 {
|
||||||
|
errW = 20
|
||||||
|
}
|
||||||
|
errMsg := limitStr(site.LastError, errW)
|
||||||
|
b.WriteString(" " + m.st.subtleStyle.Render("Error") + " " + m.st.dangerStyle.Render(errMsg) + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
var stateChanges []models.StateChange
|
||||||
|
if m.detailChangesSiteID == site.ID {
|
||||||
|
stateChanges = m.detailChanges
|
||||||
|
}
|
||||||
|
if len(stateChanges) > 0 {
|
||||||
|
var parts []string
|
||||||
|
limit := 3
|
||||||
|
if len(stateChanges) < limit {
|
||||||
|
limit = len(stateChanges)
|
||||||
|
}
|
||||||
|
for _, sc := range stateChanges[:limit] {
|
||||||
|
ago := fmtDuration(time.Since(sc.ChangedAt))
|
||||||
|
arrow := m.st.subtleStyle.Render("→")
|
||||||
|
from := m.fmtStatusWord(sc.FromStatus)
|
||||||
|
to := m.fmtStatusWord(sc.ToStatus)
|
||||||
|
entry := from + " " + arrow + " " + to + " " + m.st.subtleStyle.Render(ago+" ago")
|
||||||
|
if sc.ErrorReason != "" {
|
||||||
|
entry += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, 30))
|
||||||
|
}
|
||||||
|
parts = append(parts, entry)
|
||||||
|
}
|
||||||
|
b.WriteString(" " + strings.Join(parts, m.st.subtleStyle.Render(" · ")) + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(hist.Latencies) > 0 {
|
||||||
|
sparkW := width - 30
|
||||||
|
if sparkW < 10 {
|
||||||
|
sparkW = 10
|
||||||
|
}
|
||||||
|
if sparkW > detailSparkWidth {
|
||||||
|
sparkW = detailSparkWidth
|
||||||
|
}
|
||||||
|
spark := m.latencySparkline(hist.Latencies, hist.Statuses, sparkW, m.theme.Bg)
|
||||||
|
minMs := hist.Latencies[0].Milliseconds()
|
||||||
|
maxMs := hist.Latencies[0].Milliseconds()
|
||||||
|
var sumMs int64
|
||||||
|
for _, l := range hist.Latencies {
|
||||||
|
ms := l.Milliseconds()
|
||||||
|
if ms < minMs {
|
||||||
|
minMs = ms
|
||||||
|
}
|
||||||
|
if ms > maxMs {
|
||||||
|
maxMs = ms
|
||||||
|
}
|
||||||
|
sumMs += ms
|
||||||
|
}
|
||||||
|
avgMs := sumMs / int64(len(hist.Latencies))
|
||||||
|
stats := fmt.Sprintf("Min %s Avg %s Max %s",
|
||||||
|
m.fmtLatency(time.Duration(minMs)*time.Millisecond),
|
||||||
|
m.fmtLatency(time.Duration(avgMs)*time.Millisecond),
|
||||||
|
m.fmtLatency(time.Duration(maxMs)*time.Millisecond))
|
||||||
|
b.WriteString(" " + spark + " " + stats + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := m.st.subtleStyle.Render("[h] History [s] SLA [e] Edit [esc] Close")
|
||||||
|
b.WriteString(" " + keys + "\n")
|
||||||
|
|
||||||
|
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) fmtStatusWord(status string) string {
|
||||||
|
switch status {
|
||||||
|
case "DOWN":
|
||||||
|
return m.st.dangerStyle.Render("DOWN")
|
||||||
|
case "UP":
|
||||||
|
return m.st.specialStyle.Render("UP")
|
||||||
|
default:
|
||||||
|
return m.st.subtleStyle.Render(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user