Skip to content

Commit b35a1b9

Browse files
feat: implement strata query input and improve pulse grouping
- Implement interactive query prompt in Strata UI when pressing '/' - Fix Strata startup schema error by ignoring empty queries on connection - Clean up Pulse groups view by normalizing timestamps to <TIMESTAMP> and IPs to <IP> Signed-off-by: night-slayer18 <samanuaia257@gmail.com>
1 parent c920a02 commit b35a1b9

3 files changed

Lines changed: 107 additions & 20 deletions

File tree

apps/pulse/internal/model/group.go

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,22 @@ import (
1111
// more specific patterns (UUIDs, hex) must run before the generic number
1212
// pattern would otherwise partially rewrite them.
1313
var (
14-
reUUID = regexp.MustCompile(`(?i)\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b`)
15-
reHex = regexp.MustCompile(`(?i)\b0x[0-9a-f]+\b|\b[0-9a-f]{8,}\b`)
16-
reNumber = regexp.MustCompile(`\b\d+(\.\d+)?\b`)
17-
reSpaces = regexp.MustCompile(`\s+`)
14+
reUUID = regexp.MustCompile(`(?i)\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b`)
15+
reTimestamp = regexp.MustCompile(`\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?\b`)
16+
reIP = regexp.MustCompile(`\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b`)
17+
reHex = regexp.MustCompile(`(?i)\b0x[0-9a-f]+\b|\b[0-9a-f]{8,}\b`)
18+
reNumber = regexp.MustCompile(`\b\d+(\.\d+)?\b`)
19+
reSpaces = regexp.MustCompile(`\s+`)
1820
)
1921

20-
// Normalize collapses the variable parts of a log line — UUIDs, hex strings,
21-
// and numeric literals — into fixed placeholder tokens and squeezes runs of
22-
// whitespace. Two log lines that differ only in those variable parts normalize
23-
// to the same template, which is the basis for grouping similar errors.
22+
// Normalize collapses the variable parts of a log line — UUIDs, timestamps,
23+
// IP addresses, hex strings, and numeric literals — into fixed placeholder tokens
24+
// and squeezes runs of whitespace. Two log lines that differ only in those variable
25+
// parts normalize to the same template, which is the basis for grouping similar errors.
2426
func Normalize(line string) string {
2527
s := reUUID.ReplaceAllString(line, "<UUID>")
28+
s = reTimestamp.ReplaceAllString(s, "<TIMESTAMP>")
29+
s = reIP.ReplaceAllString(s, "<IP>")
2630
s = reHex.ReplaceAllString(s, "<HEX>")
2731
s = reNumber.ReplaceAllString(s, "<NUM>")
2832
s = reSpaces.ReplaceAllString(s, " ")

apps/pulse/internal/model/group_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,31 @@ func TestNormalizeCollapsesWhitespace(t *testing.T) {
3434
}
3535
}
3636

37+
func TestNormalizeReplacesTimestamp(t *testing.T) {
38+
cases := []struct {
39+
in string
40+
want string
41+
}{
42+
{"2024-05-17 09:00:01 INFO log message", "<TIMESTAMP> INFO log message"},
43+
{"time=2024-05-17T09:00:01Z level=info", "time=<TIMESTAMP> level=info"},
44+
{"time=2024-05-17T09:00:01.123456Z level=info", "time=<TIMESTAMP> level=info"},
45+
{"time=2024-05-17T09:00:01+05:30 level=info", "time=<TIMESTAMP> level=info"},
46+
}
47+
for _, tc := range cases {
48+
if got := Normalize(tc.in); got != tc.want {
49+
t.Errorf("Normalize(%q) = %q, want %q", tc.in, got, tc.want)
50+
}
51+
}
52+
}
53+
54+
func TestNormalizeReplacesIP(t *testing.T) {
55+
got := Normalize("client 10.0.4.18 exceeded limit")
56+
want := "client <IP> exceeded limit"
57+
if got != want {
58+
t.Fatalf("got %q want %q", got, want)
59+
}
60+
}
61+
3762
func TestGroupSimilarCollapsesByTemplate(t *testing.T) {
3863
in := entries(
3964
"connection failed for user 1",

apps/strata/internal/ui/root.go

Lines changed: 70 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ type Root struct {
2525
size tui.Size
2626
showHelp bool
2727

28+
// searching reports whether the query input line is active.
29+
searching bool
30+
// queryInput holds the in-progress query while searching.
31+
queryInput string
2832
// conn is the connection string supplied on the command line, if any. It is
2933
// resolved to a backend + DSN and connected on Init.
3034
conn string
@@ -87,15 +91,60 @@ func (r Root) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
8791
r.connect(msg.conn)
8892
return r, nil
8993
case tea.KeyMsg:
90-
switch r.keys.Dispatch(msg) {
91-
case tui.ActionQuit:
92-
return r, tea.Quit
93-
case tui.ActionHelp:
94-
r.showHelp = !r.showHelp
95-
case tui.ActionUp, tui.ActionDown, tui.ActionLeft, tui.ActionRight,
96-
tui.ActionPageUp, tui.ActionPageDown, tui.ActionTop, tui.ActionBottom:
97-
r.state.Table.Navigate(msg.String())
94+
return r.handleKey(msg)
95+
}
96+
return r, nil
97+
}
98+
99+
// handleKey routes key presses either to the query input line or to standard navigation.
100+
func (r Root) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
101+
if r.searching {
102+
return r.handleSearchKey(msg)
103+
}
104+
105+
switch r.keys.Dispatch(msg) {
106+
case tui.ActionQuit:
107+
return r, tea.Quit
108+
case tui.ActionHelp:
109+
r.showHelp = !r.showHelp
110+
case tui.ActionSearch:
111+
r.searching = true
112+
// Pre-populate input with current query if any
113+
r.queryInput = ""
114+
case tui.ActionUp, tui.ActionDown, tui.ActionLeft, tui.ActionRight,
115+
tui.ActionPageUp, tui.ActionPageDown, tui.ActionTop, tui.ActionBottom:
116+
r.state.Table.Navigate(msg.String())
117+
}
118+
return r, nil
119+
}
120+
121+
// handleSearchKey handles character input and executes or cancels queries.
122+
func (r Root) handleSearchKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
123+
switch msg.Type {
124+
case tea.KeyEnter:
125+
r.searching = false
126+
if r.queryInput != "" {
127+
r.state.SetSchemaQuery(r.queryInput)
128+
if _, err := r.state.ReadSchema(); err != nil {
129+
r.status = fmt.Sprintf("schema error: %v", err)
130+
return r, nil
131+
}
132+
n, err := r.state.RunQuery(r.queryInput)
133+
if err != nil {
134+
r.status = fmt.Sprintf("query error: %v", err)
135+
return r, nil
136+
}
137+
r.status = fmt.Sprintf("loaded %d rows", n)
98138
}
139+
case tea.KeyEsc:
140+
r.searching = false
141+
r.queryInput = ""
142+
case tea.KeyBackspace:
143+
if len(r.queryInput) > 0 {
144+
r.queryInput = r.queryInput[:len(r.queryInput)-1]
145+
}
146+
case tea.KeyRunes, tea.KeySpace:
147+
r.queryInput += string(msg.Runes)
99148
}
100149
return r, nil
101150
}
@@ -115,7 +164,9 @@ func (r Root) View() string {
115164
}
116165

117166
var footer string
118-
if r.showHelp {
167+
if r.searching {
168+
footer = r.styles.Footer.Width(r.size.Width).Render("Query: " + r.queryInput + "▏")
169+
} else if r.showHelp {
119170
footer = r.styles.Footer.Width(r.size.Width).Render(r.help.View(r.keys))
120171
} else {
121172
hints := r.help.ShortHelpView(r.keys.ShortHelp())
@@ -143,10 +194,17 @@ func (r *Root) connect(conn string) {
143194
r.status = fmt.Sprintf("connect %s: %v", backend, err)
144195
return
145196
}
146-
// For SQL backends, derive the schema from the connected database/table the
147-
// user is exploring. When the source exposes fixed metadata this is a no-op.
197+
// For SQL/NoSQL backends, derive the schema from the connected database/table the
198+
// user is exploring. Ignore initial lack of query/collection errors.
148199
if _, err := r.state.ReadSchema(); err != nil {
149-
r.status = err.Error()
200+
errMsg := err.Error()
201+
if strings.Contains(errMsg, "no schema query configured") ||
202+
strings.Contains(errMsg, "no collection configured") ||
203+
strings.Contains(errMsg, "no table configured") {
204+
r.status = fmt.Sprintf("connected to %s", backend)
205+
return
206+
}
207+
r.status = errMsg
150208
return
151209
}
152210
r.status = fmt.Sprintf("connected to %s", backend)

0 commit comments

Comments
 (0)