Add history tab
This commit is contained in:
@@ -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**.
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user