feat(tui): mouse click for settings sections, column sort, logs panel
- Click Alerts/Nodes/Users sub-tabs in settings to switch sections - Click STATUS/NAME/LATENCY column headers to sort (click again to reverse direction) - Logs panel show/hide: [l] toggles sidebar visibility, click focused panel to hide. Monitors expand to full width when hidden. - Logs full-screen: Enter when focused on logs opens scrollable full-screen log view with severity tags, filter toggle [f], and live updates
This commit was merged in pull request #159.
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
type logSeverity int
|
type logSeverity int
|
||||||
@@ -72,6 +73,23 @@ func (m Model) renderLogLine(entry models.LogEntry) string {
|
|||||||
return fmt.Sprintf(" %s %s %s", ts, tag, entry.Message)
|
return fmt.Sprintf(" %s %s %s", ts, tag, entry.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m Model) viewLogsFullscreen() string {
|
||||||
|
header := m.st.subtleStyle.Render(" Logs") + "\n" + m.divider()
|
||||||
|
|
||||||
|
filterLabel := m.st.subtleStyle.Render("[f] All")
|
||||||
|
if m.logFilterImportant {
|
||||||
|
filterLabel = m.st.subtleStyle.Render("[f] Important only")
|
||||||
|
}
|
||||||
|
countLabel := m.st.subtleStyle.Render(fmt.Sprintf("%d/%d", m.logShown, m.logTotal))
|
||||||
|
footer := m.divider() + "\n " + m.st.subtleStyle.Render("[Esc] Back") + " " + filterLabel + " " + countLabel
|
||||||
|
|
||||||
|
m.logViewport.Width = m.termWidth - chromePadH
|
||||||
|
m.logViewport.Height = m.termHeight - 8
|
||||||
|
|
||||||
|
return lipgloss.NewStyle().Padding(1, 2).Render(
|
||||||
|
header + "\n" + m.logViewport.View() + "\n" + footer)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Model) refreshLogContent() {
|
func (m *Model) refreshLogContent() {
|
||||||
var rendered []string
|
var rendered []string
|
||||||
total := 0
|
total := 0
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,11 +20,13 @@ func (m Model) viewSettingsTab() string {
|
|||||||
|
|
||||||
var tabs []string
|
var tabs []string
|
||||||
for i, name := range sections {
|
for i, name := range sections {
|
||||||
|
var rendered string
|
||||||
if i == m.settingsSection {
|
if i == m.settingsSection {
|
||||||
tabs = append(tabs, m.st.activeTab.Render(name))
|
rendered = m.st.activeTab.Render(name)
|
||||||
} else {
|
} else {
|
||||||
tabs = append(tabs, m.st.inactiveTab.Render(name))
|
rendered = m.st.inactiveTab.Render(name)
|
||||||
}
|
}
|
||||||
|
tabs = append(tabs, m.zones.Mark(fmt.Sprintf("section-%d", i), rendered))
|
||||||
}
|
}
|
||||||
header := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
|
header := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,11 @@ func (m Model) computeLayout() tableLayout {
|
|||||||
sortName: colName,
|
sortName: colName,
|
||||||
sortLatency: colLatency,
|
sortLatency: colLatency,
|
||||||
}
|
}
|
||||||
|
sortableKeys := map[colKey]string{
|
||||||
|
colStatus: "sort-status",
|
||||||
|
colName: "sort-name",
|
||||||
|
colLatency: "sort-latency",
|
||||||
|
}
|
||||||
if sortedKey, ok := sortColMap[m.sortColumn]; ok {
|
if sortedKey, ok := sortColMap[m.sortColumn]; ok {
|
||||||
arrow := "▼"
|
arrow := "▼"
|
||||||
if m.sortAsc {
|
if m.sortAsc {
|
||||||
@@ -122,6 +127,11 @@ func (m Model) computeLayout() tableLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for i, k := range active {
|
||||||
|
if zoneID, ok := sortableKeys[k]; ok {
|
||||||
|
headers[i] = m.zones.Mark(zoneID, headers[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
numCols := len(headers)
|
numCols := len(headers)
|
||||||
borderOverhead := 2 + (numCols - 1)
|
borderOverhead := 2 + (numCols - 1)
|
||||||
|
|||||||
@@ -197,6 +197,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
|
||||||
|
|
||||||
|
logsOpen bool
|
||||||
detailOpen bool
|
detailOpen bool
|
||||||
detailChanges []models.StateChange
|
detailChanges []models.StateChange
|
||||||
detailChangesSiteID int
|
detailChangesSiteID int
|
||||||
@@ -247,6 +248,7 @@ func InitialModel(ctx context.Context, isAdmin bool, s store.Store, eng *monitor
|
|||||||
theme: theme,
|
theme: theme,
|
||||||
themeIndex: themeIdx,
|
themeIndex: themeIdx,
|
||||||
st: newStyles(theme),
|
st: newStyles(theme),
|
||||||
|
logsOpen: true,
|
||||||
detailOpen: detailPref == "true",
|
detailOpen: detailPref == "true",
|
||||||
demoMode: os.Getenv("UPTOP_DEMO") == "1",
|
demoMode: os.Getenv("UPTOP_DEMO") == "1",
|
||||||
version: version,
|
version: version,
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if m.state == stateFormSite || m.state == stateFormAlert || m.state == stateFormUser || m.state == stateFormMaint {
|
if m.state == stateFormSite || m.state == stateFormAlert || m.state == stateFormUser || m.state == stateFormMaint {
|
||||||
return m.handleFormMsg(msg)
|
return m.handleFormMsg(msg)
|
||||||
}
|
}
|
||||||
|
if m.state == stateLogs {
|
||||||
|
return m.handleLogsFullscreen(msg)
|
||||||
|
}
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.MouseMsg:
|
case tea.MouseMsg:
|
||||||
@@ -241,6 +244,40 @@ func (m *Model) testAlertCmd(id int, name string) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleLogsFullscreen(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "esc", "q":
|
||||||
|
m.state = stateDashboard
|
||||||
|
m.focusedPanel = panelLogs
|
||||||
|
case "ctrl+c":
|
||||||
|
return m, tea.Quit
|
||||||
|
case "f":
|
||||||
|
m.logFilterImportant = !m.logFilterImportant
|
||||||
|
m.refreshLogContent()
|
||||||
|
case "up", "k":
|
||||||
|
m.logViewport.ScrollUp(1)
|
||||||
|
case "down", "j":
|
||||||
|
m.logViewport.ScrollDown(1)
|
||||||
|
case "pgup":
|
||||||
|
m.logViewport.ScrollUp(m.logViewport.Height)
|
||||||
|
case "pgdown":
|
||||||
|
m.logViewport.ScrollDown(m.logViewport.Height)
|
||||||
|
}
|
||||||
|
case tea.MouseMsg:
|
||||||
|
switch msg.Button {
|
||||||
|
case tea.MouseButtonWheelUp:
|
||||||
|
m.logViewport.ScrollUp(3)
|
||||||
|
case tea.MouseButtonWheelDown:
|
||||||
|
m.logViewport.ScrollDown(3)
|
||||||
|
}
|
||||||
|
case tickMsg:
|
||||||
|
m.refreshLogContent()
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||||
if m.state == stateHistory {
|
if m.state == stateHistory {
|
||||||
switch msg.Button {
|
switch msg.Button {
|
||||||
@@ -579,10 +616,13 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.switchSettingsSection(m.settingsSection + 1)
|
m.switchSettingsSection(m.settingsSection + 1)
|
||||||
case tabMonitors:
|
case tabMonitors:
|
||||||
if m.focusedPanel == panelLogs {
|
if m.focusedPanel == panelLogs {
|
||||||
|
m.logsOpen = false
|
||||||
m.focusedPanel = panelMonitors
|
m.focusedPanel = panelMonitors
|
||||||
} else {
|
} else {
|
||||||
|
m.logsOpen = true
|
||||||
m.focusedPanel = panelLogs
|
m.focusedPanel = panelLogs
|
||||||
}
|
}
|
||||||
|
m.recalcLayout()
|
||||||
}
|
}
|
||||||
case "up", "k":
|
case "up", "k":
|
||||||
if m.currentTab == tabMonitors && m.focusedPanel == panelLogs {
|
if m.currentTab == tabMonitors && m.focusedPanel == panelLogs {
|
||||||
@@ -618,6 +658,12 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case "n":
|
case "n":
|
||||||
return m.handleNewItem()
|
return m.handleNewItem()
|
||||||
case "enter":
|
case "enter":
|
||||||
|
if m.currentTab == tabMonitors && m.focusedPanel == panelLogs {
|
||||||
|
m.refreshLogContent()
|
||||||
|
m.logViewport.GotoTop()
|
||||||
|
m.state = stateLogs
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
if m.currentTab == tabMonitors && len(m.sites) > 0 {
|
if m.currentTab == tabMonitors && len(m.sites) > 0 {
|
||||||
m.state = stateDetail
|
m.state = stateDetail
|
||||||
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||||
@@ -840,7 +886,41 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.currentTab == tabSettings {
|
||||||
|
maxSec := 1
|
||||||
|
if m.isAdmin {
|
||||||
|
maxSec = 2
|
||||||
|
}
|
||||||
|
for i := 0; i <= maxSec; i++ {
|
||||||
|
if m.zones.Get(fmt.Sprintf("section-%d", i)).InBounds(msg) {
|
||||||
|
m.switchSettingsSection(i)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if m.currentTab == tabMonitors {
|
if m.currentTab == tabMonitors {
|
||||||
|
sortZones := []struct {
|
||||||
|
zone string
|
||||||
|
col int
|
||||||
|
}{
|
||||||
|
{"sort-status", sortStatus},
|
||||||
|
{"sort-name", sortName},
|
||||||
|
{"sort-latency", sortLatency},
|
||||||
|
}
|
||||||
|
for _, sz := range sortZones {
|
||||||
|
if m.zones.Get(sz.zone).InBounds(msg) {
|
||||||
|
if m.sortColumn == sz.col {
|
||||||
|
m.sortAsc = !m.sortAsc
|
||||||
|
} else {
|
||||||
|
m.sortColumn = sz.col
|
||||||
|
m.sortAsc = false
|
||||||
|
}
|
||||||
|
m.refreshLive()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if m.zones.Get("panel-monitors").InBounds(msg) {
|
if m.zones.Get("panel-monitors").InBounds(msg) {
|
||||||
m.focusedPanel = panelMonitors
|
m.focusedPanel = panelMonitors
|
||||||
} else if m.zones.Get("panel-logs").InBounds(msg) {
|
} else if m.zones.Get("panel-logs").InBounds(msg) {
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ func (m Model) View() string {
|
|||||||
return ""
|
return ""
|
||||||
case stateDetail:
|
case stateDetail:
|
||||||
return m.zones.Scan(m.viewDetailPanel())
|
return m.zones.Scan(m.viewDetailPanel())
|
||||||
|
case stateLogs:
|
||||||
|
return m.viewLogsFullscreen()
|
||||||
case stateHistory:
|
case stateHistory:
|
||||||
return m.viewHistoryPanel()
|
return m.viewHistoryPanel()
|
||||||
case stateSLA:
|
case stateSLA:
|
||||||
@@ -152,7 +154,7 @@ func (m Model) viewDashboard() string {
|
|||||||
var content string
|
var content string
|
||||||
switch m.currentTab {
|
switch m.currentTab {
|
||||||
case tabMonitors:
|
case tabMonitors:
|
||||||
showSidebar := m.termWidth >= wideBreakpoint
|
showSidebar := m.termWidth >= wideBreakpoint && m.logsOpen
|
||||||
if showSidebar {
|
if showSidebar {
|
||||||
availW := m.termWidth - chromePadH
|
availW := m.termWidth - chromePadH
|
||||||
leftW := availW * 70 / 100
|
leftW := availW * 70 / 100
|
||||||
@@ -310,7 +312,7 @@ func (m Model) renderFooter(stats dashboardStats) string {
|
|||||||
switch m.currentTab {
|
switch m.currentTab {
|
||||||
case tabMonitors:
|
case tabMonitors:
|
||||||
if m.focusedPanel == panelLogs {
|
if m.focusedPanel == panelLogs {
|
||||||
keys = "[↑/↓]Scroll [l/Esc]Back [T]Theme [q]Quit"
|
keys = "[↑/↓]Scroll [Enter]Expand [l/Esc]Back [T]Theme [q]Quit"
|
||||||
} else if m.detailOpen {
|
} else if m.detailOpen {
|
||||||
keys = "[i]Close [Enter]Expand [h]History [s]SLA [e]Edit [l]Logs [↑/↓]Select [T]Theme [q]Quit"
|
keys = "[i]Close [Enter]Expand [h]History [s]SLA [e]Edit [l]Logs [↑/↓]Select [T]Theme [q]Quit"
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user