diff --git a/cmd/sql-tapd/main.go b/cmd/sql-tapd/main.go index dc9eee8..a4d33c5 100644 --- a/cmd/sql-tapd/main.go +++ b/cmd/sql-tapd/main.go @@ -22,6 +22,7 @@ import ( "github.com/mickamy/sql-tap/proxy" "github.com/mickamy/sql-tap/proxy/mysql" "github.com/mickamy/sql-tap/proxy/postgres" + "github.com/mickamy/sql-tap/query" "github.com/mickamy/sql-tap/server" "github.com/mickamy/sql-tap/web" ) @@ -156,6 +157,9 @@ func run( go func() { for ev := range p.Events() { + if ev.Query != "" { + ev.NormalizedQuery = query.Normalize(ev.Query) + } if det != nil && isSelectQuery(ev.Op, ev.Query) { r := det.Record(ev.Query, ev.StartTime) ev.NPlus1 = r.Matched diff --git a/gen/tap/v1/tap.pb.go b/gen/tap/v1/tap.pb.go index d9ab2c6..3f191f7 100644 --- a/gen/tap/v1/tap.pb.go +++ b/gen/tap/v1/tap.pb.go @@ -24,19 +24,20 @@ const ( ) type QueryEvent struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - Op int32 `protobuf:"varint,2,opt,name=op,proto3" json:"op,omitempty"` - Query string `protobuf:"bytes,3,opt,name=query,proto3" json:"query,omitempty"` - Args []string `protobuf:"bytes,4,rep,name=args,proto3" json:"args,omitempty"` - StartTime *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=start_time,json=startTime,proto3" json:"start_time,omitempty"` - Duration *durationpb.Duration `protobuf:"bytes,6,opt,name=duration,proto3" json:"duration,omitempty"` - RowsAffected int64 `protobuf:"varint,7,opt,name=rows_affected,json=rowsAffected,proto3" json:"rows_affected,omitempty"` - Error string `protobuf:"bytes,8,opt,name=error,proto3" json:"error,omitempty"` - TxId string `protobuf:"bytes,9,opt,name=tx_id,json=txId,proto3" json:"tx_id,omitempty"` - NPlus_1 bool `protobuf:"varint,10,opt,name=n_plus_1,json=nPlus1,proto3" json:"n_plus_1,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Op int32 `protobuf:"varint,2,opt,name=op,proto3" json:"op,omitempty"` + Query string `protobuf:"bytes,3,opt,name=query,proto3" json:"query,omitempty"` + Args []string `protobuf:"bytes,4,rep,name=args,proto3" json:"args,omitempty"` + StartTime *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=start_time,json=startTime,proto3" json:"start_time,omitempty"` + Duration *durationpb.Duration `protobuf:"bytes,6,opt,name=duration,proto3" json:"duration,omitempty"` + RowsAffected int64 `protobuf:"varint,7,opt,name=rows_affected,json=rowsAffected,proto3" json:"rows_affected,omitempty"` + Error string `protobuf:"bytes,8,opt,name=error,proto3" json:"error,omitempty"` + TxId string `protobuf:"bytes,9,opt,name=tx_id,json=txId,proto3" json:"tx_id,omitempty"` + NPlus_1 bool `protobuf:"varint,10,opt,name=n_plus_1,json=nPlus1,proto3" json:"n_plus_1,omitempty"` + NormalizedQuery string `protobuf:"bytes,11,opt,name=normalized_query,json=normalizedQuery,proto3" json:"normalized_query,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *QueryEvent) Reset() { @@ -139,6 +140,13 @@ func (x *QueryEvent) GetNPlus_1() bool { return false } +func (x *QueryEvent) GetNormalizedQuery() string { + if x != nil { + return x.NormalizedQuery + } + return "" +} + type WatchRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -327,7 +335,7 @@ var File_tap_v1_tap_proto protoreflect.FileDescriptor const file_tap_v1_tap_proto_rawDesc = "" + "\n" + - "\x10tap/v1/tap.proto\x12\x06tap.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/duration.proto\"\xb2\x02\n" + + "\x10tap/v1/tap.proto\x12\x06tap.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/duration.proto\"\xdd\x02\n" + "\n" + "QueryEvent\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x0e\n" + @@ -341,7 +349,8 @@ const file_tap_v1_tap_proto_rawDesc = "" + "\x05error\x18\b \x01(\tR\x05error\x12\x13\n" + "\x05tx_id\x18\t \x01(\tR\x04txId\x12\x18\n" + "\bn_plus_1\x18\n" + - " \x01(\bR\x06nPlus1\"\x0e\n" + + " \x01(\bR\x06nPlus1\x12)\n" + + "\x10normalized_query\x18\v \x01(\tR\x0fnormalizedQuery\"\x0e\n" + "\fWatchRequest\"9\n" + "\rWatchResponse\x12(\n" + "\x05event\x18\x01 \x01(\v2\x12.tap.v1.QueryEventR\x05event\"T\n" + diff --git a/proto/tap/v1/tap.proto b/proto/tap/v1/tap.proto index 2d5c56c..77f4aa2 100644 --- a/proto/tap/v1/tap.proto +++ b/proto/tap/v1/tap.proto @@ -16,6 +16,7 @@ message QueryEvent { string error = 8; string tx_id = 9; bool n_plus_1 = 10; + string normalized_query = 11; } message WatchRequest {} diff --git a/proxy/proxy.go b/proxy/proxy.go index 20431ea..41f2d3c 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -44,16 +44,17 @@ func (o Op) String() string { // Event represents a captured database query event. type Event struct { - ID string - Op Op - Query string - Args []string - StartTime time.Time - Duration time.Duration - RowsAffected int64 - Error string - TxID string - NPlus1 bool + ID string + Op Op + Query string + Args []string + StartTime time.Time + Duration time.Duration + RowsAffected int64 + Error string + TxID string + NPlus1 bool + NormalizedQuery string } // Proxy is the common interface for DB protocol proxies. diff --git a/query/normalize.go b/query/normalize.go new file mode 100644 index 0000000..1b26004 --- /dev/null +++ b/query/normalize.go @@ -0,0 +1,115 @@ +package query + +import "strings" + +// Normalize replaces literal values in a SQL query with placeholders, +// so that structurally identical queries can be grouped together. +// +// String literals ('...') are replaced with '?', standalone numeric +// literals are replaced with ?, and $N parameters are kept as-is. +// Consecutive whitespace is collapsed to a single space. +func Normalize(sql string) string { + if sql == "" { + return "" + } + + var b strings.Builder + b.Grow(len(sql)) + + i := 0 + prevSpace := false + for i < len(sql) { + ch := sql[i] + + if ch == '\'' { + i = normalizeString(&b, sql, i) + prevSpace = false + continue + } + + if ch == '$' && i+1 < len(sql) && isDigit(sql[i+1]) { + i = keepParam(&b, sql, i) + prevSpace = false + continue + } + + if isDigit(ch) && (i == 0 || isNumBoundary(sql[i-1])) { + if next, ok := normalizeNumber(&b, sql, i); ok { + i = next + prevSpace = false + continue + } + } + + if isSpace(ch) { + if !prevSpace && b.Len() > 0 { + b.WriteByte(' ') + prevSpace = true + } + i++ + continue + } + + b.WriteByte(ch) + i++ + prevSpace = false + } + + return strings.TrimRight(b.String(), " ") +} + +// normalizeString replaces a string literal starting at pos with '?'. +func normalizeString(b *strings.Builder, sql string, pos int) int { + j := pos + 1 + for j < len(sql) { + if sql[j] == '\'' && j+1 < len(sql) && sql[j+1] == '\'' { + j += 2 + continue + } + if sql[j] == '\'' { + j++ + break + } + j++ + } + b.WriteString("'?'") + return j +} + +// keepParam writes $N parameter as-is and returns the new position. +func keepParam(b *strings.Builder, sql string, pos int) int { + b.WriteByte('$') + j := pos + 1 + for j < len(sql) && isDigit(sql[j]) { + b.WriteByte(sql[j]) + j++ + } + return j +} + +// normalizeNumber replaces a numeric literal at pos with '?'. +// Returns (newPos, true) if replaced, or (0, false) if not a standalone number. +func normalizeNumber(b *strings.Builder, sql string, pos int) (int, bool) { + j := pos + 1 + for j < len(sql) && (isDigit(sql[j]) || sql[j] == '.') { + j++ + } + if j >= len(sql) || isNumBoundary(sql[j]) { + b.WriteByte('?') + return j, true + } + return 0, false +} + +func isDigit(c byte) bool { return c >= '0' && c <= '9' } + +func isSpace(c byte) bool { + return c == ' ' || c == '\t' || c == '\n' || c == '\r' +} + +func isNumBoundary(c byte) bool { + return isSpace(c) || + c == ',' || c == '(' || c == ')' || c == '=' || + c == '<' || c == '>' || c == '+' || c == '-' || + c == '*' || c == '/' || c == ';' +} diff --git a/query/normalize_test.go b/query/normalize_test.go new file mode 100644 index 0000000..c0d3d7c --- /dev/null +++ b/query/normalize_test.go @@ -0,0 +1,40 @@ +package query_test + +import ( + "testing" + + "github.com/mickamy/sql-tap/query" +) + +func TestNormalize(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want string + }{ + {"empty", "", ""}, + {"string literal", "SELECT id FROM users WHERE name = 'alice'", "SELECT id FROM users WHERE name = '?'"}, + {"escaped quote", "WHERE name = 'it''s'", "WHERE name = '?'"}, + {"numeric literal", "SELECT id, name FROM users WHERE id = 42", "SELECT id, name FROM users WHERE id = ?"}, + {"float literal", "WHERE score > 3.14", "WHERE score > ?"}, + {"pg param kept", "WHERE id = $1 AND name = $2", "WHERE id = $1 AND name = $2"}, + {"in list", "WHERE id IN (1, 2, 3)", "WHERE id IN (?, ?, ?)"}, + {"mixed", "WHERE id = 42 AND name = 'bob' AND status = $1", "WHERE id = ? AND name = '?' AND status = $1"}, + {"whitespace collapse", "SELECT id\n\tFROM users", "SELECT id FROM users"}, + {"leading trailing space", " SELECT 1 ", "SELECT ?"}, + {"no replace in identifier", "SELECT t1.id FROM t1", "SELECT t1.id FROM t1"}, + {"negative number", "WHERE x = -5", "WHERE x = -?"}, + {"multiple string literals", "INSERT INTO t (a, b) VALUES ('x', 'y')", "INSERT INTO t (a, b) VALUES ('?', '?')"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := query.Normalize(tt.in) + if got != tt.want { + t.Errorf("Normalize(%q)\n got %q\n want %q", tt.in, got, tt.want) + } + }) + } +} diff --git a/server/server.go b/server/server.go index 94951db..2299691 100644 --- a/server/server.go +++ b/server/server.go @@ -109,16 +109,17 @@ func eventToProto(ev proxy.Event) *tapv1.QueryEvent { args[i] = sanitizeUTF8(a) } return &tapv1.QueryEvent{ - Id: ev.ID, - Op: int32(ev.Op), - Query: sanitizeUTF8(ev.Query), - Args: args, - StartTime: timestamppb.New(ev.StartTime), - Duration: durationpb.New(ev.Duration), - RowsAffected: ev.RowsAffected, - Error: sanitizeUTF8(ev.Error), - TxId: ev.TxID, - NPlus_1: ev.NPlus1, + Id: ev.ID, + Op: int32(ev.Op), + Query: sanitizeUTF8(ev.Query), + Args: args, + StartTime: timestamppb.New(ev.StartTime), + Duration: durationpb.New(ev.Duration), + RowsAffected: ev.RowsAffected, + Error: sanitizeUTF8(ev.Error), + TxId: ev.TxID, + NPlus_1: ev.NPlus1, + NormalizedQuery: sanitizeUTF8(ev.NormalizedQuery), } } diff --git a/tui/analytics.go b/tui/analytics.go index 90bf500..3008591 100644 --- a/tui/analytics.go +++ b/tui/analytics.go @@ -1,8 +1,10 @@ package tui import ( + "cmp" "context" "fmt" + "slices" "sort" "strings" "time" @@ -20,6 +22,7 @@ const ( analyticsSortTotalDuration analyticsSortMode = iota analyticsSortCount analyticsSortAvgDuration + analyticsSortP95Duration ) func (s analyticsSortMode) String() string { @@ -30,6 +33,8 @@ func (s analyticsSortMode) String() string { return "count" case analyticsSortAvgDuration: return "avg" + case analyticsSortP95Duration: + return "p95" } return "total" } @@ -41,6 +46,8 @@ func (s analyticsSortMode) next() analyticsSortMode { case analyticsSortCount: return analyticsSortAvgDuration case analyticsSortAvgDuration: + return analyticsSortP95Duration + case analyticsSortP95Duration: return analyticsSortTotalDuration } return analyticsSortTotalDuration @@ -51,12 +58,15 @@ type analyticsRow struct { count int totalDuration time.Duration avgDuration time.Duration + p95Duration time.Duration + maxDuration time.Duration } func (m Model) buildAnalyticsRows() []analyticsRow { type agg struct { - count int - totalDur time.Duration + count int + totalDur time.Duration + durations []time.Duration } groups := make(map[string]*agg) @@ -67,32 +77,45 @@ func (m Model) buildAnalyticsRows() []analyticsRow { case proxy.OpQuery, proxy.OpExec, proxy.OpExecute: } - q := ev.GetQuery() - if q == "" { + nq := ev.GetNormalizedQuery() + if nq == "" { continue } - g, ok := groups[q] + dur := ev.GetDuration().AsDuration() + g, ok := groups[nq] if !ok { g = &agg{} - groups[q] = g + groups[nq] = g } g.count++ - g.totalDur += ev.GetDuration().AsDuration() + g.totalDur += dur + g.durations = append(g.durations, dur) } rows := make([]analyticsRow, 0, len(groups)) for q, g := range groups { + slices.SortFunc(g.durations, cmp.Compare) rows = append(rows, analyticsRow{ query: q, count: g.count, totalDuration: g.totalDur, avgDuration: g.totalDur / time.Duration(g.count), + p95Duration: percentile(g.durations, 0.95), + maxDuration: g.durations[len(g.durations)-1], }) } return rows } +func percentile(sorted []time.Duration, p float64) time.Duration { + if len(sorted) == 0 { + return 0 + } + idx := int(float64(len(sorted)-1) * p) + return sorted[idx] +} + func sortAnalyticsRows(rows []analyticsRow, mode analyticsSortMode) { sort.Slice(rows, func(i, j int) bool { switch mode { @@ -102,6 +125,8 @@ func sortAnalyticsRows(rows []analyticsRow, mode analyticsSortMode) { return rows[i].count > rows[j].count case analyticsSortAvgDuration: return rows[i].avgDuration > rows[j].avgDuration + case analyticsSortP95Duration: + return rows[i].p95Duration > rows[j].p95Duration } return rows[i].totalDuration > rows[j].totalDuration }) @@ -171,6 +196,8 @@ const ( analyticsColMarker = 2 // "▶ " or " " analyticsColCount = 7 // " Count" right-aligned analyticsColAvg = 10 // " Avg" right-aligned + analyticsColP95 = 10 // " P95" right-aligned + analyticsColMax = 10 // " Max" right-aligned analyticsColTotal = 10 // " Total" right-aligned ) @@ -179,9 +206,11 @@ func (m Model) analyticsVisibleRows() int { } func (m Model) analyticsMaxLineWidth() int { + fixedCols := analyticsColMarker + analyticsColCount + analyticsColAvg + + analyticsColP95 + analyticsColMax + analyticsColTotal + 6 maxW := 0 for _, r := range m.analyticsRows { - w := analyticsColMarker + analyticsColCount + analyticsColAvg + analyticsColTotal + 4 + len([]rune(r.query)) + w := fixedCols + len([]rune(r.query)) if w > maxW { maxW = w } @@ -195,12 +224,16 @@ func (m Model) renderAnalytics() string { title := fmt.Sprintf(" Analytics (%d templates) [sort: %s] ", len(m.analyticsRows), m.analyticsSortMode) - // 4 = separator spaces: count" "avg" "total" "query - colQuery := max(innerWidth-analyticsColMarker-analyticsColCount-analyticsColAvg-analyticsColTotal-4, 10) + // 6 = separator spaces between columns + fixedWidth := analyticsColMarker + analyticsColCount + analyticsColAvg + + analyticsColP95 + analyticsColMax + analyticsColTotal + 6 + colQuery := max(innerWidth-fixedWidth, 10) - header := fmt.Sprintf(" %*s %*s %*s %s", + header := fmt.Sprintf(" %*s %*s %*s %*s %*s %s", analyticsColCount, "Count", analyticsColAvg, "Avg", + analyticsColP95, "P95", + analyticsColMax, "Max", analyticsColTotal, "Total", "Query", ) @@ -238,10 +271,12 @@ func (m Model) renderAnalytics() string { q = string([]rune(q)[:colQuery-1]) + "…" } - row := fmt.Sprintf("%s%*d %*s %*s %s", + row := fmt.Sprintf("%s%*d %*s %*s %*s %*s %s", marker, analyticsColCount, r.count, analyticsColAvg, formatDurationValue(r.avgDuration), + analyticsColP95, formatDurationValue(r.p95Duration), + analyticsColMax, formatDurationValue(r.maxDuration), analyticsColTotal, formatDurationValue(r.totalDuration), q, ) diff --git a/tui/export.go b/tui/export.go index e7c8041..be90f7b 100644 --- a/tui/export.go +++ b/tui/export.go @@ -1,10 +1,12 @@ package tui import ( + "cmp" "encoding/json" "fmt" "os" "path/filepath" + "slices" "strings" "time" @@ -31,6 +33,8 @@ type exportAnalyticsRow struct { Count int `json:"count"` TotalMs float64 `json:"total_ms"` AvgMs float64 `json:"avg_ms"` + P95Ms float64 `json:"p95_ms"` + MaxMs float64 `json:"max_ms"` } type exportQuery struct { @@ -74,8 +78,9 @@ func filteredEvents( // buildExportAnalytics aggregates query metrics from the given events. func buildExportAnalytics(events []*tapv1.QueryEvent) []exportAnalyticsRow { type agg struct { - count int - totalDur time.Duration + count int + totalDur time.Duration + durations []time.Duration } groups := make(map[string]*agg) var order []string @@ -87,30 +92,37 @@ func buildExportAnalytics(events []*tapv1.QueryEvent) []exportAnalyticsRow { continue case proxy.OpQuery, proxy.OpExec, proxy.OpExecute: } - q := ev.GetQuery() - if q == "" { + nq := ev.GetNormalizedQuery() + if nq == "" { continue } - g, ok := groups[q] + dur := ev.GetDuration().AsDuration() + g, ok := groups[nq] if !ok { g = &agg{} - groups[q] = g - order = append(order, q) + groups[nq] = g + order = append(order, nq) } g.count++ - g.totalDur += ev.GetDuration().AsDuration() + g.totalDur += dur + g.durations = append(g.durations, dur) } rows := make([]exportAnalyticsRow, 0, len(groups)) for _, q := range order { g := groups[q] + slices.SortFunc(g.durations, cmp.Compare) totalMs := float64(g.totalDur.Microseconds()) / 1000 avgMs := totalMs / float64(g.count) + p95Ms := float64(percentile(g.durations, 0.95).Microseconds()) / 1000 + maxMs := float64(g.durations[len(g.durations)-1].Microseconds()) / 1000 rows = append(rows, exportAnalyticsRow{ Query: q, Count: g.count, TotalMs: totalMs, AvgMs: avgMs, + P95Ms: p95Ms, + MaxMs: maxMs, }) } return rows @@ -217,14 +229,16 @@ func renderMarkdown( if len(d.Analytics) > 0 { sb.WriteString("\n## Analytics\n\n") - sb.WriteString("| Query | Count | Total | Avg |\n") - sb.WriteString("|-------|-------|-------|-----|\n") + sb.WriteString("| Query | Count | Avg | P95 | Max | Total |\n") + sb.WriteString("|-------|-------|-----|-----|-----|-------|\n") for _, a := range d.Analytics { - fmt.Fprintf(&sb, "| %s | %d | %s | %s |\n", + fmt.Fprintf(&sb, "| %s | %d | %s | %s | %s | %s |\n", escapeMarkdownPipe(a.Query), a.Count, - formatDurationMs(a.TotalMs), formatDurationMs(a.AvgMs), + formatDurationMs(a.P95Ms), + formatDurationMs(a.MaxMs), + formatDurationMs(a.TotalMs), ) } } diff --git a/tui/export_test.go b/tui/export_test.go index 34cc745..fb45dba 100644 --- a/tui/export_test.go +++ b/tui/export_test.go @@ -15,14 +15,15 @@ import ( ) func makeExportEvent( - op proxy.Op, query string, args []string, + op proxy.Op, query, normalizedQuery string, args []string, dur time.Duration, startTime time.Time, ) *tapv1.QueryEvent { ev := &tapv1.QueryEvent{ - Op: int32(op), - Query: query, - Args: args, - StartTime: timestamppb.New(startTime), + Op: int32(op), + Query: query, + NormalizedQuery: normalizedQuery, + Args: args, + StartTime: timestamppb.New(startTime), } if dur > 0 { ev.Duration = durationpb.New(dur) @@ -34,14 +35,17 @@ func testEvents() []*tapv1.QueryEvent { base := time.Date(2026, 2, 20, 15, 4, 5, 123000000, time.UTC) return []*tapv1.QueryEvent{ makeExportEvent(proxy.OpQuery, + "SELECT id FROM users WHERE email = $1", "SELECT id FROM users WHERE email = $1", []string{"alice@example.com"}, 152300*time.Microsecond, base), makeExportEvent(proxy.OpQuery, + "SELECT id FROM users WHERE email = $1", "SELECT id FROM users WHERE email = $1", []string{"bob@example.com"}, 203100*time.Microsecond, base.Add(time.Second)), makeExportEvent(proxy.OpExec, + "INSERT INTO orders (user_id) VALUES ($1)", "INSERT INTO orders (user_id) VALUES ($1)", []string{"1"}, 50*time.Millisecond, base.Add(2*time.Second)), @@ -64,7 +68,7 @@ func TestRenderMarkdown(t *testing.T) { "['alice@example.com']", "INSERT INTO orders", "## Analytics", - "| Query | Count | Total | Avg |", + "| Query | Count | Avg | P95 | Max | Total |", } for _, want := range checks { @@ -138,7 +142,7 @@ func TestRenderJSONEmptyArgs(t *testing.T) { base := time.Date(2026, 2, 20, 15, 0, 0, 0, time.UTC) events := []*tapv1.QueryEvent{ - makeExportEvent(proxy.OpQuery, "SELECT 1", nil, + makeExportEvent(proxy.OpQuery, "SELECT 1", "SELECT ?", nil, 10*time.Millisecond, base), } diff --git a/web/static/app.js b/web/static/app.js index bd79b00..383dd0a 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -2,6 +2,10 @@ const events = []; let selectedIdx = -1; let filterText = ''; let autoScroll = true; +let viewMode = 'events'; +let statsSortKey = 'total'; +let statsSortAsc = false; +let selectedStatsQuery = null; // SQL syntax highlighting const SQL_KW = new Set([ @@ -100,15 +104,18 @@ function highlightSQL(sql) { const tbody = document.getElementById('tbody'); const tableWrap = document.getElementById('table-wrap'); +const statsWrap = document.getElementById('stats-wrap'); +const statsTbody = document.getElementById('stats-tbody'); const statsEl = document.getElementById('stats'); const statusEl = document.getElementById('status'); const filterEl = document.getElementById('filter'); const detailEl = document.getElementById('detail'); +const statsDetailEl = document.getElementById('stats-detail'); const explainOutput = document.getElementById('explain-output'); filterEl.addEventListener('input', () => { filterText = filterEl.value.toLowerCase(); - renderTable(); + render(); }); tableWrap.addEventListener('scroll', () => { @@ -116,6 +123,46 @@ tableWrap.addEventListener('scroll', () => { autoScroll = el.scrollTop + el.clientHeight >= el.scrollHeight - 20; }); +// Stats sort header clicks +document.querySelectorAll('#stats-wrap th.sortable').forEach(th => { + th.addEventListener('click', () => { + const key = th.dataset.sort; + if (statsSortKey === key) { + statsSortAsc = !statsSortAsc; + } else { + statsSortKey = key; + statsSortAsc = false; + } + document.querySelectorAll('#stats-wrap th.sortable').forEach(h => h.classList.remove('active')); + th.classList.add('active'); + renderStats(); + }); +}); + +function switchView(mode) { + viewMode = mode; + document.getElementById('tab-events').classList.toggle('active', mode === 'events'); + document.getElementById('tab-stats').classList.toggle('active', mode === 'stats'); + tableWrap.style.display = mode === 'events' ? '' : 'none'; + statsWrap.style.display = mode === 'stats' ? '' : 'none'; + if (mode === 'events') { + detailEl.className = selectedIdx >= 0 ? 'open' : ''; + statsDetailEl.className = ''; + } else { + detailEl.className = ''; + statsDetailEl.className = selectedStatsQuery ? 'open' : ''; + } + render(); +} + +function render() { + if (viewMode === 'events') { + renderTable(); + } else { + renderStats(); + } +} + function getFiltered() { if (!filterText) return events.map((ev, i) => ({ev, idx: i})); return events.reduce((acc, ev, i) => { @@ -171,6 +218,98 @@ function renderTable() { } } +// --- Stats view --- + +function buildStats() { + const groups = new Map(); + const skipOps = new Set(['Begin', 'Commit', 'Rollback', 'Bind', 'Prepare']); + for (const ev of events) { + if (skipOps.has(ev.op)) continue; + const nq = ev.normalized_query; + if (!nq) continue; + if (filterText && !nq.toLowerCase().includes(filterText)) continue; + let group = groups.get(nq); + if (!group) { + group = {query: nq, durations: []}; + groups.set(nq, group); + } + group.durations.push(ev.duration_ms); + } + const rows = []; + for (const g of groups.values()) { + const durs = g.durations.sort((a, b) => a - b); + const count = durs.length; + const total = durs.reduce((s, d) => s + d, 0); + const avg = total / count; + const p95 = durs[Math.floor((count - 1) * 0.95)]; + const mx = durs[count - 1]; + rows.push({query: g.query, count, avg, p95, max: mx, total}); + } + return rows; +} + +function sortStats(rows) { + const dir = statsSortAsc ? 1 : -1; + rows.sort((a, b) => { + const va = a[statsSortKey]; + const vb = b[statsSortKey]; + if (va < vb) return -1 * dir; + if (va > vb) return 1 * dir; + return 0; + }); +} + +function renderStats() { + const rows = buildStats(); + sortStats(rows); + statsEl.textContent = `${rows.length} templates`; + + const fragment = document.createDocumentFragment(); + for (const r of rows) { + const tr = document.createElement('tr'); + tr.className = 'row' + (selectedStatsQuery === r.query ? ' selected' : ''); + tr.onclick = () => selectStatsRow(r); + tr.innerHTML = + `${r.count}` + + `${fmtDur(r.avg)}` + + `${fmtDur(r.p95)}` + + `${fmtDur(r.max)}` + + `${fmtDur(r.total)}` + + `${highlightSQL(r.query)}`; + fragment.appendChild(tr); + } + statsTbody.replaceChildren(fragment); +} + +function selectStatsRow(r) { + if (selectedStatsQuery === r.query) { + selectedStatsQuery = null; + statsDetailEl.className = ''; + renderStats(); + return; + } + selectedStatsQuery = r.query; + + document.getElementById('sd-metrics').innerHTML = + `Count:${r.count}` + + `Avg:${fmtDur(r.avg)}` + + `P95:${fmtDur(r.p95)}` + + `Max:${fmtDur(r.max)}` + + `Total:${fmtDur(r.total)}`; + document.getElementById('sd-query').innerHTML = highlightSQL(r.query); + statsDetailEl.className = 'open'; + renderStats(); +} + +function copyStatsQuery() { + if (!selectedStatsQuery) return; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(selectedStatsQuery).then(() => showToast('Copied!')).catch(() => fallbackCopy(selectedStatsQuery)); + } else { + fallbackCopy(selectedStatsQuery); + } +} + function selectRow(idx) { if (selectedIdx === idx) { selectedIdx = -1; @@ -315,7 +454,7 @@ function connectSSE() { if (ev.n_plus_1) { showToast('N+1 detected: ' + (ev.query || '').substring(0, 80)); } - renderTable(); + render(); }; es.onerror = () => { statusEl.textContent = 'disconnected'; diff --git a/web/static/index.html b/web/static/index.html index e95f30a..f6f3d22 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -11,6 +11,10 @@

sql-tap

0 queries +
+ + +
connecting...
@@ -28,6 +32,21 @@

sql-tap

+
Op:
@@ -50,6 +69,16 @@

sql-tap

+
+
+
+
Query:
+
+
+ +
+
+
diff --git a/web/static/style.css b/web/static/style.css index 0a38687..d5f6450 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -28,6 +28,21 @@ header .status { margin-left: auto; font-size: 12px; } header .status.connected { color: #4ec9b0; } header .status.disconnected { color: #f44747; } +.tab-group { display: flex; gap: 2px; } +.tab-btn { + background: #3c3c3c; + border: 1px solid #555; + color: #888; + padding: 3px 10px; + font-family: inherit; + font-size: 12px; + cursor: pointer; +} +.tab-btn:first-child { border-radius: 3px 0 0 3px; } +.tab-btn:last-child { border-radius: 0 3px 3px 0; } +.tab-btn.active { background: #007acc; color: #fff; border-color: #007acc; } +.tab-btn:hover:not(.active) { background: #505050; } + #filter { background: #3c3c3c; border: 1px solid #555; @@ -42,6 +57,7 @@ header .status.disconnected { color: #f44747; } #filter:focus { outline: none; border-color: #007acc; } #table-wrap { flex: 1; overflow-y: auto; min-height: 0; } +#stats-wrap { flex: 1; overflow-y: auto; min-height: 0; } table { width: 100%; border-collapse: collapse; table-layout: fixed; } @@ -57,6 +73,10 @@ th { white-space: nowrap; } +th.sortable { cursor: pointer; } +th.sortable:hover { color: #d4d4d4; } +th.sortable.active { color: #4ec9b0; } + td { padding: 4px 8px; border-bottom: 1px solid #2a2a2a; @@ -77,6 +97,10 @@ tr.row.n-plus-1 td { color: #e5c07b; } .col-err { width: 60px; } .col-query { overflow: hidden; text-overflow: ellipsis; } +.stats-col-count { width: 70px; text-align: right; } +.stats-col-dur { width: 80px; text-align: right; } +.stats-col-query { overflow: hidden; text-overflow: ellipsis; } + #detail { border-top: 1px solid #3c3c3c; background: #252526; @@ -88,6 +112,17 @@ tr.row.n-plus-1 td { color: #e5c07b; } #detail.open { display: block; } #detail-content { padding: 12px 16px; } +#stats-detail { + border-top: 1px solid #3c3c3c; + background: #252526; + max-height: 45vh; + overflow-y: auto; + display: none; +} + +#stats-detail.open { display: block; } +#stats-detail-content { padding: 12px 16px; } + .detail-row { margin-bottom: 4px; } .detail-label { color: #888; display: inline-block; width: 80px; } .detail-value { color: #d4d4d4; } diff --git a/web/web.go b/web/web.go index d87a635..a46acdb 100644 --- a/web/web.go +++ b/web/web.go @@ -69,32 +69,34 @@ func (s *Server) Handler() http.Handler { } type eventJSON struct { - ID string `json:"id"` - Op string `json:"op"` - Query string `json:"query"` - Args []string `json:"args"` - StartTime string `json:"start_time"` - DurationMs float64 `json:"duration_ms"` - RowsAffected int64 `json:"rows_affected"` - Error string `json:"error,omitempty"` - TxID string `json:"tx_id,omitempty"` - NPlus1 bool `json:"n_plus_1,omitempty"` + ID string `json:"id"` + Op string `json:"op"` + Query string `json:"query"` + Args []string `json:"args"` + StartTime string `json:"start_time"` + DurationMs float64 `json:"duration_ms"` + RowsAffected int64 `json:"rows_affected"` + Error string `json:"error,omitempty"` + TxID string `json:"tx_id,omitempty"` + NPlus1 bool `json:"n_plus_1,omitempty"` + NormalizedQuery string `json:"normalized_query,omitempty"` } func eventToJSON(ev proxy.Event) eventJSON { args := make([]string, len(ev.Args)) copy(args, ev.Args) return eventJSON{ - ID: ev.ID, - Op: ev.Op.String(), - Query: ev.Query, - Args: args, - StartTime: ev.StartTime.Format(time.RFC3339Nano), - DurationMs: float64(ev.Duration.Microseconds()) / 1000, - RowsAffected: ev.RowsAffected, - Error: ev.Error, - TxID: ev.TxID, - NPlus1: ev.NPlus1, + ID: ev.ID, + Op: ev.Op.String(), + Query: ev.Query, + Args: args, + StartTime: ev.StartTime.Format(time.RFC3339Nano), + DurationMs: float64(ev.Duration.Microseconds()) / 1000, + RowsAffected: ev.RowsAffected, + Error: ev.Error, + TxID: ev.TxID, + NPlus1: ev.NPlus1, + NormalizedQuery: ev.NormalizedQuery, } }