From d54389fbcfe7ffd67e15f5a49951ae893239b216 Mon Sep 17 00:00:00 2001 From: Jacek Migdal Date: Mon, 6 May 2024 13:40:42 +0200 Subject: [PATCH] UI refactor (#39) This is a giant change. Mostly move and rename with minor delete of unused code. Principle: - replace `html_pages` and `html_panels` with filer per each tab - reduce the amount of exposed public API using `internal` and private name - keep every non-test file below 300 lines Long term goals: - Make UI configurable (e.g. port, disable debug, limit amount of messages) - Be able to refactor so one UI can attach to multiple quesma consoles - Make modular UI, so it's easy to add new tabs, even for occasional use case More changes will follow (e.g. more packages, configurability) --- quesma/quesma/ui/console_routes.go | 57 +- quesma/quesma/ui/dashboard.go | 228 +++++ quesma/quesma/ui/dashboard_drilldown.go | 32 + quesma/quesma/ui/data_sources.go | 80 ++ quesma/quesma/ui/html_pages.go | 933 ------------------ quesma/quesma/ui/html_panels.go | 418 -------- quesma/quesma/ui/html_utils.go | 95 ++ quesma/quesma/ui/ingest.go | 89 ++ .../ui/{ => internal/buffer}/html_buffer.go | 2 +- .../{ => internal/buffer}/html_buffer_test.go | 2 +- .../quesma/ui/{ => internal}/sqlfmt/pretty.go | 0 .../ui/{ => internal}/sqlfmt/pretty_test.go | 0 quesma/quesma/ui/live_tail.go | 284 ++++++ quesma/quesma/ui/live_tail_drilldown.go | 275 ++++++ quesma/quesma/ui/management_console.go | 150 +-- quesma/quesma/ui/routing.go | 77 ++ quesma/quesma/ui/schema.go | 275 ++++++ quesma/quesma/ui/telemetry.go | 36 + quesma/quesma/ui/unsupported_queries.go | 51 +- 19 files changed, 1551 insertions(+), 1533 deletions(-) create mode 100644 quesma/quesma/ui/dashboard.go create mode 100644 quesma/quesma/ui/dashboard_drilldown.go create mode 100644 quesma/quesma/ui/data_sources.go delete mode 100644 quesma/quesma/ui/html_pages.go delete mode 100644 quesma/quesma/ui/html_panels.go create mode 100644 quesma/quesma/ui/html_utils.go create mode 100644 quesma/quesma/ui/ingest.go rename quesma/quesma/ui/{ => internal/buffer}/html_buffer.go (97%) rename quesma/quesma/ui/{ => internal/buffer}/html_buffer_test.go (96%) rename quesma/quesma/ui/{ => internal}/sqlfmt/pretty.go (100%) rename quesma/quesma/ui/{ => internal}/sqlfmt/pretty_test.go (100%) create mode 100644 quesma/quesma/ui/live_tail.go create mode 100644 quesma/quesma/ui/live_tail_drilldown.go create mode 100644 quesma/quesma/ui/routing.go create mode 100644 quesma/quesma/ui/schema.go create mode 100644 quesma/quesma/ui/telemetry.go diff --git a/quesma/quesma/ui/console_routes.go b/quesma/quesma/ui/console_routes.go index 06fa6cba3..6a5d0ace4 100644 --- a/quesma/quesma/ui/console_routes.go +++ b/quesma/quesma/ui/console_routes.go @@ -3,16 +3,18 @@ package ui import ( "embed" "encoding/json" + "errors" "github.com/gorilla/mux" "mitmproxy/quesma/logger" "mitmproxy/quesma/stats" "net/http" + "runtime" ) const ( + uiTcpPort = "9999" managementInternalPath = "/_quesma" healthPath = managementInternalPath + "/health" - bypassPath = managementInternalPath + "/bypass" ) //go:embed asset/* @@ -25,8 +27,6 @@ func (qmc *QuesmaManagementConsole) createRouting() *mux.Router { router.HandleFunc(healthPath, qmc.checkHealth) - router.HandleFunc(bypassPath, bypassSwitch).Methods("POST") - router.PathPrefix("/debug/pprof/").Handler(http.DefaultServeMux) router.HandleFunc("/", func(writer http.ResponseWriter, req *http.Request) { @@ -56,7 +56,7 @@ func (qmc *QuesmaManagementConsole) createRouting() *mux.Router { }) router.HandleFunc("/telemetry", func(writer http.ResponseWriter, req *http.Request) { - buf := qmc.generatePhoneHome() + buf := qmc.generateTelemetry() _, _ = writer.Write(buf) }) @@ -71,7 +71,7 @@ func (qmc *QuesmaManagementConsole) createRouting() *mux.Router { }) router.HandleFunc("/ingest-statistics", func(writer http.ResponseWriter, req *http.Request) { - buf := qmc.generateStatisticsLiveTail() + buf := qmc.generateIngestStatistics() _, _ = writer.Write(buf) }) @@ -160,3 +160,50 @@ func (qmc *QuesmaManagementConsole) createRouting() *mux.Router { router.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.FS(uiFs)))) return router } + +func (qmc *QuesmaManagementConsole) newHTTPServer() *http.Server { + return &http.Server{ + Addr: ":" + uiTcpPort, + Handler: qmc.createRouting(), + } +} + +func panicRecovery(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := recover(); err != nil { + buf := make([]byte, 2048) + n := runtime.Stack(buf, false) + buf = buf[:n] + + w.WriteHeader(http.StatusInternalServerError) + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte("Internal Server Error\n\n")) + + w.Write([]byte("Stack:\n")) + w.Write(buf) + logger.Error().Msgf("recovering from err %v\n %s", err, buf) + } + }() + + h.ServeHTTP(w, r) + }) +} + +func (qmc *QuesmaManagementConsole) checkHealth(writer http.ResponseWriter, _ *http.Request) { + health := qmc.checkElasticsearch() + if health.status != "red" { + writer.WriteHeader(200) + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"cluster_name": "quesma"}`)) + } else { + writer.WriteHeader(503) + _, _ = writer.Write([]byte(`Elastic search is unavailable: ` + health.message)) + } +} + +func (qmc *QuesmaManagementConsole) listenAndServe() { + if err := qmc.ui.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Fatal().Msgf("Error starting server: %v", err) + } +} diff --git a/quesma/quesma/ui/dashboard.go b/quesma/quesma/ui/dashboard.go new file mode 100644 index 000000000..5b7d1d66a --- /dev/null +++ b/quesma/quesma/ui/dashboard.go @@ -0,0 +1,228 @@ +package ui + +import ( + "fmt" + "github.com/shirou/gopsutil/v3/cpu" + "github.com/shirou/gopsutil/v3/host" + "github.com/shirou/gopsutil/v3/mem" + "mitmproxy/quesma/buildinfo" + "mitmproxy/quesma/quesma/ui/internal/buffer" + "mitmproxy/quesma/stats/errorstats" + "net/url" + "runtime" + "strings" + "time" +) + +func (qmc *QuesmaManagementConsole) generateDashboard() []byte { + buffer := newBufferWithHead() + buffer.Write(generateTopNavigation("dashboard")) + + buffer.Html(`
` + "\n") + + // Unfortunately, we need tiny bit of javascript to pause the animation. + buffer.Html(`` + "\n") + + buffer.Html(`
`) + buffer.Html(`` + "\n") + // One limitation is that, we don't update color of paths after initial draw. + // They rarely change, so it's not a big deal for now. + // Clickhouse -> Kibana + if qmc.config.ReadsFromClickhouse() { + status, _ := qmc.generateDashboardTrafficText(RequestStatisticKibana2Clickhouse) + buffer.Html(fmt.Sprintf(``, status)) + } + // Elasticsearch -> Kibana + if qmc.config.ReadsFromElasticsearch() { + status, _ := qmc.generateDashboardTrafficText(RequestStatisticKibana2Elasticsearch) + buffer.Html(fmt.Sprintf(``, status)) + } + + // Ingest -> Clickhouse + if qmc.config.WritesToClickhouse() { + status, _ := qmc.generateDashboardTrafficText(RequestStatisticIngest2Clickhouse) + buffer.Html(fmt.Sprintf(``, status)) + } + // Ingest -> Elasticsearch + if qmc.config.WritesToElasticsearch() { + status, _ := qmc.generateDashboardTrafficText(RequestStatisticIngest2Elasticsearch) + buffer.Html(fmt.Sprintf(``, status)) + } + buffer.Html(`` + "\n") + buffer.Write(qmc.generateDashboardTrafficPanel()) + buffer.Html(`
` + "\n") + + buffer.Html(`
` + "\n") + buffer.Write(qmc.generateDashboardPanel()) + buffer.Html("
\n") + buffer.Html("\n
\n\n") + + buffer.Html("\n") + buffer.Html("\n") + return buffer.Bytes() +} + +func (qmc *QuesmaManagementConsole) generateDashboardTrafficText(typeName string) (string, string) { + reqStats := qmc.requestsStore.GetRequestsStats(typeName) + status := "green" + if reqStats.ErrorRate > 0.20 { + status = "red" + } + return status, fmt.Sprintf("%4.1f req/s, err:%5.1f%%, p99:%3dms", + reqStats.RatePerMinute/60, reqStats.ErrorRate*100, reqStats.Duration99Percentile) +} + +func (qmc *QuesmaManagementConsole) generateDashboardTrafficElement(typeName string, y int) string { + status, text := qmc.generateDashboardTrafficText(typeName) + return fmt.Sprintf( + `
%s
`, + y, typeName, status, text) +} + +func (qmc *QuesmaManagementConsole) generateDashboardTrafficPanel() []byte { + var buffer buffer.HtmlBuffer + + // Clickhouse -> Kibana + if qmc.config.ReadsFromClickhouse() { + buffer.Html(qmc.generateDashboardTrafficElement(RequestStatisticKibana2Clickhouse, 21)) + } + + // Elasticsearch -> Kibana + if qmc.config.ReadsFromElasticsearch() { + buffer.Html(qmc.generateDashboardTrafficElement(RequestStatisticKibana2Elasticsearch, 66)) + } + + // Ingest -> Clickhouse + if qmc.config.WritesToClickhouse() { + buffer.Html(qmc.generateDashboardTrafficElement(RequestStatisticIngest2Clickhouse, 31)) + } + + // Ingest -> Elasticsearch + if qmc.config.WritesToElasticsearch() { + buffer.Html(qmc.generateDashboardTrafficElement(RequestStatisticIngest2Elasticsearch, 76)) + } + + return buffer.Bytes() +} + +func secondsToTerseString(second uint64) string { + return (time.Duration(second) * time.Second).String() +} + +func statusToDiv(s healthCheckStatus) string { + return fmt.Sprintf(`
%s
`, s.status, s.tooltip, s.message) +} + +func (qmc *QuesmaManagementConsole) generateDashboardPanel() []byte { + var buffer buffer.HtmlBuffer + + dashboardName := "

Kibana

" + storeName := "

Elasticsearch

" + if qmc.config.Elasticsearch.Url != nil && strings.Contains(qmc.config.Elasticsearch.Url.String(), "opensearch") { + dashboardName = "

OpenSearch

Dashboards

" + storeName = "

OpenSearch

" + } + + clickhouseName := "

ClickHouse

" + if qmc.config.Hydrolix.Url != nil { + clickhouseName = "

Hydrolix

" + } + + buffer.Html(`
`) + if qmc.config.Elasticsearch.AdminUrl != nil { + buffer.Html(fmt.Sprintf(``, qmc.config.Elasticsearch.AdminUrl.String())) + } + buffer.Html(dashboardName) + if qmc.config.Elasticsearch.AdminUrl != nil { + buffer.Html(``) + } + buffer.Html(statusToDiv(qmc.checkKibana())) + buffer.Html(`
`) + + buffer.Html(`
`) + buffer.Html(`

Ingest

`) + buffer.Html(statusToDiv(qmc.checkIngest())) + buffer.Html(`
`) + + buffer.Html(`
`) + buffer.Html(storeName) + buffer.Html(statusToDiv(qmc.checkElasticsearch())) + buffer.Html(`
`) + + buffer.Html(`
`) + if qmc.config.ClickHouse.AdminUrl != nil { + buffer.Html(fmt.Sprintf(``, qmc.config.ClickHouse.AdminUrl.String())) + } + buffer.Html(clickhouseName) + if qmc.config.ClickHouse.AdminUrl != nil { + buffer.Html(``) + } + buffer.Html(statusToDiv(qmc.checkClickhouseHealth())) + buffer.Html(`
`) + + buffer.Html(`
`) + + buffer.Html(`
`) + buffer.Html(`

Quesma

`) + + cpuStr := "" + c0, err0 := cpu.Percent(0, false) + + if err0 == nil { + cpuStr = fmt.Sprintf("Host CPU: %.1f%%", c0[0]) + } else { + cpuStr = fmt.Sprintf("Host CPU: N/A (error: %s)", err0.Error()) + } + + buffer.Html(fmt.Sprintf(`
%s
`, cpuStr)) + + var m runtime.MemStats + runtime.ReadMemStats(&m) + memStr := fmt.Sprintf("Memory used: %1.f MB", float64(m.Alloc)/1024.0/1024.0) + if v, errV := mem.VirtualMemory(); errV == nil { + total := float64(v.Total) / 1024.0 / 1024.0 / 1024.0 + memStr += fmt.Sprintf(", avail: %.1f GB", total) + } + buffer.Html(fmt.Sprintf(`
%s
`, memStr)) + + duration := uint64(time.Since(qmc.startedAt).Seconds()) + + buffer.Html(fmt.Sprintf(`
Started: %s ago
`, secondsToTerseString(duration))) + buffer.Html(fmt.Sprintf(`
Mode: %s
`, qmc.config.Mode.String())) + + if h, errH := host.Info(); errH == nil { + buffer.Html(fmt.Sprintf(`
Host uptime: %s
`, secondsToTerseString(h.Uptime))) + } + + buffer.Html("
Version: ") + buffer.Text(buildinfo.Version) + buffer.Html("
") + + buffer.Html(`
`) + + buffer.Html(`
`) + errors := errorstats.GlobalErrorStatistics.ReturnTopErrors(5) + if len(errors) > 0 { + buffer.Html(`

Top errors:

`) + for _, e := range errors { + buffer.Html(fmt.Sprintf(`
%d: %s
`, + e.Count, url.PathEscape(e.Reason), e.Reason)) + } + } else { + buffer.Html(`

No errors

`) + } + buffer.Html(`
`) + buffer.Html(`
`) + + return buffer.Bytes() +} diff --git a/quesma/quesma/ui/dashboard_drilldown.go b/quesma/quesma/ui/dashboard_drilldown.go new file mode 100644 index 000000000..a180d3e5f --- /dev/null +++ b/quesma/quesma/ui/dashboard_drilldown.go @@ -0,0 +1,32 @@ +package ui + +import ( + "fmt" + "mitmproxy/quesma/stats/errorstats" +) + +func (qmc *QuesmaManagementConsole) generateErrorForReason(reason string) []byte { + buffer := newBufferWithHead() + buffer.Write(generateTopNavigation(fmt.Sprintf("Errors with reason '%s'", reason))) + + buffer.Html(`
`) + errors := errorstats.GlobalErrorStatistics.ErrorReportsForReason(reason) + // TODO: Make it nicer + for _, errorReport := range errors { + buffer.Html("

").Text(errorReport.ReportedAt.String() + " " + errorReport.DebugMessage).Html("

\n") + } + buffer.Html("\n
\n\n") + + buffer.Html(`") + + buffer.Html("\n") + buffer.Html("\n") + + return buffer.Bytes() +} diff --git a/quesma/quesma/ui/data_sources.go b/quesma/quesma/ui/data_sources.go new file mode 100644 index 000000000..fd80798b2 --- /dev/null +++ b/quesma/quesma/ui/data_sources.go @@ -0,0 +1,80 @@ +package ui + +import ( + "fmt" + "slices" + "strings" +) + +func (qmc *QuesmaManagementConsole) generateDatasources() []byte { + buffer := newBufferWithHead() + buffer.Write(generateTopNavigation("datasources")) + + buffer.Html(`
`) + buffer.Html(`

Data sources

`) + + buffer.Html(`

Clickhouse

`) + + buffer.Html(``) + + buffer.Html(`

Elasticsearch

`) + + buffer.Html(``) + + buffer.Html("\n
\n\n") + + buffer.Html(`") + + buffer.Html("\n") + buffer.Html("\n") + return buffer.Bytes() +} diff --git a/quesma/quesma/ui/html_pages.go b/quesma/quesma/ui/html_pages.go deleted file mode 100644 index 93c891ffe..000000000 --- a/quesma/quesma/ui/html_pages.go +++ /dev/null @@ -1,933 +0,0 @@ -package ui - -import ( - "encoding/json" - "fmt" - "gopkg.in/yaml.v3" - "mitmproxy/quesma/buildinfo" - "mitmproxy/quesma/clickhouse" - "mitmproxy/quesma/logger" - "mitmproxy/quesma/stats/errorstats" - "mitmproxy/quesma/util" - "net/url" - "slices" - "sort" - "strings" -) - -func generateSimpleTop(title string) []byte { - var buffer HtmlBuffer - buffer.Html(`
` + "\n") - buffer.Html(`
` + "\n") - buffer.Html(`` + "\n") - buffer.Html(`

`).Text(title).Html(`

`) - buffer.Html("\n
\n
\n\n") - return buffer.Bytes() -} - -func generateTopNavigation(target string) []byte { - var buffer HtmlBuffer - buffer.Html(`
` + "\n") - buffer.Html(`
` + "\n") - buffer.Html(`` + "\n") - buffer.Html("\n") - buffer.Html("\n
\n") - - if target != "schema" && target != "telemetry" { - buffer.Html(`
` + "\n") - buffer.Html(`
`) - buffer.Html(fmt.Sprintf( - ``, - url.PathEscape(target), url.PathEscape(target))) - buffer.Html(``) - buffer.Html("\n
") - buffer.Html("\n
\n") - } - buffer.Html("\n
\n\n") - return buffer.Bytes() -} - -func (qmc *QuesmaManagementConsole) generateSchema() []byte { - type menuEntry struct { - label string - target string - } - - var menuEntries []menuEntry - - type tableColumn struct { - name string - typeName string - isAttribute bool - isFullTextSearch bool - warning *string - } - - buffer := newBufferWithHead() - buffer.Write(generateTopNavigation("schema")) - buffer.Html(`
`) - - if qmc.logManager != nil { - - // Not sure if we should read directly from the TableMap or we should use the Snapshot of it. - // Let's leave it as is for now. - schema := qmc.logManager.GetTableDefinitions() - - tableNames := schema.Keys() - sort.Strings(tableNames) - - buffer.Html("\n") - - for i, tableName := range tableNames { - table, ok := schema.Load(tableName) - if !ok { - continue - } - - id := fmt.Sprintf("schema-table-%d", i) - var menu menuEntry - menu.label = table.Name - menu.target = fmt.Sprintf("#%s", id) - menuEntries = append(menuEntries, menu) - - buffer.Html(``) - buffer.Html(``) - buffer.Html(``) - - buffer.Html(``) - buffer.Html(``) - buffer.Html(``) - buffer.Html(``) - - var columnNames []string - var columnMap = make(map[string]tableColumn) - - // standard columns, visible for the user - for k := range table.Cols { - c := tableColumn{} - - c.name = k - if table.Cols[k].Type != nil { - c.typeName = table.Cols[k].Type.StringWithNullable() - } else { - c.typeName = "n/a" - } - - c.isAttribute = false - c.isFullTextSearch = table.Cols[k].IsFullTextMatch - - columnNames = append(columnNames, k) - columnMap[k] = c - } - - for _, a := range qmc.config.AliasFields(table.Name) { - - // check for collisions - if field, collide := columnMap[a.SourceFieldName]; collide { - field.warning = util.Pointer("alias declared with the same name") - columnMap[a.SourceFieldName] = field - continue - } - - // check if target exists - c := tableColumn{} - c.name = a.SourceFieldName - if aliasedField, ok := columnMap[a.TargetFieldName]; ok { - c.typeName = fmt.Sprintf("alias of '%s', %s", a.TargetFieldName, aliasedField.typeName) - c.isFullTextSearch = aliasedField.isFullTextSearch - c.isAttribute = aliasedField.isAttribute - } else { - c.warning = util.Pointer("alias points to non-existing field '" + a.TargetFieldName + "'") - c.typeName = "dangling alias" - } - - columnNames = append(columnNames, a.SourceFieldName) - columnMap[a.SourceFieldName] = c - } - - // columns added by Quesma, not visible for the user - // - // this part is based on addOurFieldsToCreateTableQuery in log_manager.go - attributes := table.Config.GetAttributes() - if len(attributes) > 0 { - for _, a := range attributes { - _, ok := table.Cols[a.KeysArrayName] - if !ok { - c := tableColumn{} - c.name = a.KeysArrayName - c.typeName = clickhouse.CompoundType{Name: "Array", BaseType: clickhouse.NewBaseType("String")}.StringWithNullable() - c.isAttribute = true - columnNames = append(columnNames, c.name) - columnMap[c.name] = c - } - _, ok = table.Cols[a.ValuesArrayName] - if !ok { - c := tableColumn{} - c.name = a.ValuesArrayName - c.typeName = clickhouse.CompoundType{Name: "Array", BaseType: a.Type}.StringWithNullable() - c.isAttribute = true - columnNames = append(columnNames, c.name) - columnMap[c.name] = c - } - } - } - - sort.Strings(columnNames) - - for _, columnName := range columnNames { - column, ok := columnMap[columnName] - if !ok { - continue - } - - buffer.Html(``) - buffer.Html(``) - buffer.Html(``) - buffer.Html(``) - } - - } - - buffer.Html("\n

`) - buffer.Html(`Table: `) - buffer.Text(table.Name) - - if table.Comment != "" { - buffer.Text(" (") - buffer.Text(table.Comment) - buffer.Text(")") - } - - buffer.Html(`

`) - buffer.Html(`Name`) - buffer.Html(``) - buffer.Html(`Type`) - buffer.Html(`
`) - - buffer.Text(column.name) - buffer.Html(``) - - buffer.Text(column.typeName) - if column.isFullTextSearch { - buffer.Html(` (Full text match)`) - } - - if column.warning != nil { - buffer.Html(` WARNING: `) - buffer.Text(*column.warning) - buffer.Html(``) - } - - buffer.Html(`
") - - } else { - buffer.Html(`

Schema is not available

`) - } - - buffer.Html("\n") - buffer.Html(``) - buffer.Html(``) - buffer.Html(``) - - buffer.Html(``) - buffer.Html(``) - buffer.Html(``) - buffer.Html(``) - - buffer.Html(``) - - for _, cfg := range qmc.config.IndexConfig { - buffer.Html(``) - buffer.Html(``) - buffer.Html(``) - - buffer.Html(``) - - buffer.Html(``) - } - - buffer.Html("\n

`) - buffer.Html(`Quesma Config`) - buffer.Html(`

`) - buffer.Html(`Name Pattern`) - buffer.Html(``) - buffer.Html(`Enabled?`) - buffer.Html(``) - buffer.Html(`Full Text Search Fields`) - buffer.Html(`
`) - buffer.Text(cfg.Name) - buffer.Html(``) - if cfg.Enabled { - buffer.Text("true") - } else { - buffer.Text("false") - } - buffer.Html(``) - buffer.Text(strings.Join(cfg.FullTextFields, ", ")) - buffer.Html(`
") - - buffer.Html("\n
\n\n") - - buffer.Html(`") - - buffer.Html("\n") - buffer.Html("\n") - return buffer.Bytes() -} - -func (qmc *QuesmaManagementConsole) generatePhoneHome() []byte { - - buffer := newBufferWithHead() - buffer.Write(generateTopNavigation("telemetry")) - buffer.Html(`
`) - - buffer.Html(`

Telemetry

`) - buffer.Html("
")
-
-	stats, available := qmc.phoneHomeAgent.RecentStats()
-	if available {
-		asBytes, err := json.MarshalIndent(stats, "", "  ")
-
-		if err != nil {
-			logger.Error().Err(err).Msg("Error marshalling phone home stats")
-			buffer.Html("Telemetry Stats are unable to be displayed. This is a bug.")
-		} else {
-			buffer.Html(string(asBytes))
-		}
-
-	} else {
-		buffer.Html("Telemetry Stats are not available yet.")
-	}
-
-	buffer.Html("
") - - buffer.Html("\n") - buffer.Html("\n") - return buffer.Bytes() -} - -func (qmc *QuesmaManagementConsole) generateDatasources() []byte { - buffer := newBufferWithHead() - buffer.Write(generateTopNavigation("datasources")) - - buffer.Html(`
`) - buffer.Html(`

Data sources

`) - - buffer.Html(`

Clickhouse

`) - - buffer.Html(`
    `) - - tableNames := []string{} - for tableName := range qmc.config.IndexConfig { - tableNames = append(tableNames, tableName) - } - slices.Sort(tableNames) - tables := qmc.logManager.GetTableDefinitions() - slices.Sort(tableNames) - for _, tableName := range tableNames { - if _, exist := tables.Load(tableName); exist { - buffer.Html(fmt.Sprintf(`
  • %s (table exists)
  • `, tableName)) - } else { - buffer.Html(fmt.Sprintf(`
  • %s
  • `, tableName)) - } - } - buffer.Html(`
`) - - buffer.Html(`

Elasticsearch

`) - - buffer.Html(`
    `) - - qmc.indexManagement.Start() - indexNames := []string{} - internalIndexNames := []string{} - for indexName := range qmc.indexManagement.GetSourceNames() { - if strings.HasPrefix(indexName, ".") { - internalIndexNames = append(internalIndexNames, indexName) - } else { - indexNames = append(indexNames, indexName) - } - } - - slices.Sort(indexNames) - slices.Sort(internalIndexNames) - for _, indexName := range indexNames { - buffer.Html(fmt.Sprintf(`
  • %s
  • `, indexName)) - } - - if len(internalIndexNames) > 0 { - buffer.Html(`
      `) - - for _, indexName := range internalIndexNames { - buffer.Html(fmt.Sprintf(`
    • %s
    • `, indexName)) - } - buffer.Html(`
    `) - } - - buffer.Html(`
`) - - buffer.Html("\n
\n\n") - - buffer.Html(`") - - buffer.Html("\n") - buffer.Html("\n") - return buffer.Bytes() -} - -func (qmc *QuesmaManagementConsole) generateRouterStatisticsLiveTail() []byte { - buffer := newBufferWithHead() - buffer.Write(generateTopNavigation("routing-statistics")) - - buffer.Html(`
`) - buffer.Write(qmc.generateRouterStatistics()) - buffer.Html("\n
\n\n") - - buffer.Html(`") - - buffer.Html("\n") - buffer.Html("\n") - return buffer.Bytes() -} - -func (qmc *QuesmaManagementConsole) generateStatisticsLiveTail() []byte { - buffer := newBufferWithHead() - buffer.Write(generateTopNavigation("statistics")) - - buffer.Html(`
`) - buffer.Write(qmc.generateStatistics()) - buffer.Html("\n
\n\n") - - buffer.Html(`") - - buffer.Html("\n") - buffer.Html("\n") - return buffer.Bytes() -} - -func (qmc *QuesmaManagementConsole) generateLiveTail() []byte { - buffer := newBufferWithHead() - buffer.Write(generateTopNavigation("queries")) - - // This preserves scrolling, but does not work if new queries appear. - buffer.Html(``) - - buffer.Html(`
`) - buffer.Write(qmc.generateQueries()) - buffer.Html("\n
\n\n") - - buffer.Html(`") - buffer.Html("\n") - buffer.Html("\n") - return buffer.Bytes() -} - -func (qmc *QuesmaManagementConsole) generateDashboard() []byte { - buffer := newBufferWithHead() - buffer.Write(generateTopNavigation("dashboard")) - - buffer.Html(`
` + "\n") - - // Unfortunately, we need tiny bit of javascript to pause the animation. - buffer.Html(`` + "\n") - - buffer.Html(`
`) - buffer.Html(`` + "\n") - // One limitation is that, we don't update color of paths after initial draw. - // They rarely change, so it's not a big deal for now. - // Clickhouse -> Kibana - if qmc.config.ReadsFromClickhouse() { - status, _ := qmc.generateDashboardTrafficText(RequestStatisticKibana2Clickhouse) - buffer.Html(fmt.Sprintf(``, status)) - } - // Elasticsearch -> Kibana - if qmc.config.ReadsFromElasticsearch() { - status, _ := qmc.generateDashboardTrafficText(RequestStatisticKibana2Elasticsearch) - buffer.Html(fmt.Sprintf(``, status)) - } - - // Ingest -> Clickhouse - if qmc.config.WritesToClickhouse() { - status, _ := qmc.generateDashboardTrafficText(RequestStatisticIngest2Clickhouse) - buffer.Html(fmt.Sprintf(``, status)) - } - // Ingest -> Elasticsearch - if qmc.config.WritesToElasticsearch() { - status, _ := qmc.generateDashboardTrafficText(RequestStatisticIngest2Elasticsearch) - buffer.Html(fmt.Sprintf(``, status)) - } - buffer.Html(`` + "\n") - buffer.Write(qmc.generateDashboardTrafficPanel()) - buffer.Html(`
` + "\n") - - buffer.Html(`
` + "\n") - buffer.Write(qmc.generateDashboardPanel()) - buffer.Html("
\n") - buffer.Html("\n
\n\n") - - buffer.Html("\n") - buffer.Html("\n") - return buffer.Bytes() -} - -func (qmc *QuesmaManagementConsole) generateReportForRequestId(requestId string) []byte { - qmc.mutex.Lock() - request, requestFound := qmc.debugInfoMessages[requestId] - qmc.mutex.Unlock() - - buffer := newBufferWithHead() - if requestFound { - buffer.Write(generateSimpleTop("Report for request UUID " + requestId)) - } else { - buffer.Write(generateSimpleTop("Report not found for request UUID " + requestId)) - } - - buffer.Html(`
`) - - debugKeyValueSlice := []DebugKeyValue{} - if requestFound { - debugKeyValueSlice = append(debugKeyValueSlice, DebugKeyValue{requestId, request}) - } - - buffer.Write(generateQueries(debugKeyValueSlice, false)) - - buffer.Html("\n
\n") - buffer.Html(`") - buffer.Html("\n") - buffer.Html("\n") - return buffer.Bytes() -} - -func (qmc *QuesmaManagementConsole) generateLogForRequestId(requestId string) []byte { - qmc.mutex.Lock() - request, requestFound := qmc.debugInfoMessages[requestId] - qmc.mutex.Unlock() - - logMessages, optAsyncId := generateLogMessages(request.logMessages, []string{}) - - buffer := newBufferWithHead() - if requestFound { - if optAsyncId != nil { - buffer.Write(generateSimpleTop("Log for request id " + requestId + " and async id " + *optAsyncId)) - } else { - buffer.Write(generateSimpleTop("Log for request id " + requestId)) - } - } else { - buffer.Write(generateSimpleTop("Log not found for request id " + requestId)) - } - - buffer.Html(`
`) - buffer.Html("\n\n") - buffer.Html(`
`) - - buffer.Write(logMessages) - - buffer.Html("\n
\n") - buffer.Html("\n
\n") - buffer.Html(`") - buffer.Html("\n") - buffer.Html("\n") - return buffer.Bytes() -} - -// links might be empty, then table won't have any links within. -// if i < len(logMessages) && i < len(links) then logMessages[i] will have link links[i] -func generateLogMessages(logMessages []string, links []string) ([]byte, *string) { - // adds a link to the table row if there is a link for it - addOpeningLink := func(row, column int) string { - if row < len(links) { - link := `" - } - return "" - } - addClosingLink := func(i int) string { - if i < len(links) { - return "" - } - return "" - } - - var buffer HtmlBuffer - buffer.Html("\n") - buffer.Html("\n") - buffer.Html("\n") - buffer.Html(``) - buffer.Html(``) - buffer.Html(``) - buffer.Html(``) - buffer.Html("\n") - - buffer.Html("\n") - buffer.Html("\n") - - var asyncId *string - - for i, logMessage := range logMessages { - buffer.Html("\n") - - var fields map[string]interface{} - - if err := json.Unmarshal([]byte(logMessage), &fields); err != nil { - // error print - buffer.Html("").Text(err.Error()).Html("") - - // get rid of request_id and async_id - delete(fields, "request_id") - if id, ok := fields["async_id"].(string); ok { - asyncId = &id - delete(fields, "async_id") - } - - // level - buffer.Html(`") - delete(fields, "level") - } else { - buffer.Html("missing level") - } - buffer.Html(addClosingLink(i) + "") - - // message - buffer.Html(`") - - // fields - buffer.Html(`\n") - } - - buffer.Html("\n") - buffer.Html("
TimeLevelMessageFields
error") - continue - } - // time - buffer.Html(`` + addOpeningLink(i, 0)) - if _, ok := fields["time"]; ok { - time := fields["time"].(string) - time = strings.Replace(time, "T", " ", 1) - time = strings.Replace(time, ".", " ", 1) - buffer.Text(time) - delete(fields, "time") - } else { - buffer.Html("missing time") - } - buffer.Html(addClosingLink(i) + "` + addOpeningLink(i, 1)) - if level, ok := fields["level"].(string); ok { - if level == "error" { - buffer.Html(``) - } else if level == "warn" { - buffer.Html(``) - } else { - buffer.Html(``) - } - buffer.Text(level).Html("` + addOpeningLink(i, 2)) - if message, ok := fields["message"].(string); ok { - buffer.Text(message) - delete(fields, "message") - } - buffer.Html(addClosingLink(i) + "` + addOpeningLink(i, 3)) - if rest, err := yaml.Marshal(&fields); err == nil { - buffer.Text(string(rest)) - } - buffer.Html(addClosingLink(i) + "
\n") - return buffer.Bytes(), asyncId -} - -func (qmc *QuesmaManagementConsole) generateReportForRequestsWithStr(requestStr string) []byte { - var debugKeyValueSlice []DebugKeyValue - - qmc.mutex.Lock() - for i := len(qmc.debugLastMessages) - 1; i >= 0; i-- { - debugInfo := qmc.debugInfoMessages[qmc.debugLastMessages[i]] - if debugInfo.requestContains(requestStr) && len(debugKeyValueSlice) < maxLastMessages { - debugKeyValueSlice = append(debugKeyValueSlice, - DebugKeyValue{qmc.debugLastMessages[i], qmc.debugInfoMessages[qmc.debugLastMessages[i]]}) - } - } - qmc.mutex.Unlock() - - title := fmt.Sprintf("Report for str '%s' with %d results", requestStr, len(debugKeyValueSlice)) - return qmc.generateReportForRequests(title, debugKeyValueSlice, []byte{}) -} - -func (qmc *QuesmaManagementConsole) generateReportForRequestsWithError() []byte { - var debugKeyValueSlice []DebugKeyValue - - qmc.mutex.Lock() - for i := len(qmc.debugLastMessages) - 1; i >= 0; i-- { - debugInfo := qmc.debugInfoMessages[qmc.debugLastMessages[i]] - if debugInfo.errorLogCount > 0 && len(debugKeyValueSlice) < maxLastMessages { - debugKeyValueSlice = append(debugKeyValueSlice, - DebugKeyValue{qmc.debugLastMessages[i], qmc.debugInfoMessages[qmc.debugLastMessages[i]]}) - } - } - qmc.mutex.Unlock() - - return qmc.generateReportForRequests("Report for requests with errors", debugKeyValueSlice, []byte{}) -} - -func (qmc *QuesmaManagementConsole) generateReportForRequestsWithWarning() []byte { - var debugKeyValueSlice []DebugKeyValue - - qmc.mutex.Lock() - for i := len(qmc.debugLastMessages) - 1; i >= 0; i-- { - debugInfo := qmc.debugInfoMessages[qmc.debugLastMessages[i]] - if debugInfo.warnLogCount > 0 && len(debugKeyValueSlice) < maxLastMessages { - debugKeyValueSlice = append(debugKeyValueSlice, - DebugKeyValue{qmc.debugLastMessages[i], qmc.debugInfoMessages[qmc.debugLastMessages[i]]}) - } - } - qmc.mutex.Unlock() - - return qmc.generateReportForRequests("Report for requests with warnings", debugKeyValueSlice, []byte{}) -} - -func (qmc *QuesmaManagementConsole) generateReportForRequests(title string, requests []DebugKeyValue, sidebar []byte) []byte { - buffer := newBufferWithHead() - buffer.Write(generateSimpleTop(title)) - - buffer.Html("\n\n\n") - - buffer.Html(`
`) - - buffer.Write(generateQueries(requests, true)) - - buffer.Html("\n
\n\n") - - buffer.Html(`") - buffer.Html("\n") - buffer.Html("\n") - - return buffer.Bytes() -} - -func (qmc *QuesmaManagementConsole) generateErrorForReason(reason string) []byte { - buffer := newBufferWithHead() - buffer.Write(generateTopNavigation(fmt.Sprintf("Errors with reason '%s'", reason))) - - buffer.Html(`
`) - errors := errorstats.GlobalErrorStatistics.ErrorReportsForReason(reason) - // TODO: Make it nicer - for _, errorReport := range errors { - buffer.Html("

").Text(errorReport.ReportedAt.String() + " " + errorReport.DebugMessage).Html("

\n") - } - buffer.Html("\n
\n\n") - - buffer.Html(`") - - buffer.Html("\n") - buffer.Html("\n") - - return buffer.Bytes() -} diff --git a/quesma/quesma/ui/html_panels.go b/quesma/quesma/ui/html_panels.go deleted file mode 100644 index ba8f1ee65..000000000 --- a/quesma/quesma/ui/html_panels.go +++ /dev/null @@ -1,418 +0,0 @@ -package ui - -import ( - "fmt" - "github.com/shirou/gopsutil/v3/cpu" - "github.com/shirou/gopsutil/v3/host" - "github.com/shirou/gopsutil/v3/mem" - "mitmproxy/quesma/buildinfo" - "mitmproxy/quesma/quesma/mux" - "mitmproxy/quesma/quesma/ui/sqlfmt" - "mitmproxy/quesma/stats" - "mitmproxy/quesma/stats/errorstats" - "net/url" - "runtime" - "strings" - "time" -) - -func generateQueries(debugKeyValueSlice []DebugKeyValue, withLinks bool) []byte { - var buffer HtmlBuffer - - buffer.Html("\n" + `
` + "\n") - buffer.Html(`
Query`) - buffer.Html("\n
\n") - buffer.Html(`") - buffer.Html("\n
\n") - - buffer.Html(`\n") - - buffer.Html(`\n") - - buffer.Html(`\n") - - return buffer.Bytes() -} - -func dropFirstSegment(path string) string { - segments := strings.SplitN(path, "/", 3) - if len(segments) > 2 { - return "/" + segments[2] - } - return path -} - -func (qmc *QuesmaManagementConsole) generateRouterStatistics() []byte { - var buffer HtmlBuffer - - matchedKeys, matched, unmatchedKeys, unmatched := mux.MatchStatistics().GroupByFirstSegment() - - buffer.Html("\n

Matched URLs

\n\n") - buffer.Html("\n

Not matched URLs

\n\n") - - return buffer.Bytes() -} - -func (qmc *QuesmaManagementConsole) generateStatistics() []byte { - var buffer HtmlBuffer - const maxTopValues = 5 - - if !qmc.config.IngestStatistics { - buffer.Html("

Statistics are disabled.

\n") - buffer.Html("

 You can enable them by changing ingest_statistics setting to true.

\n") - return buffer.Bytes() - } - - statistics := stats.GlobalStatistics - - for _, index := range statistics.SortedIndexNames() { - buffer.Html("\n

Stats for \"").Text(index.IndexName). - Html(fmt.Sprintf("\" from %d requests

\n", index.Requests)) - - buffer.Html("\n") - - buffer.Html("\n") - buffer.Html(`` + "\n") - buffer.Html(`` + "\n") - buffer.Html(`` + "\n") - buffer.Html(`` + "\n") - buffer.Html(`` + "\n") - buffer.Html(`` + "\n") - buffer.Html(`` + "\n") - buffer.Html("\n") - buffer.Html("\n") - buffer.Html("\n") - - for _, keyStats := range index.SortedKeyStatistics() { - topValuesCount := maxTopValues - if len(keyStats.Values) < maxTopValues { - topValuesCount = len(keyStats.Values) - } - - buffer.Html(`` + "\n") - buffer.Html(fmt.Sprintf(`\n") - buffer.Html(fmt.Sprintf(``+"\n", topValuesCount, keyStats.Occurrences)) - - for i, value := range keyStats.TopNValues(topValuesCount) { - if i > 0 { - buffer.Html("\n\n") - } - - buffer.Html(``) - buffer.Html(fmt.Sprintf(``, value.Occurrences)) - buffer.Html(fmt.Sprintf(``, 100*float32(value.Occurrences)/float32(keyStats.Occurrences))) - buffer.Html(fmt.Sprintf(``, strings.Join(value.Types, ", "))) - } - buffer.Html("\n") - } - - buffer.Html("\n") - - buffer.Html("
KeyCountValueCountPercentagePotential type
`, topValuesCount)).Text(keyStats.KeyName).Html("%d
`).Text(value.ValueName).Html(`%d%.1f%%%s
\n") - } - - return buffer.Bytes() -} - -func errorBanner(debugInfo QueryDebugInfo) string { - result := "" - if debugInfo.errorLogCount > 0 { - result += fmt.Sprintf(` %d errors`, debugInfo.errorLogCount) - } - if debugInfo.warnLogCount > 0 { - result += fmt.Sprintf(` %d warnings`, debugInfo.warnLogCount) - } - return result -} - -func secondsToTerseString(second uint64) string { - return (time.Duration(second) * time.Second).String() -} - -func statusToDiv(s healthCheckStatus) string { - return fmt.Sprintf(`
%s
`, s.status, s.tooltip, s.message) -} - -func (qmc *QuesmaManagementConsole) generateDashboardPanel() []byte { - var buffer HtmlBuffer - - dashboardName := "

Kibana

" - storeName := "

Elasticsearch

" - if qmc.config.Elasticsearch.Url != nil && strings.Contains(qmc.config.Elasticsearch.Url.String(), "opensearch") { - dashboardName = "

OpenSearch

Dashboards

" - storeName = "

OpenSearch

" - } - - clickhouseName := "

ClickHouse

" - if qmc.config.Hydrolix.Url != nil { - clickhouseName = "

Hydrolix

" - } - - buffer.Html(`
`) - if qmc.config.Elasticsearch.AdminUrl != nil { - buffer.Html(fmt.Sprintf(``, qmc.config.Elasticsearch.AdminUrl.String())) - } - buffer.Html(dashboardName) - if qmc.config.Elasticsearch.AdminUrl != nil { - buffer.Html(``) - } - buffer.Html(statusToDiv(qmc.checkKibana())) - buffer.Html(`
`) - - buffer.Html(`
`) - buffer.Html(`

Ingest

`) - buffer.Html(statusToDiv(qmc.checkIngest())) - buffer.Html(`
`) - - buffer.Html(`
`) - buffer.Html(storeName) - buffer.Html(statusToDiv(qmc.checkElasticsearch())) - buffer.Html(`
`) - - buffer.Html(`
`) - if qmc.config.ClickHouse.AdminUrl != nil { - buffer.Html(fmt.Sprintf(``, qmc.config.ClickHouse.AdminUrl.String())) - } - buffer.Html(clickhouseName) - if qmc.config.ClickHouse.AdminUrl != nil { - buffer.Html(``) - } - buffer.Html(statusToDiv(qmc.checkClickhouseHealth())) - buffer.Html(`
`) - - buffer.Html(`
`) - - buffer.Html(`
`) - buffer.Html(`

Quesma

`) - - cpuStr := "" - c0, err0 := cpu.Percent(0, false) - - if err0 == nil { - cpuStr = fmt.Sprintf("Host CPU: %.1f%%", c0[0]) - } else { - cpuStr = fmt.Sprintf("Host CPU: N/A (error: %s)", err0.Error()) - } - - buffer.Html(fmt.Sprintf(`
%s
`, cpuStr)) - - var m runtime.MemStats - runtime.ReadMemStats(&m) - memStr := fmt.Sprintf("Memory used: %1.f MB", float64(m.Alloc)/1024.0/1024.0) - if v, errV := mem.VirtualMemory(); errV == nil { - total := float64(v.Total) / 1024.0 / 1024.0 / 1024.0 - memStr += fmt.Sprintf(", avail: %.1f GB", total) - } - buffer.Html(fmt.Sprintf(`
%s
`, memStr)) - - duration := uint64(time.Since(qmc.startedAt).Seconds()) - - buffer.Html(fmt.Sprintf(`
Started: %s ago
`, secondsToTerseString(duration))) - buffer.Html(fmt.Sprintf(`
Mode: %s
`, qmc.config.Mode.String())) - - if h, errH := host.Info(); errH == nil { - buffer.Html(fmt.Sprintf(`
Host uptime: %s
`, secondsToTerseString(h.Uptime))) - } - - buffer.Html("
Version: ") - buffer.Text(buildinfo.Version) - buffer.Html("
") - - buffer.Html(`
`) - - buffer.Html(`
`) - errors := errorstats.GlobalErrorStatistics.ReturnTopErrors(5) - if len(errors) > 0 { - buffer.Html(`

Top errors:

`) - for _, e := range errors { - buffer.Html(fmt.Sprintf(`
%d: %s
`, - e.Count, url.PathEscape(e.Reason), e.Reason)) - } - } else { - buffer.Html(`

No errors

`) - } - buffer.Html(`
`) - buffer.Html(`
`) - - return buffer.Bytes() -} - -func (qmc *QuesmaManagementConsole) generateDashboardTrafficText(typeName string) (string, string) { - reqStats := qmc.requestsStore.GetRequestsStats(typeName) - status := "green" - if reqStats.ErrorRate > 0.20 { - status = "red" - } - return status, fmt.Sprintf("%4.1f req/s, err:%5.1f%%, p99:%3dms", - reqStats.RatePerMinute/60, reqStats.ErrorRate*100, reqStats.Duration99Percentile) -} - -func (qmc *QuesmaManagementConsole) generateDashboardTrafficElement(typeName string, y int) string { - status, text := qmc.generateDashboardTrafficText(typeName) - return fmt.Sprintf( - `
%s
`, - y, typeName, status, text) -} - -func (qmc *QuesmaManagementConsole) generateDashboardTrafficPanel() []byte { - var buffer HtmlBuffer - - // Clickhouse -> Kibana - if qmc.config.ReadsFromClickhouse() { - buffer.Html(qmc.generateDashboardTrafficElement(RequestStatisticKibana2Clickhouse, 21)) - } - - // Elasticsearch -> Kibana - if qmc.config.ReadsFromElasticsearch() { - buffer.Html(qmc.generateDashboardTrafficElement(RequestStatisticKibana2Elasticsearch, 66)) - } - - // Ingest -> Clickhouse - if qmc.config.WritesToClickhouse() { - buffer.Html(qmc.generateDashboardTrafficElement(RequestStatisticIngest2Clickhouse, 31)) - } - - // Ingest -> Elasticsearch - if qmc.config.WritesToElasticsearch() { - buffer.Html(qmc.generateDashboardTrafficElement(RequestStatisticIngest2Elasticsearch, 76)) - } - - return buffer.Bytes() -} - -func (qmc *QuesmaManagementConsole) generateQueriesStatsPanel() []byte { - qmc.mutex.Lock() - errorCount := 0 - warnCount := 0 - for _, msg := range qmc.debugInfoMessages { - if msg.errorLogCount > 0 { - errorCount++ - } - if msg.warnLogCount > 0 { - warnCount++ - } - } - qmc.mutex.Unlock() - - var buffer HtmlBuffer - - buffer.Html(``) - - return buffer.Bytes() -} diff --git a/quesma/quesma/ui/html_utils.go b/quesma/quesma/ui/html_utils.go new file mode 100644 index 000000000..328b55a31 --- /dev/null +++ b/quesma/quesma/ui/html_utils.go @@ -0,0 +1,95 @@ +package ui + +import ( + "fmt" + "mitmproxy/quesma/quesma/ui/internal/buffer" + "net/url" +) + +func generateSimpleTop(title string) []byte { + var buffer buffer.HtmlBuffer + buffer.Html(`
` + "\n") + buffer.Html(`
` + "\n") + buffer.Html(`` + "\n") + buffer.Html(`

`).Text(title).Html(`

`) + buffer.Html("\n
\n
\n\n") + return buffer.Bytes() +} + +func generateTopNavigation(target string) []byte { + var buffer buffer.HtmlBuffer + buffer.Html(`
` + "\n") + buffer.Html(`
` + "\n") + buffer.Html(`` + "\n") + buffer.Html("\n") + buffer.Html("\n
\n") + + if target != "schema" && target != "telemetry" { + buffer.Html(`
` + "\n") + buffer.Html(`
`) + buffer.Html(fmt.Sprintf( + ``, + url.PathEscape(target), url.PathEscape(target))) + buffer.Html(``) + buffer.Html("\n
") + buffer.Html("\n
\n") + } + buffer.Html("\n
\n\n") + return buffer.Bytes() +} + +func newBufferWithHead() buffer.HtmlBuffer { + const bufferSize = 4 * 1024 // size of ui/head.html + var buffer buffer.HtmlBuffer + buffer.Grow(bufferSize) + head, err := uiFs.ReadFile("asset/head.html") + buffer.Write(head) + if err != nil { + buffer.Text(err.Error()) + } + buffer.Html("\n") + return buffer +} diff --git a/quesma/quesma/ui/ingest.go b/quesma/quesma/ui/ingest.go new file mode 100644 index 000000000..ff659ed42 --- /dev/null +++ b/quesma/quesma/ui/ingest.go @@ -0,0 +1,89 @@ +package ui + +import ( + "fmt" + "mitmproxy/quesma/quesma/ui/internal/buffer" + "mitmproxy/quesma/stats" + "strings" +) + +func (qmc *QuesmaManagementConsole) generateIngestStatistics() []byte { + buffer := newBufferWithHead() + buffer.Write(generateTopNavigation("statistics")) + + buffer.Html(`
`) + buffer.Write(qmc.generateStatistics()) + buffer.Html("\n
\n\n") + + buffer.Html(`") + + buffer.Html("\n") + buffer.Html("\n") + return buffer.Bytes() +} + +func (qmc *QuesmaManagementConsole) generateStatistics() []byte { + var buffer buffer.HtmlBuffer + const maxTopValues = 5 + + if !qmc.config.IngestStatistics { + buffer.Html("

Statistics are disabled.

\n") + buffer.Html("

 You can enable them by changing ingest_statistics setting to true.

\n") + return buffer.Bytes() + } + + statistics := stats.GlobalStatistics + + for _, index := range statistics.SortedIndexNames() { + buffer.Html("\n

Stats for \"").Text(index.IndexName). + Html(fmt.Sprintf("\" from %d requests

\n", index.Requests)) + + buffer.Html("\n") + + buffer.Html("\n") + buffer.Html(`` + "\n") + buffer.Html(`` + "\n") + buffer.Html(`` + "\n") + buffer.Html(`` + "\n") + buffer.Html(`` + "\n") + buffer.Html(`` + "\n") + buffer.Html(`` + "\n") + buffer.Html("\n") + buffer.Html("\n") + buffer.Html("\n") + + for _, keyStats := range index.SortedKeyStatistics() { + topValuesCount := maxTopValues + if len(keyStats.Values) < maxTopValues { + topValuesCount = len(keyStats.Values) + } + + buffer.Html(`` + "\n") + buffer.Html(fmt.Sprintf(`\n") + buffer.Html(fmt.Sprintf(``+"\n", topValuesCount, keyStats.Occurrences)) + + for i, value := range keyStats.TopNValues(topValuesCount) { + if i > 0 { + buffer.Html("\n\n") + } + + buffer.Html(``) + buffer.Html(fmt.Sprintf(``, value.Occurrences)) + buffer.Html(fmt.Sprintf(``, 100*float32(value.Occurrences)/float32(keyStats.Occurrences))) + buffer.Html(fmt.Sprintf(``, strings.Join(value.Types, ", "))) + } + buffer.Html("\n") + } + + buffer.Html("\n") + + buffer.Html("
KeyCountValueCountPercentagePotential type
`, topValuesCount)).Text(keyStats.KeyName).Html("%d
`).Text(value.ValueName).Html(`%d%.1f%%%s
\n") + } + + return buffer.Bytes() +} diff --git a/quesma/quesma/ui/html_buffer.go b/quesma/quesma/ui/internal/buffer/html_buffer.go similarity index 97% rename from quesma/quesma/ui/html_buffer.go rename to quesma/quesma/ui/internal/buffer/html_buffer.go index b0a8df937..fee3d02ca 100644 --- a/quesma/quesma/ui/html_buffer.go +++ b/quesma/quesma/ui/internal/buffer/html_buffer.go @@ -1,4 +1,4 @@ -package ui +package buffer import ( "bytes" diff --git a/quesma/quesma/ui/html_buffer_test.go b/quesma/quesma/ui/internal/buffer/html_buffer_test.go similarity index 96% rename from quesma/quesma/ui/html_buffer_test.go rename to quesma/quesma/ui/internal/buffer/html_buffer_test.go index 780265e34..42b9678af 100644 --- a/quesma/quesma/ui/html_buffer_test.go +++ b/quesma/quesma/ui/internal/buffer/html_buffer_test.go @@ -1,4 +1,4 @@ -package ui +package buffer import ( "github.com/stretchr/testify/assert" diff --git a/quesma/quesma/ui/sqlfmt/pretty.go b/quesma/quesma/ui/internal/sqlfmt/pretty.go similarity index 100% rename from quesma/quesma/ui/sqlfmt/pretty.go rename to quesma/quesma/ui/internal/sqlfmt/pretty.go diff --git a/quesma/quesma/ui/sqlfmt/pretty_test.go b/quesma/quesma/ui/internal/sqlfmt/pretty_test.go similarity index 100% rename from quesma/quesma/ui/sqlfmt/pretty_test.go rename to quesma/quesma/ui/internal/sqlfmt/pretty_test.go diff --git a/quesma/quesma/ui/live_tail.go b/quesma/quesma/ui/live_tail.go new file mode 100644 index 000000000..26415a8ea --- /dev/null +++ b/quesma/quesma/ui/live_tail.go @@ -0,0 +1,284 @@ +package ui + +import ( + "fmt" + "mitmproxy/quesma/buildinfo" + "mitmproxy/quesma/quesma/ui/internal/buffer" + "mitmproxy/quesma/quesma/ui/internal/sqlfmt" +) + +func (qmc *QuesmaManagementConsole) generateLiveTail() []byte { + buffer := newBufferWithHead() + buffer.Write(generateTopNavigation("queries")) + + // This preserves scrolling, but does not work if new queries appear. + buffer.Html(``) + + buffer.Html(`
`) + buffer.Write(qmc.generateQueries()) + buffer.Html("\n
\n\n") + + buffer.Html(`") + buffer.Html("\n") + buffer.Html("\n") + return buffer.Bytes() +} + +func (qmc *QuesmaManagementConsole) generateQueries() []byte { + // Take last MAX_LAST_MESSAGES to display, e.g. 100 out of potentially 10m000 + qmc.mutex.Lock() + lastMessages := qmc.debugLastMessages + debugKeyValueSlice := []queryDebugInfoWithId{} + count := 0 + for i := len(lastMessages) - 1; i >= 0 && count < maxLastMessages; i-- { + debugInfoMessage := qmc.debugInfoMessages[lastMessages[i]] + if len(debugInfoMessage.QueryDebugSecondarySource.IncomingQueryBody) > 0 { + debugKeyValueSlice = append(debugKeyValueSlice, queryDebugInfoWithId{lastMessages[i], debugInfoMessage}) + count++ + } + } + qmc.mutex.Unlock() + + queriesBytes := generateQueries(debugKeyValueSlice, true) + queriesStats := qmc.generateQueriesStatsPanel() + unsupportedQueriesStats := qmc.generateUnsupportedQuerySidePanel() + return append(queriesBytes, append(queriesStats, unsupportedQueriesStats...)...) +} + +func (qmc *QuesmaManagementConsole) generateUnsupportedQuerySidePanel() []byte { + qmc.mutex.Lock() + totalErrorsCount := qmc.totalUnsupportedQueries + qmc.mutex.Unlock() + + typesCount := qmc.GetUnsupportedTypesWithCount() + savedErrorsCount := 0 + for _, count := range typesCount { + savedErrorsCount += count + } + typesSeenCount := len(typesCount) + unknownTypeCount := 0 + if value, ok := typesCount[UnrecognizedQueryType]; ok { + unknownTypeCount = value + } + + var buffer buffer.HtmlBuffer + linkToMainView := `
  • `) + if totalErrorsCount > 0 { + buffer.Html(fmt.Sprintf(`%s class="debug-warn-log"">%d total (%d recent)
  • `, linkToMainView, totalErrorsCount, savedErrorsCount)) + plural := "s" + if typesSeenCount == 1 { + plural = "" + } + buffer.Html(fmt.Sprintf(`%s class="debug-warn-log"">%d different type%s`, linkToMainView, typesSeenCount, plural)) + if unknownTypeCount > 0 { + buffer.Html(fmt.Sprintf(`
  • `, UnrecognizedQueryType)) + buffer.Html(fmt.Sprintf(`%d of unrecognized type
  • `, unknownTypeCount)) + } + } else { + buffer.Html(`
  • None!
  • `) + } + buffer.Html(``) + + return buffer.Bytes() +} + +func (qmc *QuesmaManagementConsole) generateQueriesStatsPanel() []byte { + qmc.mutex.Lock() + errorCount := 0 + warnCount := 0 + for _, msg := range qmc.debugInfoMessages { + if msg.errorLogCount > 0 { + errorCount++ + } + if msg.warnLogCount > 0 { + warnCount++ + } + } + qmc.mutex.Unlock() + + var buffer buffer.HtmlBuffer + + buffer.Html(``) + + return buffer.Bytes() +} + +func generateQueries(debugKeyValueSlice []queryDebugInfoWithId, withLinks bool) []byte { + var buffer buffer.HtmlBuffer + + buffer.Html("\n" + `
    ` + "\n") + buffer.Html(`
    Query`) + buffer.Html("\n
    \n") + buffer.Html(`") + buffer.Html("\n
    \n") + + buffer.Html(`\n") + + buffer.Html(`
    ` + "\n") + buffer.Html(`
    Clickhouse translated query` + "\n" + `
    `) + buffer.Html(`") + buffer.Html("\n
    \n") + + buffer.Html(`\n") + + return buffer.Bytes() +} + +func errorBanner(debugInfo queryDebugInfo) string { + result := "" + if debugInfo.errorLogCount > 0 { + result += fmt.Sprintf(` %d errors`, debugInfo.errorLogCount) + } + if debugInfo.warnLogCount > 0 { + result += fmt.Sprintf(` %d warnings`, debugInfo.warnLogCount) + } + return result +} diff --git a/quesma/quesma/ui/live_tail_drilldown.go b/quesma/quesma/ui/live_tail_drilldown.go new file mode 100644 index 000000000..89374d792 --- /dev/null +++ b/quesma/quesma/ui/live_tail_drilldown.go @@ -0,0 +1,275 @@ +package ui + +import ( + "encoding/json" + "fmt" + "gopkg.in/yaml.v3" + "mitmproxy/quesma/quesma/ui/internal/buffer" + "strings" +) + +func (qmc *QuesmaManagementConsole) generateReportForRequestId(requestId string) []byte { + qmc.mutex.Lock() + request, requestFound := qmc.debugInfoMessages[requestId] + qmc.mutex.Unlock() + + buffer := newBufferWithHead() + if requestFound { + buffer.Write(generateSimpleTop("Report for request UUID " + requestId)) + } else { + buffer.Write(generateSimpleTop("Report not found for request UUID " + requestId)) + } + + buffer.Html(`
    `) + + debugKeyValueSlice := []queryDebugInfoWithId{} + if requestFound { + debugKeyValueSlice = append(debugKeyValueSlice, queryDebugInfoWithId{requestId, request}) + } + + buffer.Write(generateQueries(debugKeyValueSlice, false)) + + buffer.Html("\n
    \n") + buffer.Html(`") + buffer.Html("\n") + buffer.Html("\n") + return buffer.Bytes() +} + +func (qmc *QuesmaManagementConsole) generateLogForRequestId(requestId string) []byte { + qmc.mutex.Lock() + request, requestFound := qmc.debugInfoMessages[requestId] + qmc.mutex.Unlock() + + logMessages, optAsyncId := generateLogMessages(request.logMessages, []string{}) + + buffer := newBufferWithHead() + if requestFound { + if optAsyncId != nil { + buffer.Write(generateSimpleTop("Log for request id " + requestId + " and async id " + *optAsyncId)) + } else { + buffer.Write(generateSimpleTop("Log for request id " + requestId)) + } + } else { + buffer.Write(generateSimpleTop("Log not found for request id " + requestId)) + } + + buffer.Html(`
    `) + buffer.Html("\n\n") + buffer.Html(`
    `) + + buffer.Write(logMessages) + + buffer.Html("\n
    \n") + buffer.Html("\n
    \n") + buffer.Html(`") + buffer.Html("\n") + buffer.Html("\n") + return buffer.Bytes() +} + +// links might be empty, then table won't have any links within. +// if i < len(logMessages) && i < len(links) then logMessages[i] will have link links[i] +func generateLogMessages(logMessages []string, links []string) ([]byte, *string) { + // adds a link to the table row if there is a link for it + addOpeningLink := func(row, column int) string { + if row < len(links) { + link := `" + } + return "" + } + addClosingLink := func(i int) string { + if i < len(links) { + return "" + } + return "" + } + + var buffer buffer.HtmlBuffer + buffer.Html("\n") + buffer.Html("\n") + buffer.Html("\n") + buffer.Html(``) + buffer.Html(``) + buffer.Html(``) + buffer.Html(``) + buffer.Html("\n") + + buffer.Html("\n") + buffer.Html("\n") + + var asyncId *string + + for i, logMessage := range logMessages { + buffer.Html("\n") + + var fields map[string]interface{} + + if err := json.Unmarshal([]byte(logMessage), &fields); err != nil { + // error print + buffer.Html("").Text(err.Error()).Html("") + + // get rid of request_id and async_id + delete(fields, "request_id") + if id, ok := fields["async_id"].(string); ok { + asyncId = &id + delete(fields, "async_id") + } + + // level + buffer.Html(`") + delete(fields, "level") + } else { + buffer.Html("missing level") + } + buffer.Html(addClosingLink(i) + "") + + // message + buffer.Html(`") + + // fields + buffer.Html(`\n") + } + + buffer.Html("\n") + buffer.Html("
    TimeLevelMessageFields
    error") + continue + } + // time + buffer.Html(`` + addOpeningLink(i, 0)) + if _, ok := fields["time"]; ok { + time := fields["time"].(string) + time = strings.Replace(time, "T", " ", 1) + time = strings.Replace(time, ".", " ", 1) + buffer.Text(time) + delete(fields, "time") + } else { + buffer.Html("missing time") + } + buffer.Html(addClosingLink(i) + "` + addOpeningLink(i, 1)) + if level, ok := fields["level"].(string); ok { + if level == "error" { + buffer.Html(``) + } else if level == "warn" { + buffer.Html(``) + } else { + buffer.Html(``) + } + buffer.Text(level).Html("` + addOpeningLink(i, 2)) + if message, ok := fields["message"].(string); ok { + buffer.Text(message) + delete(fields, "message") + } + buffer.Html(addClosingLink(i) + "` + addOpeningLink(i, 3)) + if rest, err := yaml.Marshal(&fields); err == nil { + buffer.Text(string(rest)) + } + buffer.Html(addClosingLink(i) + "
    \n") + return buffer.Bytes(), asyncId +} + +func (qmc *QuesmaManagementConsole) generateReportForRequestsWithStr(requestStr string) []byte { + var debugKeyValueSlice []queryDebugInfoWithId + + qmc.mutex.Lock() + for i := len(qmc.debugLastMessages) - 1; i >= 0; i-- { + debugInfo := qmc.debugInfoMessages[qmc.debugLastMessages[i]] + if debugInfo.requestContains(requestStr) && len(debugKeyValueSlice) < maxLastMessages { + debugKeyValueSlice = append(debugKeyValueSlice, + queryDebugInfoWithId{qmc.debugLastMessages[i], qmc.debugInfoMessages[qmc.debugLastMessages[i]]}) + } + } + qmc.mutex.Unlock() + + title := fmt.Sprintf("Report for str '%s' with %d results", requestStr, len(debugKeyValueSlice)) + return qmc.generateReportForRequests(title, debugKeyValueSlice, []byte{}) +} + +func (qmc *QuesmaManagementConsole) generateReportForRequestsWithError() []byte { + var debugKeyValueSlice []queryDebugInfoWithId + + qmc.mutex.Lock() + for i := len(qmc.debugLastMessages) - 1; i >= 0; i-- { + debugInfo := qmc.debugInfoMessages[qmc.debugLastMessages[i]] + if debugInfo.errorLogCount > 0 && len(debugKeyValueSlice) < maxLastMessages { + debugKeyValueSlice = append(debugKeyValueSlice, + queryDebugInfoWithId{qmc.debugLastMessages[i], qmc.debugInfoMessages[qmc.debugLastMessages[i]]}) + } + } + qmc.mutex.Unlock() + + return qmc.generateReportForRequests("Report for requests with errors", debugKeyValueSlice, []byte{}) +} + +func (qmc *QuesmaManagementConsole) generateReportForRequestsWithWarning() []byte { + var debugKeyValueSlice []queryDebugInfoWithId + + qmc.mutex.Lock() + for i := len(qmc.debugLastMessages) - 1; i >= 0; i-- { + debugInfo := qmc.debugInfoMessages[qmc.debugLastMessages[i]] + if debugInfo.warnLogCount > 0 && len(debugKeyValueSlice) < maxLastMessages { + debugKeyValueSlice = append(debugKeyValueSlice, + queryDebugInfoWithId{qmc.debugLastMessages[i], qmc.debugInfoMessages[qmc.debugLastMessages[i]]}) + } + } + qmc.mutex.Unlock() + + return qmc.generateReportForRequests("Report for requests with warnings", debugKeyValueSlice, []byte{}) +} + +func (qmc *QuesmaManagementConsole) generateReportForRequests(title string, requests []queryDebugInfoWithId, sidebar []byte) []byte { + buffer := newBufferWithHead() + buffer.Write(generateSimpleTop(title)) + + buffer.Html("\n\n\n") + + buffer.Html(`
    `) + + buffer.Write(generateQueries(requests, true)) + + buffer.Html("\n
    \n\n") + + buffer.Html(`") + buffer.Html("\n") + buffer.Html("\n") + + return buffer.Bytes() +} diff --git a/quesma/quesma/ui/management_console.go b/quesma/quesma/ui/management_console.go index 3559977aa..3de41f598 100644 --- a/quesma/quesma/ui/management_console.go +++ b/quesma/quesma/ui/management_console.go @@ -5,27 +5,21 @@ import ( "mitmproxy/quesma/elasticsearch" "mitmproxy/quesma/telemetry" "mitmproxy/quesma/tracing" - _ "net/http/pprof" + "mitmproxy/quesma/util" - "encoding/json" - "errors" - "io" "mitmproxy/quesma/clickhouse" "mitmproxy/quesma/logger" "mitmproxy/quesma/quesma/config" "mitmproxy/quesma/stats" - "mitmproxy/quesma/util" "net/http" "reflect" "regexp" - "runtime" "strings" "sync" "time" ) const ( - uiTcpPort = "9999" maxLastMessages = 10000 ) @@ -55,7 +49,7 @@ type QueryDebugSecondarySource struct { SecondaryTook time.Duration } -type QueryDebugInfo struct { +type queryDebugInfo struct { QueryDebugPrimarySource QueryDebugSecondarySource logMessages []string @@ -64,6 +58,11 @@ type QueryDebugInfo struct { unsupported *string } +type queryDebugInfoWithId struct { + id string + query queryDebugInfo +} + type recordRequests struct { typeName string took time.Duration @@ -76,9 +75,9 @@ type QuesmaManagementConsole struct { queryDebugLogs <-chan tracing.LogWithLevel ui *http.Server mutex sync.Mutex - debugInfoMessages map[string]QueryDebugInfo + debugInfoMessages map[string]queryDebugInfo debugLastMessages []string - responseMatcherChannel chan QueryDebugInfo + responseMatcherChannel chan queryDebugInfo config config.QuesmaConfiguration requestsStore *stats.RequestStatisticStore requestsSource chan *recordRequests @@ -96,9 +95,9 @@ func NewQuesmaManagementConsole(config config.QuesmaConfiguration, logManager *c queryDebugPrimarySource: make(chan *QueryDebugPrimarySource, 10), queryDebugSecondarySource: make(chan *QueryDebugSecondarySource, 10), queryDebugLogs: logChan, - debugInfoMessages: make(map[string]QueryDebugInfo), + debugInfoMessages: make(map[string]queryDebugInfo), debugLastMessages: make([]string, 0), - responseMatcherChannel: make(chan QueryDebugInfo, 5), + responseMatcherChannel: make(chan queryDebugInfo, 5), config: config, requestsStore: stats.NewRequestStatisticStore(), requestsSource: make(chan *recordRequests, 100), @@ -123,7 +122,7 @@ func (qmc *QuesmaManagementConsole) RecordRequest(typeName string, took time.Dur qmc.requestsSource <- &recordRequests{typeName, took, error} } -func (qdi *QueryDebugInfo) requestContains(queryStr string) bool { +func (qdi *queryDebugInfo) requestContains(queryStr string) bool { potentialPlaces := [][]byte{qdi.QueryDebugSecondarySource.IncomingQueryBody, qdi.QueryDebugSecondarySource.QueryBodyTranslated} for _, potentialPlace := range potentialPlaces { @@ -134,80 +133,6 @@ func (qdi *QueryDebugInfo) requestContains(queryStr string) bool { return false } -func (qmc *QuesmaManagementConsole) newHTTPServer() *http.Server { - return &http.Server{ - Addr: ":" + uiTcpPort, - Handler: qmc.createRouting(), - } -} - -func panicRecovery(h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer func() { - if err := recover(); err != nil { - buf := make([]byte, 2048) - n := runtime.Stack(buf, false) - buf = buf[:n] - - w.WriteHeader(http.StatusInternalServerError) - w.Header().Set("Content-Type", "text/plain") - w.Write([]byte("Internal Server Error\n\n")) - - w.Write([]byte("Stack:\n")) - w.Write(buf) - logger.Error().Msgf("recovering from err %v\n %s", err, buf) - } - }() - - h.ServeHTTP(w, r) - }) -} - -func (qmc *QuesmaManagementConsole) listenAndServe() { - if err := qmc.ui.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - logger.Fatal().Msgf("Error starting server: %v", err) - } -} - -type DebugKeyValue struct { - Key string - Value QueryDebugInfo -} - -func (qmc *QuesmaManagementConsole) generateQueries() []byte { - // Take last MAX_LAST_MESSAGES to display, e.g. 100 out of potentially 10m000 - qmc.mutex.Lock() - lastMessages := qmc.debugLastMessages - debugKeyValueSlice := []DebugKeyValue{} - count := 0 - for i := len(lastMessages) - 1; i >= 0 && count < maxLastMessages; i-- { - debugInfoMessage := qmc.debugInfoMessages[lastMessages[i]] - if len(debugInfoMessage.QueryDebugSecondarySource.IncomingQueryBody) > 0 { - debugKeyValueSlice = append(debugKeyValueSlice, DebugKeyValue{lastMessages[i], debugInfoMessage}) - count++ - } - } - qmc.mutex.Unlock() - - queriesBytes := generateQueries(debugKeyValueSlice, true) - queriesStats := qmc.generateQueriesStatsPanel() - unsupportedQueriesStats := qmc.generateUnsupportedQuerySidePanel() - return append(queriesBytes, append(queriesStats, unsupportedQueriesStats...)...) -} - -func newBufferWithHead() HtmlBuffer { - const bufferSize = 4 * 1024 // size of ui/head.html - var buffer HtmlBuffer - buffer.Grow(bufferSize) - head, err := uiFs.ReadFile("asset/head.html") - buffer.Write(head) - if err != nil { - buffer.Text(err.Error()) - } - buffer.Html("\n") - return buffer -} - func (qmc *QuesmaManagementConsole) addNewMessageId(messageId string) { qmc.debugLastMessages = append(qmc.debugLastMessages, messageId) if len(qmc.debugLastMessages) > maxLastMessages { @@ -224,14 +149,14 @@ func (qmc *QuesmaManagementConsole) processChannelMessage() { []byte(util.JsonPrettify(string(msg.QueryResp), true)), msg.PrimaryTook} qmc.mutex.Lock() if value, ok := qmc.debugInfoMessages[msg.Id]; !ok { - qmc.debugInfoMessages[msg.Id] = QueryDebugInfo{ + qmc.debugInfoMessages[msg.Id] = queryDebugInfo{ QueryDebugPrimarySource: debugPrimaryInfo, } qmc.addNewMessageId(msg.Id) } else { value.QueryDebugPrimarySource = debugPrimaryInfo qmc.debugInfoMessages[msg.Id] = value - // That's the point where QueryDebugInfo is + // That's the point where queryDebugInfo is // complete and we can compare results if isComplete(value) { qmc.responseMatcherChannel <- value @@ -251,13 +176,13 @@ func (qmc *QuesmaManagementConsole) processChannelMessage() { } qmc.mutex.Lock() if value, ok := qmc.debugInfoMessages[msg.Id]; !ok { - qmc.debugInfoMessages[msg.Id] = QueryDebugInfo{ + qmc.debugInfoMessages[msg.Id] = queryDebugInfo{ QueryDebugSecondarySource: secondaryDebugInfo, } qmc.addNewMessageId(msg.Id) } else { value.QueryDebugSecondarySource = secondaryDebugInfo - // That's the point where QueryDebugInfo is + // That's the point where queryDebugInfo is // complete and we can compare results qmc.debugInfoMessages[msg.Id] = value if isComplete(value) { @@ -274,10 +199,10 @@ func (qmc *QuesmaManagementConsole) processChannelMessage() { requestId := match[1] qmc.mutex.Lock() - var value QueryDebugInfo + var value queryDebugInfo var ok bool if value, ok = qmc.debugInfoMessages[requestId]; !ok { - value = QueryDebugInfo{ + value = queryDebugInfo{ logMessages: []string{log.Msg}, } qmc.addNewMessageId(requestId) @@ -301,7 +226,7 @@ func (qmc *QuesmaManagementConsole) processChannelMessage() { } } -func isComplete(value QueryDebugInfo) bool { +func isComplete(value queryDebugInfo) bool { return !reflect.DeepEqual(value.QueryDebugPrimarySource, QueryDebugPrimarySource{}) && !reflect.DeepEqual(value.QueryDebugSecondarySource, QueryDebugSecondarySource{}) } @@ -324,43 +249,6 @@ func (qmc *QuesmaManagementConsole) RunOnlyChannelProcessor() { } } -func (qmc *QuesmaManagementConsole) checkHealth(writer http.ResponseWriter, _ *http.Request) { - health := qmc.checkElasticsearch() - if health.status != "red" { - writer.WriteHeader(200) - writer.Header().Set("Content-Type", "application/json") - _, _ = writer.Write([]byte(`{"cluster_name": "quesma"}`)) - } else { - writer.WriteHeader(503) - _, _ = writer.Write([]byte(`Elastic search is unavailable: ` + health.message)) - } -} - -// curl -X POST localhost:9999/_quesma/bypass -d '{"bypass": true}' -func bypassSwitch(writer http.ResponseWriter, r *http.Request) { - bodyString, err := io.ReadAll(r.Body) - if err != nil { - logger.Error().Msgf("Error reading body: %v", err) - writer.WriteHeader(400) - _, _ = writer.Write([]byte("Error reading body: " + err.Error())) - return - } - body := make(map[string]interface{}) - err = json.Unmarshal(bodyString, &body) - if err != nil { - logger.Fatal().Msg(err.Error()) - } - - if body["bypass"] != nil { - val := body["bypass"].(bool) - config.SetTrafficAnalysis(val) - logger.Info().Msgf("global bypass set to %t\n", val) - writer.WriteHeader(200) - } else { - writer.WriteHeader(400) - } -} - func (qmc *QuesmaManagementConsole) comparePipelines() { for { queryDebugInfo, ok := <-qmc.responseMatcherChannel diff --git a/quesma/quesma/ui/routing.go b/quesma/quesma/ui/routing.go new file mode 100644 index 000000000..305fc1400 --- /dev/null +++ b/quesma/quesma/ui/routing.go @@ -0,0 +1,77 @@ +package ui + +import ( + "mitmproxy/quesma/quesma/mux" + "mitmproxy/quesma/quesma/ui/internal/buffer" + "strings" +) + +func (qmc *QuesmaManagementConsole) generateRouterStatisticsLiveTail() []byte { + buffer := newBufferWithHead() + buffer.Write(generateTopNavigation("routing-statistics")) + + buffer.Html(`
    `) + buffer.Write(qmc.generateRouterStatistics()) + buffer.Html("\n
    \n\n") + + buffer.Html(`") + + buffer.Html("\n") + buffer.Html("\n") + return buffer.Bytes() +} + +func dropFirstSegment(path string) string { + segments := strings.SplitN(path, "/", 3) + if len(segments) > 2 { + return "/" + segments[2] + } + return path +} + +func (qmc *QuesmaManagementConsole) generateRouterStatistics() []byte { + var buffer buffer.HtmlBuffer + + matchedKeys, matched, unmatchedKeys, unmatched := mux.MatchStatistics().GroupByFirstSegment() + + buffer.Html("\n

    Matched URLs

    \n\n") + buffer.Html("\n

    Not matched URLs

    \n\n") + + return buffer.Bytes() +} diff --git a/quesma/quesma/ui/schema.go b/quesma/quesma/ui/schema.go new file mode 100644 index 000000000..30b9ae4bc --- /dev/null +++ b/quesma/quesma/ui/schema.go @@ -0,0 +1,275 @@ +package ui + +import ( + "fmt" + "mitmproxy/quesma/clickhouse" + "mitmproxy/quesma/util" + "sort" + "strings" +) + +func (qmc *QuesmaManagementConsole) generateSchema() []byte { + type menuEntry struct { + label string + target string + } + + var menuEntries []menuEntry + + type tableColumn struct { + name string + typeName string + isAttribute bool + isFullTextSearch bool + warning *string + } + + buffer := newBufferWithHead() + buffer.Write(generateTopNavigation("schema")) + buffer.Html(`
    `) + + if qmc.logManager != nil { + + // Not sure if we should read directly from the TableMap or we should use the Snapshot of it. + // Let's leave it as is for now. + schema := qmc.logManager.GetTableDefinitions() + + tableNames := schema.Keys() + sort.Strings(tableNames) + + buffer.Html("\n") + + for i, tableName := range tableNames { + table, ok := schema.Load(tableName) + if !ok { + continue + } + + id := fmt.Sprintf("schema-table-%d", i) + var menu menuEntry + menu.label = table.Name + menu.target = fmt.Sprintf("#%s", id) + menuEntries = append(menuEntries, menu) + + buffer.Html(``) + buffer.Html(``) + buffer.Html(``) + + buffer.Html(``) + buffer.Html(``) + buffer.Html(``) + buffer.Html(``) + + var columnNames []string + var columnMap = make(map[string]tableColumn) + + // standard columns, visible for the user + for k := range table.Cols { + c := tableColumn{} + + c.name = k + if table.Cols[k].Type != nil { + c.typeName = table.Cols[k].Type.StringWithNullable() + } else { + c.typeName = "n/a" + } + + c.isAttribute = false + c.isFullTextSearch = table.Cols[k].IsFullTextMatch + + columnNames = append(columnNames, k) + columnMap[k] = c + } + + for _, a := range qmc.config.AliasFields(table.Name) { + + // check for collisions + if field, collide := columnMap[a.SourceFieldName]; collide { + field.warning = util.Pointer("alias declared with the same name") + columnMap[a.SourceFieldName] = field + continue + } + + // check if target exists + c := tableColumn{} + c.name = a.SourceFieldName + if aliasedField, ok := columnMap[a.TargetFieldName]; ok { + c.typeName = fmt.Sprintf("alias of '%s', %s", a.TargetFieldName, aliasedField.typeName) + c.isFullTextSearch = aliasedField.isFullTextSearch + c.isAttribute = aliasedField.isAttribute + } else { + c.warning = util.Pointer("alias points to non-existing field '" + a.TargetFieldName + "'") + c.typeName = "dangling alias" + } + + columnNames = append(columnNames, a.SourceFieldName) + columnMap[a.SourceFieldName] = c + } + + // columns added by Quesma, not visible for the user + // + // this part is based on addOurFieldsToCreateTableQuery in log_manager.go + attributes := table.Config.GetAttributes() + if len(attributes) > 0 { + for _, a := range attributes { + _, ok := table.Cols[a.KeysArrayName] + if !ok { + c := tableColumn{} + c.name = a.KeysArrayName + c.typeName = clickhouse.CompoundType{Name: "Array", BaseType: clickhouse.NewBaseType("String")}.StringWithNullable() + c.isAttribute = true + columnNames = append(columnNames, c.name) + columnMap[c.name] = c + } + _, ok = table.Cols[a.ValuesArrayName] + if !ok { + c := tableColumn{} + c.name = a.ValuesArrayName + c.typeName = clickhouse.CompoundType{Name: "Array", BaseType: a.Type}.StringWithNullable() + c.isAttribute = true + columnNames = append(columnNames, c.name) + columnMap[c.name] = c + } + } + } + + sort.Strings(columnNames) + + for _, columnName := range columnNames { + column, ok := columnMap[columnName] + if !ok { + continue + } + + buffer.Html(``) + buffer.Html(``) + buffer.Html(``) + buffer.Html(``) + } + + } + + buffer.Html("\n

    `) + buffer.Html(`Table: `) + buffer.Text(table.Name) + + if table.Comment != "" { + buffer.Text(" (") + buffer.Text(table.Comment) + buffer.Text(")") + } + + buffer.Html(`

    `) + buffer.Html(`Name`) + buffer.Html(``) + buffer.Html(`Type`) + buffer.Html(`
    `) + + buffer.Text(column.name) + buffer.Html(``) + + buffer.Text(column.typeName) + if column.isFullTextSearch { + buffer.Html(` (Full text match)`) + } + + if column.warning != nil { + buffer.Html(` WARNING: `) + buffer.Text(*column.warning) + buffer.Html(``) + } + + buffer.Html(`
    ") + + } else { + buffer.Html(`

    Schema is not available

    `) + } + + buffer.Html("\n") + buffer.Html(``) + buffer.Html(``) + buffer.Html(``) + + buffer.Html(``) + buffer.Html(``) + buffer.Html(``) + buffer.Html(``) + + buffer.Html(``) + + for _, cfg := range qmc.config.IndexConfig { + buffer.Html(``) + buffer.Html(``) + buffer.Html(``) + + buffer.Html(``) + + buffer.Html(``) + } + + buffer.Html("\n

    `) + buffer.Html(`Quesma Config`) + buffer.Html(`

    `) + buffer.Html(`Name Pattern`) + buffer.Html(``) + buffer.Html(`Enabled?`) + buffer.Html(``) + buffer.Html(`Full Text Search Fields`) + buffer.Html(`
    `) + buffer.Text(cfg.Name) + buffer.Html(``) + if cfg.Enabled { + buffer.Text("true") + } else { + buffer.Text("false") + } + buffer.Html(``) + buffer.Text(strings.Join(cfg.FullTextFields, ", ")) + buffer.Html(`
    ") + + buffer.Html("\n
    \n\n") + + buffer.Html(`") + + buffer.Html("\n") + buffer.Html("\n") + return buffer.Bytes() +} diff --git a/quesma/quesma/ui/telemetry.go b/quesma/quesma/ui/telemetry.go new file mode 100644 index 000000000..a59a5ed93 --- /dev/null +++ b/quesma/quesma/ui/telemetry.go @@ -0,0 +1,36 @@ +package ui + +import ( + "encoding/json" + "mitmproxy/quesma/logger" +) + +func (qmc *QuesmaManagementConsole) generateTelemetry() []byte { + buffer := newBufferWithHead() + buffer.Write(generateTopNavigation("telemetry")) + buffer.Html(`
    `) + + buffer.Html(`

    Telemetry

    `) + buffer.Html("
    ")
    +
    +	stats, available := qmc.phoneHomeAgent.RecentStats()
    +	if available {
    +		asBytes, err := json.MarshalIndent(stats, "", "  ")
    +
    +		if err != nil {
    +			logger.Error().Err(err).Msg("Error marshalling phone home stats")
    +			buffer.Html("Telemetry Stats are unable to be displayed. This is a bug.")
    +		} else {
    +			buffer.Html(string(asBytes))
    +		}
    +
    +	} else {
    +		buffer.Html("Telemetry Stats are not available yet.")
    +	}
    +
    +	buffer.Html("
    ") + + buffer.Html("\n") + buffer.Html("\n") + return buffer.Bytes() +} diff --git a/quesma/quesma/ui/unsupported_queries.go b/quesma/quesma/ui/unsupported_queries.go index f206889ed..35cc9cf0b 100644 --- a/quesma/quesma/ui/unsupported_queries.go +++ b/quesma/quesma/ui/unsupported_queries.go @@ -5,6 +5,7 @@ import ( "github.com/rs/zerolog" "mitmproxy/quesma/logger" "mitmproxy/quesma/model" + "mitmproxy/quesma/quesma/ui/internal/buffer" "mitmproxy/quesma/tracing" "regexp" "sort" @@ -42,14 +43,14 @@ func processUnsupportedLogMessage(log tracing.LogWithLevel) *string { } func (qmc *QuesmaManagementConsole) generateReportForUnsupportedRequests() []byte { - var debugKeyValueSlice []DebugKeyValue + var debugKeyValueSlice []queryDebugInfoWithId qmc.mutex.Lock() for i := len(qmc.debugLastMessages) - 1; i >= 0; i-- { debugInfo := qmc.debugInfoMessages[qmc.debugLastMessages[i]] if debugInfo.unsupported != nil && len(debugKeyValueSlice) < maxLastMessages { debugKeyValueSlice = append(debugKeyValueSlice, - DebugKeyValue{qmc.debugLastMessages[i], qmc.debugInfoMessages[qmc.debugLastMessages[i]]}) + queryDebugInfoWithId{qmc.debugLastMessages[i], qmc.debugInfoMessages[qmc.debugLastMessages[i]]}) } } qmc.mutex.Unlock() @@ -68,7 +69,7 @@ func (qmc *QuesmaManagementConsole) generateReportForUnsupportedRequests() []byt return slice[i].count > slice[j].count }) - var buffer HtmlBuffer + var buffer buffer.HtmlBuffer buffer.Html("
    ") buffer.Html(`

    Unsupported queries by type

    `) buffer.Html(`
      `) @@ -82,44 +83,6 @@ func (qmc *QuesmaManagementConsole) generateReportForUnsupportedRequests() []byt return qmc.generateReportForRequests("Unsupported requests", debugKeyValueSlice, buffer.Bytes()) } -func (qmc *QuesmaManagementConsole) generateUnsupportedQuerySidePanel() []byte { - qmc.mutex.Lock() - totalErrorsCount := qmc.totalUnsupportedQueries - qmc.mutex.Unlock() - - typesCount := qmc.GetUnsupportedTypesWithCount() - savedErrorsCount := 0 - for _, count := range typesCount { - savedErrorsCount += count - } - typesSeenCount := len(typesCount) - unknownTypeCount := 0 - if value, ok := typesCount[UnrecognizedQueryType]; ok { - unknownTypeCount = value - } - - var buffer HtmlBuffer - linkToMainView := `
    • `) - if totalErrorsCount > 0 { - buffer.Html(fmt.Sprintf(`%s class="debug-warn-log"">%d total (%d recent)
    • `, linkToMainView, totalErrorsCount, savedErrorsCount)) - plural := "s" - if typesSeenCount == 1 { - plural = "" - } - buffer.Html(fmt.Sprintf(`%s class="debug-warn-log"">%d different type%s`, linkToMainView, typesSeenCount, plural)) - if unknownTypeCount > 0 { - buffer.Html(fmt.Sprintf(`
    • `, UnrecognizedQueryType)) - buffer.Html(fmt.Sprintf(`%d of unrecognized type
    • `, unknownTypeCount)) - } - } else { - buffer.Html(`
    • None!
    • `) - } - buffer.Html(`
    `) - - return buffer.Bytes() -} - func (qmc *QuesmaManagementConsole) GetTotalUnsupportedQueries() int { qmc.mutex.Lock() defer qmc.mutex.Unlock() @@ -160,8 +123,8 @@ func (qmc *QuesmaManagementConsole) GetUnsupportedTypesWithCount() map[string]in return types } -func (qmc *QuesmaManagementConsole) QueriesWithUnsupportedType(typeName string) []DebugKeyValue { - var debugKeyValueSlice []DebugKeyValue +func (qmc *QuesmaManagementConsole) QueriesWithUnsupportedType(typeName string) []queryDebugInfoWithId { + var debugKeyValueSlice []queryDebugInfoWithId qmc.mutex.Lock() for i := len(qmc.debugLastMessages) - 1; i >= 0; i-- { @@ -169,7 +132,7 @@ func (qmc *QuesmaManagementConsole) QueriesWithUnsupportedType(typeName string) if debugInfo.unsupported != nil && len(debugKeyValueSlice) < maxLastMessages { if *debugInfo.unsupported == typeName { debugKeyValueSlice = append(debugKeyValueSlice, - DebugKeyValue{qmc.debugLastMessages[i], qmc.debugInfoMessages[qmc.debugLastMessages[i]]}) + queryDebugInfoWithId{qmc.debugLastMessages[i], qmc.debugInfoMessages[qmc.debugLastMessages[i]]}) } } }