Skip to content

Commit abf5803

Browse files
authored
Merge pull request #17 from galaxy-io/atterpac/browse-bugs
browse bugs
2 parents c78c5c4 + 74d9b6a commit abf5803

9 files changed

Lines changed: 124 additions & 30 deletions

File tree

‎cmd/seeder/seeder.go‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,11 @@ func seedStreams(ctx context.Context, js jetstream.JetStream) {
9999
Discard: jetstream.DiscardOld,
100100
},
101101
{
102-
Name: "EVENTS",
103-
Subjects: []string{"events.>"},
102+
Name: "EVENTS",
103+
// Multiple disjoint subjects: "audit.>" does NOT start with
104+
// "events." — watching the stream must surface these too, not just
105+
// the first configured subject.
106+
Subjects: []string{"events.>", "audit.>"},
104107
Retention: jetstream.InterestPolicy,
105108
MaxAge: 72 * time.Hour,
106109
Storage: jetstream.FileStorage,
@@ -329,6 +332,18 @@ func seedMessages(ctx context.Context, js jetstream.JetStream, nc *nats.Conn) {
329332
})
330333
}
331334

335+
// Audit — second subject on the EVENTS stream that does NOT start with
336+
// "events.". Watching the EVENTS stream should surface these alongside
337+
// the events.* messages; the old single-subject watch missed them.
338+
auditActions := []string{"login", "logout", "permission.grant", "permission.revoke", "config.change"}
339+
for i := 0; i < 80; i++ {
340+
action := auditActions[rand.Intn(len(auditActions))]
341+
messages = append(messages, msg{
342+
subject: fmt.Sprintf("audit.%s", action),
343+
data: fmt.Sprintf(`{"action":"%s","actor":"admin-%03d","target":"usr-%05d","ip":"10.0.%d.%d","result":"%s"}`, action, rand.Intn(50), rand.Intn(10000), rand.Intn(256), rand.Intn(256), []string{"allow", "deny"}[rand.Intn(2)]),
344+
})
345+
}
346+
332347
// Logs
333348
for i := 0; i < 500; i++ {
334349
level := levels[rand.Intn(len(levels))]

‎go.mod‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ module github.com/galaxy-io/gnat
33
go 1.25.0
44

55
require (
6-
github.com/atterpac/dado v0.2.9
6+
github.com/atterpac/dado v0.2.10
77
github.com/gdamore/tcell/v2 v2.13.4
88
github.com/nats-io/nats.go v1.39.1
99
golang.org/x/term v0.37.0

‎go.sum‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
github.com/atterpac/dado v0.2.9 h1:wMDErpS1oMKC8J7NJd53oWpPG2ORM/QRNyW42aLxcak=
2-
github.com/atterpac/dado v0.2.9/go.mod h1:oBqIXWzQMu1DGh8sAuyBAM5wHrIz1anqPoa9jcfnTJE=
1+
github.com/atterpac/dado v0.2.10 h1:RELhKjHEHOz2SFULSeU2YQGqSZY7nCbfuLwWSIz2ies=
2+
github.com/atterpac/dado v0.2.10/go.mod h1:oBqIXWzQMu1DGh8sAuyBAM5wHrIz1anqPoa9jcfnTJE=
33
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
44
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
55
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

‎internal/nats/client.go‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -877,7 +877,19 @@ func (c *Client) SubscribeJetStream(ctx context.Context, subject string, policy
877877
if err != nil {
878878
return nil, fmt.Errorf("no stream found for subject %q: %w", subject, err)
879879
}
880+
return c.subscribeJetStream(ctx, streamName, subject, policy, handler)
881+
}
882+
883+
// SubscribeJetStreamStream creates a JetStream subscription over an entire
884+
// stream. The ephemeral consumer carries no subject filter, so every message
885+
// in the stream is delivered regardless of which configured subject it matched.
886+
func (c *Client) SubscribeJetStreamStream(ctx context.Context, streamName string, policy DeliverPolicy, handler func(LiveMessage)) (Subscription, error) {
887+
return c.subscribeJetStream(ctx, streamName, "", policy, handler)
888+
}
880889

890+
// subscribeJetStream creates an ephemeral consumer on streamName. An empty
891+
// filterSubject watches the whole stream; a non-empty one narrows to that subject.
892+
func (c *Client) subscribeJetStream(ctx context.Context, streamName, filterSubject string, policy DeliverPolicy, handler func(LiveMessage)) (Subscription, error) {
881893
stream, err := c.jetStream().Stream(ctx, streamName)
882894
if err != nil {
883895
return nil, fmt.Errorf("getting stream %s: %w", streamName, err)
@@ -898,9 +910,9 @@ func (c *Client) SubscribeJetStream(ctx context.Context, subject string, policy
898910
jsPolicy = jetstream.DeliverAllPolicy
899911
}
900912

901-
// Create ephemeral consumer
913+
// Create ephemeral consumer. An empty FilterSubject delivers all subjects.
902914
consumer, err := stream.CreateOrUpdateConsumer(ctx, jetstream.ConsumerConfig{
903-
FilterSubject: subject,
915+
FilterSubject: filterSubject,
904916
DeliverPolicy: jsPolicy,
905917
AckPolicy: jetstream.AckNonePolicy, // No acks needed for monitoring
906918
InactiveThreshold: 5 * time.Minute,

‎internal/nats/provider.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ type Provider interface {
108108

109109
// JetStream subscription with replay capability
110110
SubscribeJetStream(ctx context.Context, subject string, policy DeliverPolicy, handler func(LiveMessage)) (Subscription, error)
111+
112+
// JetStream subscription over an entire stream (no subject filter — all subjects)
113+
SubscribeJetStreamStream(ctx context.Context, streamName string, policy DeliverPolicy, handler func(LiveMessage)) (Subscription, error)
111114
}
112115

113116
// ConnectionStats holds basic connection statistics.

‎internal/view/app.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ func (a *App) setup() {
157157
}
158158
if a.app.Pages().CanPop() {
159159
a.app.Pages().Pop()
160+
a.app.SetFocus(a.app.Pages())
160161
return nil
161162
}
162163
return event
@@ -310,6 +311,11 @@ func (a *App) NavigateToMessageMonitorWithSubject(subject string) {
310311
a.app.Pages().Push(view)
311312
}
312313

314+
func (a *App) NavigateToMessageMonitorForStream(streamName string) {
315+
view := NewMessageMonitorForStream(a, streamName)
316+
a.app.Pages().Push(view)
317+
}
318+
313319
func (a *App) NavigateToConsumerLag() {
314320
view := NewConsumerLag(a)
315321
a.app.Pages().Push(view)

‎internal/view/message_monitor.go‎

Lines changed: 75 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ type MessageMonitor struct {
7979
// Initial subject (for auto-subscribe on Start)
8080
initSubject string
8181

82+
// When set, the monitor watches this entire stream (all subjects) instead
83+
// of a single subject filter. The subject input becomes a client-side filter.
84+
streamName string
85+
8286
// JSON path / pipeline filter for preview pane
8387
jsonFilter string
8488
pipeline *Pipeline
@@ -89,6 +93,24 @@ func NewMessageMonitor(app *App) *MessageMonitor {
8993
return NewMessageMonitorWithSubject(app, "")
9094
}
9195

96+
// NewMessageMonitorForStream creates a monitor that watches an entire stream
97+
// (every subject it stores), rather than a single subject filter. The subject
98+
// field acts as a client-side filter over the live messages instead of
99+
// changing the subscription.
100+
func NewMessageMonitorForStream(app *App, streamName string) *MessageMonitor {
101+
mm := &MessageMonitor{
102+
app: app,
103+
useJetStream: true,
104+
deliverPolicy: nats.DeliverAll,
105+
streamName: streamName,
106+
processCancel: func() {},
107+
}
108+
109+
mm.buildUI("")
110+
mm.setupBinding()
111+
return mm
112+
}
113+
92114
// NewMessageMonitorWithSubject creates the message monitor with a pre-filled subject.
93115
func NewMessageMonitorWithSubject(app *App, subject string) *MessageMonitor {
94116
mm := &MessageMonitor{
@@ -123,16 +145,30 @@ func (mm *MessageMonitor) buildUI(subject string) {
123145
SetWordWrap(true)
124146
mm.preview.SetBackgroundColor(theme.Bg())
125147

126-
// Subject input
148+
// Subject input. In stream-watch mode it filters the live list client-side
149+
// rather than re-subscribing, since the subscription already covers the
150+
// whole stream.
151+
subjectLabel := "Subject: "
152+
subjectPlaceholder := ">"
153+
if mm.streamName != "" {
154+
subjectLabel = "Filter: "
155+
subjectPlaceholder = "filter subjects..."
156+
}
127157
mm.subjectInput = components.NewTextField("subject").
128-
SetLabel("Subject: ").
129-
SetPlaceholder(">")
158+
SetLabel(subjectLabel).
159+
SetPlaceholder(subjectPlaceholder)
130160
mm.subjectInput.SetBackgroundColor(theme.Bg())
131161
mm.subjectInput.SetOnSubmit(func(_ *components.SubmitEvent) {
132-
subj := mm.subjectInput.GetValue()
133-
// subscribe is goroutine-safe; dispatch off main goroutine so
134-
// the NATS handshake cannot block the event loop.
135-
go mm.subscribe(subj)
162+
val := mm.subjectInput.GetValue()
163+
// Move focus off the text input so arrow keys navigate the message
164+
// list instead of being captured by the field.
165+
mm.app.app.SetFocus(mm.MasterDetailView)
166+
// Dispatch off the main goroutine so the work can't block the event loop.
167+
if mm.streamName != "" {
168+
go mm.setFilter(val)
169+
return
170+
}
171+
go mm.subscribe(val)
136172
})
137173
if subject != "" {
138174
mm.subjectInput.SetValue(subject)
@@ -237,6 +273,12 @@ func (mm *MessageMonitor) Start() {
237273
// Start() runs on main goroutine — safe to touch UI directly.
238274
atomic.StoreInt32(&mm.stopped, 0)
239275

276+
// Whole-stream watch: subscribe to the entire stream regardless of subject.
277+
if mm.streamName != "" {
278+
go mm.subscribe("")
279+
return
280+
}
281+
240282
// Re-subscribe to whatever subject was active (or the initial one).
241283
subject := mm.state.Get().subject
242284
if subject == "" {
@@ -434,10 +476,23 @@ func (mm *MessageMonitor) HandleKey(event *tcell.EventKey) bool {
434476
// ── Subscribe / Unsubscribe ────────────────────────────────────────────────
435477

436478
func (mm *MessageMonitor) subscribe(subject string) {
437-
if subject == "" || atomic.LoadInt32(&mm.stopped) == 1 {
479+
if atomic.LoadInt32(&mm.stopped) == 1 {
438480
return
439481
}
440482

483+
// Whole-stream watch mode: no subject filter, drive off the stream name.
484+
streamMode := mm.streamName != "" && subject == ""
485+
if subject == "" && !streamMode {
486+
return
487+
}
488+
489+
// Label used for display/state (the subscription itself is by stream in
490+
// stream mode, so there is no single subject to show).
491+
displayLabel := subject
492+
if streamMode {
493+
displayLabel = fmt.Sprintf("%s (all subjects)", mm.streamName)
494+
}
495+
441496
// Tear down any existing subscription first.
442497
mm.doUnsubscribe()
443498

@@ -461,15 +516,15 @@ func (mm *MessageMonitor) subscribe(subject string) {
461516
// Push "subscribing" state.
462517
mm.state.SetAndDraw(monitorState{
463518
status: statusSubscribing,
464-
subject: subject,
519+
subject: displayLabel,
465520
mode: mm.modeLabel(),
466521
policy: mm.policyLabel(),
467522
})
468523

469524
// Start processor goroutine for this subscription.
470525
processCtx, processCancel := context.WithCancel(context.Background())
471526
mm.processCancel = processCancel
472-
go mm.processMessages(processCtx, myGen, msgChan, subject)
527+
go mm.processMessages(processCtx, myGen, msgChan, displayLabel)
473528

474529
// Handler: never blocks, never holds locks. Channel send is guarded by
475530
// recover so a closed-channel panic in the narrow unsubscribe race is safe.
@@ -489,16 +544,21 @@ func (mm *MessageMonitor) subscribe(subject string) {
489544
var err error
490545
var modeLabel string
491546

492-
if useJS {
547+
if streamMode {
548+
// Whole-stream watch is inherently JetStream — no single subject for
549+
// a core NATS subscription to bind to.
550+
sub, err = provider.SubscribeJetStreamStream(context.Background(), mm.streamName, policy, handler)
551+
modeLabel = "JetStream"
552+
} else if useJS {
493553
sub, err = provider.SubscribeJetStream(context.Background(), subject, policy, handler)
494554
modeLabel = "JetStream"
495555
} else {
496556
sub, err = provider.Subscribe(context.Background(), subject, handler)
497557
modeLabel = "Core NATS"
498558
}
499559

500-
// Fallback to core NATS if JetStream fails.
501-
if err != nil && useJS {
560+
// Fallback to core NATS if JetStream fails (single-subject mode only).
561+
if err != nil && useJS && !streamMode {
502562
sub, err = provider.Subscribe(context.Background(), subject, handler)
503563
if err == nil {
504564
modeLabel = "Core NATS (fallback)"
@@ -511,7 +571,7 @@ func (mm *MessageMonitor) subscribe(subject string) {
511571
if err != nil {
512572
mm.state.SetAndDraw(monitorState{
513573
status: statusError,
514-
subject: subject,
574+
subject: displayLabel,
515575
errMsg: err.Error(),
516576
mode: mm.modeLabel(),
517577
policy: mm.policyLabel(),
@@ -533,7 +593,7 @@ func (mm *MessageMonitor) subscribe(subject string) {
533593
mm.mu.Unlock()
534594

535595
mm.app.QueueUpdateDraw(func() {
536-
mm.app.ShowSuccess(fmt.Sprintf("Subscribed to %s (%s)", subject, modeLabel))
596+
mm.app.ShowSuccess(fmt.Sprintf("Subscribed to %s (%s)", displayLabel, modeLabel))
537597
})
538598
}
539599

‎internal/view/stream_detail.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,8 @@ func (sd *StreamDetail) HandleKey(event *tcell.EventKey) bool {
137137
go sd.showSubjectBreakdown()
138138
return true
139139
case 'w':
140-
if info := sd.info.Get(); info != nil && len(info.Config.Subjects) > 0 {
141-
sd.app.NavigateToMessageMonitorWithSubject(info.Config.Subjects[0])
140+
if info := sd.info.Get(); info != nil {
141+
sd.app.NavigateToMessageMonitorForStream(info.Config.Name)
142142
}
143143
return true
144144
case 'e':

‎internal/view/stream_list.go‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,8 @@ func NewStreamList(app *App) *StreamList {
8585
}).
8686
SetRefreshInterval(10 * time.Second).
8787
SetOnSelect(func(s *jetstream.StreamInfo) {
88-
// Enter opens watch view directly
89-
if len(s.Config.Subjects) > 0 {
90-
sl.app.NavigateToMessageMonitorWithSubject(s.Config.Subjects[0])
91-
}
88+
// Enter opens watch view directly — watch the whole stream.
89+
sl.app.NavigateToMessageMonitorForStream(s.Config.Name)
9290
}).
9391
SetOnRefresh(func(data []*jetstream.StreamInfo, err error) {
9492
if err != nil {
@@ -201,8 +199,8 @@ func (sl *StreamList) Hints() []components.KeyHint {
201199
func (sl *StreamList) HandleKey(event *tcell.EventKey) bool {
202200
switch {
203201
case event.Key() == tcell.KeyEnter:
204-
if s, ok := sl.binding.GetSelectedValue(); ok && s != nil && len(s.Config.Subjects) > 0 {
205-
sl.app.NavigateToMessageMonitorWithSubject(s.Config.Subjects[0])
202+
if s, ok := sl.binding.GetSelectedValue(); ok && s != nil {
203+
sl.app.NavigateToMessageMonitorForStream(s.Config.Name)
206204
}
207205
return true
208206
case event.Rune() == 'v':

0 commit comments

Comments
 (0)