Add history tab
ci / test (push) Successful in 41s
ci / release (push) Successful in 1m27s

This commit is contained in:
2026-07-03 08:51:25 -04:00
parent 7cb328cb97
commit 82c4c38492
15 changed files with 614 additions and 159 deletions
+4 -2
View File
@@ -83,7 +83,7 @@ Precedence: **flags → environment → config file → defaults**.
| Key | Action |
|-----|--------|
| `j` / `k`, `↑` / `↓` | Move selection |
| `Tab` | Feed → Incidents → Peers → Stats → Blog |
| `Tab` | Feed → Incidents → Peers → Stats → **History** Blog |
| `Enter` | Open detail (or read blog post on Blog tab) |
| `Esc` | Close detail / back from blog post / cancel filter or search |
| `m` | Toggle mouse (off by default) |
@@ -91,8 +91,9 @@ Precedence: **flags → environment → config file → defaults**.
| `s` | Search by attack or moderation ID (opens detail on match) |
| `o` | Operator sign-in — subscription ID + billing email unlocks real IPs |
| `/` | Filter — text or `service:ssh geo:cn peer:id since:1h` |
| `PgUp` | Load older history (Feed / Incidents / Blog) |
| `PgUp` | Page up in list / load more blog posts |
| `PgDn` | Page down in list |
| `]` / `[` | History sub-tab (attacks/reputation) / time range |
| `r` | Reconnect stream |
| `?` | Help overlay |
| `q` | Quit |
@@ -118,6 +119,7 @@ In the detail view, `j`/`k` or the mouse wheel scroll long content.
- **Incidents** — grouped activity with drill-down detail
- **Peers** — animated hub-and-spoke mesh (like the web viz), live roster, connect events
- **Stats** — aggregates and breakdowns
- **History** — paginated attack and reputation database history (like the web viz sidebar), with time-range filters
- **Blog** — published HoneyPeer threat briefings (markdown, tables, charts)
Timestamps use your **local timezone**.
Executable
BIN
View File
Binary file not shown.
+26 -2
View File
@@ -37,11 +37,35 @@ func (c *APIClient) AttacksQuery(q AttackQuery) (viz.AttackHistoryResponse, erro
return fetchJSON[viz.AttackHistoryResponse](c, u)
}
func (c *APIClient) ModerationHistory(offset, limit int) (viz.ModerationHistoryResponse, error) {
u := fmt.Sprintf("%s/api/public/viz/moderation-history?offset=%d&limit=%d", c.base, offset, limit)
type ModerationQuery struct {
Offset int
Limit int
Geo string
Tier string
Since string
}
func (c *APIClient) ModerationHistoryQuery(q ModerationQuery) (viz.ModerationHistoryResponse, error) {
params := url.Values{}
params.Set("offset", strconv.Itoa(q.Offset))
params.Set("limit", strconv.Itoa(q.Limit))
if q.Geo != "" {
params.Set("geo", q.Geo)
}
if q.Tier != "" {
params.Set("tier", q.Tier)
}
if q.Since != "" {
params.Set("since", q.Since)
}
u := fmt.Sprintf("%s/api/public/viz/moderation-history?%s", c.base, params.Encode())
return fetchJSON[viz.ModerationHistoryResponse](c, u)
}
func (c *APIClient) ModerationHistory(offset, limit int) (viz.ModerationHistoryResponse, error) {
return c.ModerationHistoryQuery(ModerationQuery{Offset: offset, Limit: limit})
}
func (c *APIClient) ModerationDetail(id string) (viz.ModerationEvent, error) {
u := fmt.Sprintf("%s/api/public/viz/moderation-history/%s", c.base, url.PathEscape(id))
return fetchJSON[viz.ModerationEvent](c, u)
+4 -30
View File
@@ -32,35 +32,6 @@ func (m *Model) flushCoalescedSSE() {
m.invalidateModalCache()
}
func (m *Model) requestModerationHistory() tea.Cmd {
if m.api == nil || m.tab != TabIncidents {
return nil
}
if !canLoadMoreHistory(m.moderationOffset, m.moderationTotal, m.moderationExhausted) {
return nil
}
if time.Now().Before(m.rateLimitUntil) {
m.statusErr = "rate limited — wait before loading more history"
return nil
}
return loadModerationHistory(m.api, m.moderationOffset, historyPageSize)
}
func (m *Model) requestHistory() tea.Cmd {
if m.api == nil || m.tab != TabFeed {
return nil
}
if !canLoadMoreHistory(m.historyOffset, m.historyTotal, m.historyExhausted) {
return nil
}
if time.Now().Before(m.rateLimitUntil) {
m.statusErr = "rate limited — wait before loading more history"
return nil
}
filters := historyFiltersFromQuery(m.filterQuery)
return loadHistory(m.api, m.historyOffset, historyPageSize, filters)
}
type historyFilters struct {
Service string
Geo string
@@ -108,7 +79,10 @@ func (m *Model) invalidateModalCache() {
}
func (m Model) historyFooterNote() string {
return historyStatusText(m.historyOffset, m.historyTotal, m.historyExhausted)
if m.tab == TabHistory {
return m.historyTabFooterNote()
}
return ""
}
func (m Model) modalBaseCached() string {
+11 -6
View File
@@ -358,6 +358,9 @@ func renderFooter(t Theme, filtering, searching, operatorSigning bool, filter st
if tab == TabFeed {
hints = append(hints, "v mesh")
}
if tab == TabHistory {
hints = append(hints, "] sub", "[ range", "scroll loads more")
}
hints = append(hints, "e sound")
if soundEnabled {
hints = append(hints, "-/= vol")
@@ -394,7 +397,7 @@ func renderHelp(t Theme) string {
Navigation
j / k, ↑ / ↓ Move selection
Tab Cycle Feed → Incidents → Peers → Stats → Blog
Tab Cycle Feed → Incidents → Peers → Stats → History → Blog
Enter Open detail modal (or blog post on Blog tab)
f Toggle follow mode (auto-scroll to newest)
PgUp Load older attacks (Feed) / moderation (Incidents) / more posts (Blog)
@@ -443,11 +446,13 @@ func renderHelp(t Theme) string {
Shift+O Sign out (while operator modal is open)
Esc Close operator modal
History
PgUp (Feed) Load older attacks from API
PgUp (Incidents) Load moderation history from API
PgDn Page down in list
r Force SSE reconnect
History (database — like web viz sidebar)
Tab to History view
] Switch Attacks ↔ Reputation sub-tabs
[ Cycle time range: all → 1h → 24h → 7d
j / k, scroll Infinite scroll — loads more near the bottom
/ filter service:ssh geo:cn peer:id tier:block (reputation)
Enter Open attack or moderation detail
General
e Toggle synthesized event sounds (matches web viz)
+3
View File
@@ -23,6 +23,7 @@ const (
TabIncidents
TabPeers
TabStats
TabHistory
TabBlog
)
@@ -36,6 +37,8 @@ func tabLabel(tab ViewTab) string {
return "Peers"
case TabStats:
return "Stats"
case TabHistory:
return "History"
case TabBlog:
return "Blog"
default:
+318
View File
@@ -0,0 +1,318 @@
package ui
import (
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/honeypeer/cli-viz/internal/transport"
"github.com/honeypeer/cli-viz/internal/viz"
)
// HistorySubTab mirrors web viz History sidebar: attacks vs reputation.
type HistorySubTab int
const (
HistAttacks HistorySubTab = iota
HistReputation
)
func (s HistorySubTab) Label() string {
if s == HistReputation {
return "Reputation"
}
return "Attacks"
}
func (s HistorySubTab) Next() HistorySubTab {
if s == HistAttacks {
return HistReputation
}
return HistAttacks
}
type histTabLoadedMsg struct {
sub HistorySubTab
items []viz.FeedItem
total int
exhausted bool
err error
}
func (m *Model) resetHistoryTab() {
m.histAtkItems = nil
m.histAtkOffset = 0
m.histAtkTotal = 0
m.histAtkExhausted = false
m.histModItems = nil
m.histModOffset = 0
m.histModTotal = 0
m.histModExhausted = false
m.histLoading = false
m.cursor = 0
}
func (m *Model) historySinceMS() string {
if s := m.historyRange.SinceMS(time.Now()); s != "" {
return s
}
f := viz.ParseFilterQuery(m.filterQuery)
return viz.ParseSinceToken(f.Since)
}
func (m *Model) requestHistoryTab() tea.Cmd {
if m.api == nil || m.tab != TabHistory {
return nil
}
if m.histLoading {
return nil
}
if time.Now().Before(m.rateLimitUntil) {
m.statusErr = "rate limited — wait before loading more history"
return nil
}
switch m.historySubTab {
case HistReputation:
if !canLoadMoreHistory(m.histModOffset, m.histModTotal, m.histModExhausted) {
return nil
}
f := viz.ParseFilterQuery(m.filterQuery)
m.histLoading = true
return loadHistModeration(m.api, m.histModOffset, historyPageSize, moderationHistFilters{
Geo: f.Geo, Tier: f.Tier, Since: m.historySinceMS(),
})
default:
if !canLoadMoreHistory(m.histAtkOffset, m.histAtkTotal, m.histAtkExhausted) {
return nil
}
f := historyFiltersFromQuery(m.filterQuery)
since := m.historySinceMS()
if since != "" {
f.Since = since
}
m.histLoading = true
return loadHistAttacks(m.api, m.histAtkOffset, historyPageSize, f)
}
}
const historyPrefetchMargin = 8
func (m Model) historyExhausted() bool {
if m.historySubTab == HistReputation {
return m.histModExhausted
}
return m.histAtkExhausted
}
func (m Model) historyListViewRows() int {
mainH, _ := m.layoutMetrics()
// History chrome: sub-tab header + rule (2 lines) + feed header row
rows := mainH - 3
if rows < 1 {
rows = 1
}
return rows
}
// maybeLoadMoreHistory prefetches the next page when scrolling near the end or when
// the list does not yet fill the viewport (infinite scroll).
func (m *Model) maybeLoadMoreHistory() tea.Cmd {
if m.tab != TabHistory || m.api == nil {
return nil
}
items := m.visibleHistoryItems()
n := len(items)
if n == 0 {
return m.ensureHistoryTabLoaded()
}
if m.historyExhausted() {
return nil
}
viewRows := m.historyListViewRows()
margin := historyPrefetchMargin
if margin > viewRows {
margin = viewRows
}
nearEnd := m.cursor >= n-margin
underfill := n < viewRows
if nearEnd || underfill {
return m.requestHistoryTab()
}
return nil
}
func (m *Model) ensureHistoryTabLoaded() tea.Cmd {
if m.tab != TabHistory || m.api == nil {
return nil
}
switch m.historySubTab {
case HistReputation:
if len(m.histModItems) == 0 && !m.histModExhausted && !m.histLoading {
return m.requestHistoryTab()
}
default:
if len(m.histAtkItems) == 0 && !m.histAtkExhausted && !m.histLoading {
return m.requestHistoryTab()
}
}
return nil
}
func (m *Model) cycleHistoryRange() tea.Cmd {
m.historyRange = m.historyRange.Next()
m.resetHistoryTab()
return m.requestHistoryTab()
}
func (m *Model) cycleHistorySubTab() tea.Cmd {
m.historySubTab = m.historySubTab.Next()
m.cursor = 0
return m.ensureHistoryTabLoaded()
}
func (m *Model) refreshHistoryAfterFilter() tea.Cmd {
if m.tab != TabHistory {
return nil
}
m.resetHistoryTab()
return m.requestHistoryTab()
}
type moderationHistFilters struct {
Geo string
Tier string
Since string
}
func loadHistAttacks(api *transport.APIClient, offset, limit int, filters historyFilters) tea.Cmd {
return func() tea.Msg {
resp, err := api.AttacksQuery(transport.AttackQuery{
Offset: offset, Limit: limit,
Service: filters.Service, Geo: filters.Geo,
PeerID: filters.PeerID, Since: filters.Since,
})
if err != nil {
return histTabLoadedMsg{sub: HistAttacks, err: err}
}
items := attacksToFeedItems(resp.Attacks)
exhausted := len(resp.Attacks) == 0 || len(resp.Attacks) < limit
if resp.Total > 0 && offset+len(resp.Attacks) >= resp.Total {
exhausted = true
}
return histTabLoadedMsg{sub: HistAttacks, items: items, total: resp.Total, exhausted: exhausted}
}
}
func loadHistModeration(api *transport.APIClient, offset, limit int, filters moderationHistFilters) tea.Cmd {
return func() tea.Msg {
resp, err := api.ModerationHistoryQuery(transport.ModerationQuery{
Offset: offset, Limit: limit,
Geo: filters.Geo, Tier: filters.Tier, Since: filters.Since,
})
if err != nil {
return histTabLoadedMsg{sub: HistReputation, err: err}
}
items := make([]viz.FeedItem, 0, len(resp.Events))
for _, ev := range resp.Events {
items = append(items, viz.FeedFromModeration(ev))
}
exhausted := len(resp.Events) == 0 || len(resp.Events) < limit
if resp.Total > 0 && offset+len(resp.Events) >= resp.Total {
exhausted = true
}
return histTabLoadedMsg{sub: HistReputation, items: items, total: resp.Total, exhausted: exhausted}
}
}
func attacksToFeedItems(attacks []viz.PublicAttack) []viz.FeedItem {
items := make([]viz.FeedItem, 0, len(attacks))
for _, a := range attacks {
ip := viz.DisplayIP(a.IP, a.IPMasked)
peer := a.PeerID
if a.PeerPublicKeyHex != "" {
peer = a.PeerPublicKeyHex
}
items = append(items, viz.FeedItem{
ID: a.ID, Type: viz.FeedAttack, Timestamp: a.Timestamp,
Label: a.Service, Service: a.Service, IP: a.IP, IPMasked: ip,
Geo: a.Geo, PeerID: peer, PeerPublicKeyHex: a.PeerPublicKeyHex,
Port: a.Port, Summary: a.Summary,
Interaction: a.Interaction, BlobCount: a.BlobCount,
CoConspiratorCount: a.CoConspiratorCount,
ScoreAfter: a.ScoreAfter, ScoreDelta: a.ScoreDelta, TierAfter: a.TierAfter,
BlockScore: a.BlockScore, GreylistScore: a.GreylistScore,
})
}
return items
}
func (m Model) visibleHistoryItems() []viz.FeedItem {
var items []viz.FeedItem
if m.historySubTab == HistReputation {
items = m.histModItems
} else {
items = m.histAtkItems
}
f := viz.ParseFilterQuery(m.filterQuery)
if f.Text == "" {
return items
}
return viz.FilterFeed(items, f.Text)
}
func (m Model) renderHistoryView(height, width int) string {
sub := m.historySubTab.Label()
rangeLbl := m.historyRange.Label()
var total, loaded int
var exhausted bool
if m.historySubTab == HistReputation {
total, loaded, exhausted = m.histModTotal, m.histModOffset, m.histModExhausted
} else {
total, loaded, exhausted = m.histAtkTotal, m.histAtkOffset, m.histAtkExhausted
}
status := viz.HistoryRangeSummary(m.historyRange, loaded, total, exhausted)
header := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Honey).Render("History") +
" " + m.theme.MutedStyle().Render("│") + " " +
lipgloss.NewStyle().Foreground(m.theme.Teal).Render(sub) +
" " + m.theme.MutedStyle().Render("│") + " " +
m.theme.MutedStyle().Render("range: "+rangeLbl) +
" " + m.theme.MutedStyle().Render("│") + " " +
m.theme.MutedStyle().Render(status)
if m.histLoading {
header += " " + m.theme.MutedStyle().Render("loading…")
}
listH := height - 2
if listH < 1 {
listH = 1
}
items := m.visibleHistoryItems()
var body string
if len(items) == 0 {
if m.histLoading {
body = m.theme.MutedStyle().Render("Loading database history…")
} else if m.api == nil {
body = m.theme.MutedStyle().Render("History requires a live API (--url)")
} else {
body = m.theme.MutedStyle().Render("No history rows — try another range or filter")
}
} else {
body = renderFeedList(m.theme, items, m.cursor, viz.NewStore(1), listH, width, m.cfg.Operator)
}
return header + "\n" + strings.Repeat("─", min(width, 60)) + "\n" + body
}
func (m Model) historyTabFooterNote() string {
if m.tab != TabHistory {
return ""
}
var loaded, total int
var exhausted bool
if m.historySubTab == HistReputation {
loaded, total, exhausted = m.histModOffset, m.histModTotal, m.histModExhausted
} else {
loaded, total, exhausted = m.histAtkOffset, m.histAtkTotal, m.histAtkExhausted
}
return viz.HistoryRangeSummary(m.historyRange, loaded, total, exhausted)
}
+45 -6
View File
@@ -1,24 +1,63 @@
package ui
import "testing"
import (
"testing"
"github.com/honeypeer/cli-viz/internal/config"
"github.com/honeypeer/cli-viz/internal/viz"
)
func TestCanLoadMoreHistory(t *testing.T) {
if !canLoadMoreHistory(0, 100, false) {
t.Fatal("should load at start")
t.Fatal("offset 0 should load")
}
if canLoadMoreHistory(100, 100, false) {
t.Fatal("should not load when offset >= total")
t.Fatal("at total should not load")
}
if canLoadMoreHistory(50, 0, true) {
t.Fatal("should not load when exhausted")
t.Fatal("exhausted should not load")
}
}
func TestHistoryStatusText(t *testing.T) {
if got := historyStatusText(50, 200, false); got != "history: 50 / 200" {
t.Fatalf("got %q", got)
t.Fatalf("unexpected: %q", got)
}
if got := historyStatusText(200, 200, false); got != "history: end" {
t.Fatalf("got %q", got)
t.Fatalf("unexpected: %q", got)
}
}
func TestMaybeLoadMoreHistoryNearEnd(t *testing.T) {
m := NewModel(config.Config{URL: "https://viz.example.com", Buffer: 100})
m.tab = TabHistory
m.width = 120
m.height = 40
m.ready = true
m.histAtkItems = make([]viz.FeedItem, 20)
for i := range m.histAtkItems {
m.histAtkItems[i] = viz.FeedItem{ID: "a", Type: viz.FeedAttack}
}
m.histAtkTotal = 100
m.cursor = 18
if m.maybeLoadMoreHistory() == nil {
t.Fatal("expected load near end")
}
m2 := NewModel(config.Config{URL: "https://viz.example.com", Buffer: 100})
m2.tab = TabHistory
m2.width = 120
m2.height = 40
m2.ready = true
m2.histAtkItems = make([]viz.FeedItem, 5)
m2.histAtkTotal = 100
if m2.maybeLoadMoreHistory() == nil {
t.Fatal("expected load when underfilling viewport")
}
m.histAtkExhausted = true
m.histLoading = false
if m.maybeLoadMoreHistory() != nil {
t.Fatal("should not load when exhausted")
}
}
+6 -3
View File
@@ -43,13 +43,16 @@ func TestListScrollStart(t *testing.T) {
func TestTabHitZones(t *testing.T) {
zones := tabHitZones()
if len(zones) != 5 {
t.Fatalf("expected 5 tabs, got %d", len(zones))
if len(zones) != 6 {
t.Fatalf("expected 6 tabs, got %d", len(zones))
}
if zones[0].Tab != TabFeed || zones[0].StartX != 0 {
t.Fatalf("unexpected first zone: %+v", zones[0])
}
if zones[4].Tab != TabBlog {
if zones[4].Tab != TabHistory {
t.Fatalf("expected History tab, got %+v", zones[4])
}
if zones[5].Tab != TabBlog {
t.Fatalf("expected Blog tab last, got %+v", zones[4])
}
}
+62 -107
View File
@@ -38,19 +38,7 @@ type moderationAttacksMsg struct {
attacks []viz.PublicAttack
err error
}
type historyLoadedMsg struct {
items []viz.FeedItem
total int
exhausted bool
err error
}
type moderationLoadedMsg struct {
items []viz.FeedItem
total int
exhausted bool
err error
}
type blogLoadedMsg struct {
posts []viz.BlogPostSummary
total int
@@ -68,6 +56,8 @@ type keyMap struct {
Up, Down, Quit, Help, Tab, Follow, Filter, Search, Operator, Enter, Reconnect, Close, Mouse key.Binding
PgUp, PgDn key.Binding
MeshView key.Binding
HistSub key.Binding
HistRange key.Binding
Sound key.Binding
VolDown key.Binding
VolUp key.Binding
@@ -90,6 +80,8 @@ func defaultKeyMap() keyMap {
Reconnect: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "reconnect")),
PgUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "history")),
PgDn: key.NewBinding(key.WithKeys("pgdn"), key.WithHelp("pgdn", "down")),
HistSub: key.NewBinding(key.WithKeys("]"), key.WithHelp("]", "hist sub")),
HistRange: key.NewBinding(key.WithKeys("["), key.WithHelp("[", "hist range")),
MeshView: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "mesh")),
Sound: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "sound")),
VolDown: key.NewBinding(key.WithKeys("-", "_"), key.WithHelp("-", "quieter")),
@@ -136,12 +128,17 @@ type Model struct {
detailErr string
detailOpen bool
statusErr string
historyOffset int
historyTotal int
historyExhausted bool
moderationOffset int
moderationTotal int
moderationExhausted bool
historySubTab HistorySubTab
historyRange viz.HistoryRange
histAtkItems []viz.FeedItem
histAtkOffset int
histAtkTotal int
histAtkExhausted bool
histModItems []viz.FeedItem
histModOffset int
histModTotal int
histModExhausted bool
histLoading bool
blogPosts []viz.BlogPostSummary
blogOffset int
blogTotal int
@@ -366,12 +363,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.tab == TabBlog {
cmds = append(cmds, m.requestBlogList())
}
if m.tab == TabHistory {
cmds = append(cmds, m.ensureHistoryTabLoaded())
}
cmds = append(cmds, m.onTabChanged())
case key.Matches(msg, m.keys.Follow):
m.follow = !m.follow
if m.follow {
m.cursor = 0
}
case key.Matches(msg, m.keys.HistSub):
if m.tab == TabHistory {
cmds = append(cmds, m.cycleHistorySubTab())
}
case key.Matches(msg, m.keys.HistRange):
if m.tab == TabHistory {
cmds = append(cmds, m.cycleHistoryRange())
}
case key.Matches(msg, m.keys.MeshView):
if m.tab == TabFeed {
m.feedMeshVisible = !m.feedMeshVisible
@@ -423,12 +431,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.cursor++
}
m.follow = false
if m.tab == TabHistory {
cmds = append(cmds, m.maybeLoadMoreHistory())
}
case key.Matches(msg, m.keys.PgUp):
switch m.tab {
case TabFeed:
cmds = append(cmds, m.requestHistory())
case TabIncidents:
cmds = append(cmds, m.requestModerationHistory())
case TabBlog:
if !m.blogReading {
cmds = append(cmds, m.requestBlogList())
@@ -438,6 +445,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case key.Matches(msg, m.keys.PgDn):
m.pageDown()
if m.tab == TabHistory {
cmds = append(cmds, m.maybeLoadMoreHistory())
}
}
case coalesceFlushMsg:
@@ -529,7 +539,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.refreshDetailViewport()
case historyLoadedMsg:
case histTabLoadedMsg:
m.histLoading = false
if msg.err != nil {
m.statusErr = msg.err.Error()
if transport.IsRateLimited(msg.err) {
@@ -537,39 +548,32 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.statusErr = "rate limited — retry in 30s"
}
} else {
m.historyTotal = msg.total
if len(msg.items) == 0 || msg.exhausted {
m.historyExhausted = true
m.statusErr = ""
if msg.sub == HistReputation {
m.histModTotal = msg.total
if len(msg.items) == 0 || msg.exhausted {
m.histModExhausted = true
} else {
m.histModItems = append(m.histModItems, msg.items...)
m.histModOffset += len(msg.items)
if m.histModTotal > 0 && m.histModOffset >= m.histModTotal {
m.histModExhausted = true
}
}
} else {
m.store.Merge(msg.items)
m.historyOffset += len(msg.items)
if m.historyTotal > 0 && m.historyOffset >= m.historyTotal {
m.historyExhausted = true
m.histAtkTotal = msg.total
if len(msg.items) == 0 || msg.exhausted {
m.histAtkExhausted = true
} else {
m.histAtkItems = append(m.histAtkItems, msg.items...)
m.histAtkOffset += len(msg.items)
if m.histAtkTotal > 0 && m.histAtkOffset >= m.histAtkTotal {
m.histAtkExhausted = true
}
}
}
m.statusErr = ""
}
case moderationLoadedMsg:
if msg.err != nil {
m.statusErr = msg.err.Error()
if transport.IsRateLimited(msg.err) {
m.rateLimitUntil = time.Now().Add(30 * time.Second)
m.statusErr = "rate limited — retry in 30s"
}
} else {
m.moderationTotal = msg.total
if len(msg.items) == 0 || msg.exhausted {
m.moderationExhausted = true
} else {
m.store.Merge(msg.items)
m.moderationOffset += len(msg.items)
if m.moderationTotal > 0 && m.moderationOffset >= m.moderationTotal {
m.moderationExhausted = true
}
}
m.statusErr = ""
}
cmds = append(cmds, m.maybeLoadMoreHistory())
case blogLoadedMsg:
m.blogLoading = false
@@ -639,7 +643,8 @@ func (m *Model) updateFilter(msg tea.KeyMsg) (Model, tea.Cmd) {
m.filtering = false
m.filterInput.Blur()
m.cursor = 0
return *m, nil
cmd := m.refreshHistoryAfterFilter()
return *m, cmd
case tea.KeyCtrlC:
m.sse.Stop()
return *m, tea.Quit
@@ -868,6 +873,8 @@ func (m Model) listLen() int {
return len(m.visibleIncidents())
case TabPeers:
return len(m.peerRoster()) + len(m.visiblePeers())
case TabHistory:
return len(m.visibleHistoryItems())
case TabBlog:
if m.blogReading {
return 1
@@ -928,6 +935,8 @@ func (m Model) renderMain(height, width int) string {
return renderPeerView(m.theme, meshStr, m.peerRoster(), m.visiblePeers(), m.cursor, height, listW, m.cfg.Operator)
case TabStats:
return renderStatsView(m.theme, m.stats, m.network, width)
case TabHistory:
return m.renderHistoryView(height, listW)
case TabBlog:
if m.blogReading {
if m.blogLoading {
@@ -981,42 +990,6 @@ func loadSnapshot(api *transport.APIClient, recentLimit int) tea.Cmd {
}
}
func loadHistory(api *transport.APIClient, offset, limit int, filters historyFilters) tea.Cmd {
return func() tea.Msg {
resp, err := api.AttacksQuery(transport.AttackQuery{
Offset: offset, Limit: limit,
Service: filters.Service, Geo: filters.Geo,
PeerID: filters.PeerID, Since: filters.Since,
})
if err != nil {
return historyLoadedMsg{err: err}
}
items := make([]viz.FeedItem, 0, len(resp.Attacks))
for _, a := range resp.Attacks {
ip := viz.DisplayIP(a.IP, a.IPMasked)
peer := a.PeerID
if a.PeerPublicKeyHex != "" {
peer = a.PeerPublicKeyHex
}
items = append(items, viz.FeedItem{
ID: a.ID, Type: viz.FeedAttack, Timestamp: a.Timestamp,
Label: a.Service, Service: a.Service, IP: a.IP, IPMasked: ip,
Geo: a.Geo, PeerID: peer, PeerPublicKeyHex: a.PeerPublicKeyHex,
Port: a.Port, Summary: a.Summary,
Interaction: a.Interaction, BlobCount: a.BlobCount,
CoConspiratorCount: a.CoConspiratorCount,
ScoreAfter: a.ScoreAfter, ScoreDelta: a.ScoreDelta, TierAfter: a.TierAfter,
BlockScore: a.BlockScore, GreylistScore: a.GreylistScore,
})
}
exhausted := len(resp.Attacks) == 0 || len(resp.Attacks) < limit
if resp.Total > 0 && offset+len(resp.Attacks) >= resp.Total {
exhausted = true
}
return historyLoadedMsg{items: items, total: resp.Total, exhausted: exhausted}
}
}
func fetchAttackDetail(api *transport.APIClient, id string) tea.Cmd {
return func() tea.Msg {
a, err := api.AttackDetail(id)
@@ -1064,24 +1037,6 @@ func fetchModerationDetail(api *transport.APIClient, id string) tea.Cmd {
}
}
func loadModerationHistory(api *transport.APIClient, offset, limit int) tea.Cmd {
return func() tea.Msg {
resp, err := api.ModerationHistory(offset, limit)
if err != nil {
return moderationLoadedMsg{err: err}
}
items := make([]viz.FeedItem, 0, len(resp.Events))
for _, ev := range resp.Events {
items = append(items, viz.FeedFromModeration(ev))
}
exhausted := len(resp.Events) == 0 || len(resp.Events) < limit
if resp.Total > 0 && offset+len(resp.Events) >= resp.Total {
exhausted = true
}
return moderationLoadedMsg{items: items, total: resp.Total, exhausted: exhausted}
}
}
func loadBlogList(api *transport.APIClient, offset, limit int) tea.Cmd {
return func() tea.Msg {
resp, err := api.BlogList(offset, limit)
+6 -3
View File
@@ -106,9 +106,6 @@ func (m *Model) handleMouseWheel(ev tea.MouseEvent, ly viewLayout) []tea.Cmd {
switch ev.Button {
case tea.MouseButtonWheelUp:
if ev.Ctrl && m.tab == TabFeed {
cmds = append(cmds, m.requestHistory())
}
for i := 0; i < steps; i++ {
if m.cursor > 0 {
m.cursor--
@@ -124,6 +121,9 @@ func (m *Model) handleMouseWheel(ev tea.MouseEvent, ly viewLayout) []tea.Cmd {
}
}
m.follow = false
if m.tab == TabHistory {
cmds = append(cmds, m.maybeLoadMoreHistory())
}
}
return cmds
}
@@ -140,6 +140,9 @@ func (m *Model) handleMouseClick(ev tea.MouseEvent, ly viewLayout) []tea.Cmd {
if tab == TabBlog {
cmds = append(cmds, m.requestBlogList())
}
if tab == TabHistory {
cmds = append(cmds, m.ensureHistoryTabLoaded())
}
cmds = append(cmds, m.onTabChanged())
}
return cmds
+6
View File
@@ -34,6 +34,12 @@ func (m Model) selectedItem() selection {
cp := incs[m.cursor]
return selection{Kind: selIncident, Incident: &cp}
}
case TabHistory:
items := m.visibleHistoryItems()
if len(items) > 0 && m.cursor < len(items) {
cp := items[m.cursor]
return selection{Kind: selFeed, Feed: &cp}
}
case TabPeers:
roster := m.peerRoster()
if m.cursor < len(roster) {
+3
View File
@@ -22,6 +22,8 @@ func ParseFilterQuery(query string) AttackFilters {
f.PeerID = val
case "since":
f.Since = val
case "tier":
f.Tier = val
default:
free = append(free, part)
}
@@ -40,6 +42,7 @@ type AttackFilters struct {
Geo string
PeerID string
Since string
Tier string
Text string
}
+89
View File
@@ -0,0 +1,89 @@
package viz
import (
"fmt"
"strconv"
"time"
)
// HistoryRange matches web viz history chips (all, 1h, 24h, 7d).
type HistoryRange int
const (
HistoryAll HistoryRange = iota
History1h
History24h
History7d
)
func (r HistoryRange) Label() string {
switch r {
case History1h:
return "1h"
case History24h:
return "24h"
case History7d:
return "7d"
default:
return "all"
}
}
func (r HistoryRange) Next() HistoryRange {
return HistoryRange((int(r) + 1) % 4)
}
// SinceMS returns a unix-ms lower bound for API ?since=, or empty for all time.
func (r HistoryRange) SinceMS(now time.Time) string {
var since int64
switch r {
case History1h:
since = now.Add(-time.Hour).UnixMilli()
case History24h:
since = now.Add(-24 * time.Hour).UnixMilli()
case History7d:
since = now.Add(-7 * 24 * time.Hour).UnixMilli()
default:
return ""
}
return strconv.FormatInt(since, 10)
}
// ParseSinceToken converts filter tokens like 1h, 24h, 7d to unix ms.
func ParseSinceToken(s string) string {
s = stringsTrimSpace(s)
if s == "" {
return ""
}
if n, err := strconv.ParseInt(s, 10, 64); err == nil && n > 1_000_000_000_000 {
return s
}
now := time.Now()
switch s {
case "1h":
return strconv.FormatInt(now.Add(-time.Hour).UnixMilli(), 10)
case "24h", "1d":
return strconv.FormatInt(now.Add(-24*time.Hour).UnixMilli(), 10)
case "7d":
return strconv.FormatInt(now.Add(-7*24*time.Hour).UnixMilli(), 10)
default:
return ""
}
}
func HistoryRangeSummary(r HistoryRange, loaded, total int, exhausted bool) string {
rangeLbl := r.Label()
if total > 0 {
if exhausted || loaded >= total {
return fmt.Sprintf("history [%s]: %d / %d", rangeLbl, loaded, total)
}
return fmt.Sprintf("history [%s]: %d / %d", rangeLbl, loaded, total)
}
if exhausted {
return fmt.Sprintf("history [%s]: end", rangeLbl)
}
if loaded > 0 {
return fmt.Sprintf("history [%s]: %d loaded", rangeLbl, loaded)
}
return fmt.Sprintf("history [%s]", rangeLbl)
}
+31
View File
@@ -0,0 +1,31 @@
package viz
import (
"testing"
"time"
)
func TestHistoryRangeSinceMS(t *testing.T) {
now := mustParseTime("2026-01-01T12:00:00Z")
if HistoryAll.SinceMS(now) != "" {
t.Fatal("all time should be empty since")
}
s1h := History1h.SinceMS(now)
if s1h == "" {
t.Fatal("1h since expected")
}
}
func TestHistoryRangeNext(t *testing.T) {
if HistoryAll.Next() != History1h {
t.Fatal("next from all")
}
if History7d.Next() != HistoryAll {
t.Fatal("wrap to all")
}
}
func mustParseTime(s string) (t time.Time) {
t, _ = time.Parse(time.RFC3339, s)
return t
}