Updates
ci / release (push) Has been cancelled
ci / test (push) Has been cancelled

This commit is contained in:
2026-07-03 05:47:51 -04:00
parent 20d3f60310
commit e8ee8319eb
13 changed files with 461 additions and 32 deletions
+7
View File
@@ -24,6 +24,13 @@ func IsRateLimited(err error) bool {
return false
}
func IsNotFound(err error) bool {
if e, ok := err.(*APIError); ok {
return e.Status == 404
}
return false
}
func IsVizDisabled(err error) bool {
if e, ok := err.(*APIError); ok {
return e.Status == 503
+11 -4
View File
@@ -252,17 +252,19 @@ func writeWrappedField(b *strings.Builder, label, value string, width, labelWidt
}
}
func renderFooter(t Theme, filtering bool, filter string, follow bool, detailOpen bool, mouseEnabled bool, historyNote string) string {
func renderFooter(t Theme, filtering, searching bool, filter string, follow bool, detailOpen bool, mouseEnabled bool, historyNote string) string {
var hints []string
if detailOpen {
if searching {
hints = []string{"type id…", "Enter lookup", "Esc cancel"}
} else if detailOpen {
hints = []string{"esc close", "enter refresh"}
if mouseEnabled {
hints = append(hints, "click outside close")
}
} else if mouseEnabled {
hints = []string{"click row", "wheel scroll", "dbl-click detail", "Tab view", "j/k nav", "Enter detail", "f follow", "/ filter", "? help", "q quit"}
hints = []string{"click row", "wheel scroll", "dbl-click detail", "Tab view", "j/k nav", "Enter detail", "s search", "/ filter", "? help", "q quit"}
} else {
hints = []string{"Tab view", "j/k nav", "Enter detail", "f follow", "/ filter", "? help", "q quit"}
hints = []string{"Tab view", "j/k nav", "Enter detail", "s search", "/ filter", "? help", "q quit"}
}
hints = append(hints, "ctrl+m mouse")
if filtering {
@@ -318,6 +320,11 @@ func renderHelp(t Theme) string {
Enter Apply filter
Esc Clear filter (cancel input)
Search
s Search by attack or moderation ID
Enter Look up and open detail
Esc Cancel search
History
PgUp (Feed) Load older attacks from API
PgUp (Incidents) Load moderation history from API
+25 -2
View File
@@ -58,7 +58,7 @@ type blogPostLoadedMsg struct {
const blogPageSize = 20
type keyMap struct {
Up, Down, Quit, Help, Tab, Follow, Filter, Enter, Reconnect, Close, Mouse key.Binding
Up, Down, Quit, Help, Tab, Follow, Filter, Search, Enter, Reconnect, Close, Mouse key.Binding
PgUp, PgDn key.Binding
}
@@ -71,6 +71,7 @@ func defaultKeyMap() keyMap {
Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next view")),
Follow: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "follow")),
Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")),
Search: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "search")),
Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "detail")),
Close: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "close")),
Mouse: key.NewBinding(key.WithKeys("ctrl+m"), key.WithHelp("ctrl+m", "mouse")),
@@ -97,6 +98,8 @@ type Model struct {
filtering bool
filterQuery string
filterInput textinput.Model
searching bool
searchInput textinput.Model
store *viz.Store
stats *viz.Stats
@@ -143,6 +146,11 @@ func NewModel(cfg config.Config) Model {
ti.CharLimit = 64
ti.Width = 40
si := textinput.New()
si.Placeholder = "attack_… or mod_…"
si.CharLimit = 96
si.Width = 48
m := Model{
cfg: cfg,
theme: NewTheme(cfg.NoColor),
@@ -150,6 +158,7 @@ func NewModel(cfg config.Config) Model {
store: viz.NewStore(cfg.Buffer),
follow: true,
filterInput: ti,
searchInput: si,
conn: ConnReconnecting,
mouseEnabled: envTruthy("HP_VIZ_MOUSE"),
}
@@ -205,6 +214,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.filterInput, cmd = m.filterInput.Update(msg)
return m, cmd
}
if m.searching {
var cmd tea.Cmd
m.searchInput, cmd = m.searchInput.Update(msg)
return m, cmd
}
cmds = append(cmds, m.handleMouse(msg)...)
case tea.KeyMsg:
@@ -214,6 +228,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m.updateFilter(msg)
}
if m.searching {
return m.updateSearch(msg)
}
if m.detailOpen {
switch {
case key.Matches(msg, m.keys.Close):
@@ -284,9 +301,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.cursor = 0
}
case key.Matches(msg, m.keys.Filter):
m.cancelSearch()
m.filtering = true
m.filterInput.Focus()
m.filterInput.SetValue(m.filterQuery)
case key.Matches(msg, m.keys.Search):
m.startSearch()
case key.Matches(msg, m.keys.Enter):
if m.tab == TabBlog && !m.blogReading {
cmds = append(cmds, m.openBlogPost())
@@ -691,6 +711,9 @@ func (m Model) View() string {
if m.showHelp {
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, renderHelp(m.theme))
}
if m.searching {
return m.renderSearchModal()
}
if m.detailOpen {
return m.renderDetailModal()
}
@@ -720,7 +743,7 @@ func (m Model) renderBaseView() string {
b.WriteString(renderStatusErr(m.theme, m.statusErr))
b.WriteString("\n")
}
b.WriteString(renderFooter(m.theme, m.filtering, m.filterQuery, m.follow, m.detailOpen, m.mouseEnabled, m.historyFooterNote()))
b.WriteString(renderFooter(m.theme, m.filtering, m.searching, m.filterQuery, m.follow, m.detailOpen, m.mouseEnabled, m.historyFooterNote()))
if m.filtering {
b.WriteString("\n")
b.WriteString(m.filterInput.View())
+5
View File
@@ -44,6 +44,11 @@ func (m *Model) handleMouse(msg tea.MouseMsg) []tea.Cmd {
m.filterInput, cmd = m.filterInput.Update(msg)
return []tea.Cmd{cmd}
}
if m.searching {
var cmd tea.Cmd
m.searchInput, cmd = m.searchInput.Update(msg)
return []tea.Cmd{cmd}
}
ly := m.viewLayout()
var cmds []tea.Cmd
+146
View File
@@ -0,0 +1,146 @@
package ui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/honeypeer/cli-viz/internal/transport"
"github.com/honeypeer/cli-viz/internal/viz"
)
func (m *Model) startSearch() {
if m.detailOpen {
m.closeDetail()
}
m.filtering = false
m.filterInput.Blur()
m.searching = true
m.searchInput.Focus()
m.searchInput.SetValue("")
m.invalidateModalCache()
}
func (m *Model) cancelSearch() {
m.searching = false
m.searchInput.Blur()
m.searchInput.SetValue("")
}
func (m *Model) updateSearch(msg tea.KeyMsg) (Model, tea.Cmd) {
switch {
case key.Matches(msg, m.keys.Close):
m.cancelSearch()
return *m, nil
case key.Matches(msg, m.keys.Enter):
return *m, m.submitSearch()
case key.Matches(msg, m.keys.Mouse):
return m.toggleMouse()
case key.Matches(msg, m.keys.Quit):
m.sse.Stop()
return *m, tea.Quit
}
var cmd tea.Cmd
m.searchInput, cmd = m.searchInput.Update(msg)
return *m, cmd
}
func (m *Model) submitSearch() tea.Cmd {
id := viz.NormalizeRecordID(m.searchInput.Value())
m.cancelSearch()
if id == "" {
return nil
}
if m.api == nil {
m.statusErr = "Search requires a live API (--url)"
return nil
}
return m.openDetailByID(id)
}
func (m *Model) openDetailByID(id string) tea.Cmd {
m.clearDetail()
m.detailOpen = true
m.detailLoading = true
m.detailErr = ""
m.invalidateModalCache()
if m.cachedModalBase == "" {
base := padLines(strings.Split(strings.TrimSuffix(m.renderBaseView(), "\n"), "\n"), m.height)
m.cachedModalBase = strings.Join(base, "\n")
m.cachedModalKey = fmt.Sprintf("%dx%d", m.width, m.height)
}
m.refreshDetailViewport()
switch viz.ClassifyRecordID(id) {
case viz.RecordIDModeration:
return fetchModerationDetail(m.api, id)
case viz.RecordIDAttack:
return fetchAttackDetail(m.api, id)
default:
return fetchRecordByID(m.api, id)
}
}
func fetchRecordByID(api *transport.APIClient, id string) tea.Cmd {
return func() tea.Msg {
attack, err := api.AttackDetail(id)
if err == nil {
return attackDetailMsg{attack: attack}
}
if transport.IsNotFound(err) {
mod, err2 := api.ModerationDetail(id)
if err2 == nil {
return moderationDetailMsg{mod: mod}
}
return attackDetailMsg{err: fmt.Errorf("record not found: %s", id)}
}
return attackDetailMsg{err: err}
}
}
func (m Model) renderSearchModal() string {
baseStr := m.modalBaseCached()
if baseStr == "" {
base := padLines(strings.Split(strings.TrimSuffix(m.renderBaseView(), "\n"), "\n"), m.height)
baseStr = strings.Join(base, "\n")
}
width := m.width * 2 / 3
if width < 48 {
width = 48
}
if width > m.width-4 {
width = m.width - 4
}
height := 9
if height > m.height-4 {
height = m.height - 4
}
left := (m.width - width) / 2
top := (m.height - height) / 2
if left < 0 {
left = 0
}
if top < 0 {
top = 0
}
m.searchInput.Width = width - 8
if m.searchInput.Width < 32 {
m.searchInput.Width = 32
}
title := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Honey).Render("Search by ID")
hint := m.theme.MutedStyle().Render("attack_… or mod_… │ Enter lookup │ Esc cancel")
body := title + "\n\n" + m.searchInput.View() + "\n\n" + hint
box := m.theme.ModalStyle().
Width(width).
Height(height).
Render(body)
return overlayAt(baseStr, m.width, m.height, box, left, top)
}
+32
View File
@@ -0,0 +1,32 @@
package viz
import "strings"
// RecordIDKind classifies public viz record identifiers.
type RecordIDKind int
const (
RecordIDUnknown RecordIDKind = iota
RecordIDAttack
RecordIDModeration
)
// ClassifyRecordID guesses API type from an attack or moderation id string.
func ClassifyRecordID(id string) RecordIDKind {
id = strings.TrimSpace(id)
switch {
case id == "":
return RecordIDUnknown
case strings.HasPrefix(id, "mod_"):
return RecordIDModeration
case strings.HasPrefix(id, "attack_"):
return RecordIDAttack
default:
return RecordIDUnknown
}
}
// NormalizeRecordID trims whitespace from user input.
func NormalizeRecordID(id string) string {
return strings.TrimSpace(id)
}
+25
View File
@@ -0,0 +1,25 @@
package viz_test
import (
"testing"
"github.com/honeypeer/cli-viz/internal/viz"
)
func TestClassifyRecordID(t *testing.T) {
cases := []struct {
id string
want viz.RecordIDKind
}{
{"attack_1783071673622_g5lz6hei2", viz.RecordIDAttack},
{"mod_attack_1783071620431_i7k5ss694", viz.RecordIDModeration},
{"mod_abc", viz.RecordIDModeration},
{"", viz.RecordIDUnknown},
{"foo", viz.RecordIDUnknown},
}
for _, tc := range cases {
if got := viz.ClassifyRecordID(tc.id); got != tc.want {
t.Fatalf("ClassifyRecordID(%q) = %v, want %v", tc.id, got, tc.want)
}
}
}