IMPALA-5811: Add 'backends' tab to query details pages Add a 'backends' tab to query details pages which shows:
* host * total number of fragment instances for that query on that backend * number of still-running fragment instances * if the backend is complete (i.e. all instances finished) * peak memory consumption * the time, in ms, since a status report was received at the * coordinator from that backend. The table refreshes itself every second, controllable by a check-box. If the query has completed, no information is displayed. Testing: Add a new smoketest to test_web_pages.py. Change-Id: Ib5b3b0fb8f4188da56da593199f41ce6fab99767 Reviewed-on: http://gerrit.cloudera.org:8080/7711 Reviewed-by: Dan Hecht <[email protected]> Tested-by: Impala Public Jenkins Project: http://git-wip-us.apache.org/repos/asf/incubator-impala/repo Commit: http://git-wip-us.apache.org/repos/asf/incubator-impala/commit/ff5e9b6c Tree: http://git-wip-us.apache.org/repos/asf/incubator-impala/tree/ff5e9b6c Diff: http://git-wip-us.apache.org/repos/asf/incubator-impala/diff/ff5e9b6c Branch: refs/heads/master Commit: ff5e9b6c9a35e2869c0c845d7bbb7beb16b5d45e Parents: 6f20df8 Author: Henry Robinson <[email protected]> Authored: Tue Aug 15 22:21:18 2017 -0700 Committer: Impala Public Jenkins <[email protected]> Committed: Thu Aug 24 02:40:28 2017 +0000 ---------------------------------------------------------------------- be/src/runtime/coordinator-backend-state.cc | 26 +++++++ be/src/runtime/coordinator-backend-state.h | 7 ++ be/src/runtime/coordinator.cc | 12 +++ be/src/runtime/coordinator.h | 19 +++-- be/src/service/impala-http-handler.cc | 22 ++++++ be/src/service/impala-http-handler.h | 5 ++ tests/webserver/test_web_pages.py | 85 ++++++++++++++------- www/query_backends.tmpl | 94 ++++++++++++++++++++++++ www/query_detail_tabs.tmpl | 1 + 9 files changed, 237 insertions(+), 34 deletions(-) ---------------------------------------------------------------------- http://git-wip-us.apache.org/repos/asf/incubator-impala/blob/ff5e9b6c/be/src/runtime/coordinator-backend-state.cc ---------------------------------------------------------------------- diff --git a/be/src/runtime/coordinator-backend-state.cc b/be/src/runtime/coordinator-backend-state.cc index 88f7f21..34e0671 100644 --- a/be/src/runtime/coordinator-backend-state.cc +++ b/be/src/runtime/coordinator-backend-state.cc @@ -47,6 +47,7 @@ #include "common/names.h" using namespace impala; +using namespace rapidjson; namespace accumulators = boost::accumulators; Coordinator::BackendState::BackendState( @@ -249,6 +250,8 @@ bool Coordinator::BackendState::ApplyExecStatusReport( ProgressUpdater* scan_range_progress) { lock_guard<SpinLock> l1(exec_summary->lock); lock_guard<mutex> l2(lock_); + last_report_time_ms_ = MonotonicMillis(); + // If this backend completed previously, don't apply the update. if (IsDone()) return false; for (const TFragmentInstanceExecStatus& instance_exec_status: @@ -561,3 +564,26 @@ void Coordinator::FragmentStats::AddExecStats() { avg_profile_->AddInfoString("execution rates", rates_label.str()); avg_profile_->AddInfoString("num instances", lexical_cast<string>(num_instances_)); } + +void Coordinator::BackendState::ToJson(Value* value, Document* document) { + lock_guard<mutex> l(lock_); + value->AddMember("num_instances", fragments_.size(), document->GetAllocator()); + value->AddMember("done", IsDone(), document->GetAllocator()); + value->AddMember( + "peak_mem_consumption", peak_consumption_, document->GetAllocator()); + + string host = TNetworkAddressToString(impalad_address()); + Value val(host.c_str(), document->GetAllocator()); + value->AddMember("host", val, document->GetAllocator()); + + value->AddMember("rpc_latency", rpc_latency(), document->GetAllocator()); + value->AddMember("time_since_last_heard_from", MonotonicMillis() - last_report_time_ms_, + document->GetAllocator()); + + string status_str = status_.ok() ? "OK" : status_.GetDetail(); + Value status_val(status_str.c_str(), document->GetAllocator()); + value->AddMember("status", status_val, document->GetAllocator()); + + value->AddMember( + "num_remaining_instances", num_remaining_instances_, document->GetAllocator()); +} http://git-wip-us.apache.org/repos/asf/incubator-impala/blob/ff5e9b6c/be/src/runtime/coordinator-backend-state.h ---------------------------------------------------------------------- diff --git a/be/src/runtime/coordinator-backend-state.h b/be/src/runtime/coordinator-backend-state.h index 0846119..ccc3618 100644 --- a/be/src/runtime/coordinator-backend-state.h +++ b/be/src/runtime/coordinator-backend-state.h @@ -113,6 +113,10 @@ class Coordinator::BackendState { /// debugging aid for backend deadlocks. static void LogFirstInProgress(std::vector<BackendState*> backend_states); + /// Serializes backend state to JSON by adding members to 'value', including total + /// number of instances, peak memory consumption, host and status amongst others. + void ToJson(rapidjson::Value* value, rapidjson::Document* doc); + private: /// Execution stats for a single fragment instance. /// Not thread-safe. @@ -213,6 +217,9 @@ class Coordinator::BackendState { /// peak_consumption() int64_t peak_consumption_; + /// Set in ApplyExecStatusReport(). Uses MonotonicMillis(). + int64_t last_report_time_ms_ = 0; + /// Fill in rpc_params based on state. Uses filter_routing_table to remove filters /// that weren't selected during its construction. void SetRpcParams(const DebugOptions& debug_options, http://git-wip-us.apache.org/repos/asf/incubator-impala/blob/ff5e9b6c/be/src/runtime/coordinator.cc ---------------------------------------------------------------------- diff --git a/be/src/runtime/coordinator.cc b/be/src/runtime/coordinator.cc index a9936ad..029e0bc 100644 --- a/be/src/runtime/coordinator.cc +++ b/be/src/runtime/coordinator.cc @@ -71,6 +71,7 @@ #include "common/names.h" using namespace apache::thrift; +using namespace rapidjson; using namespace strings; using boost::algorithm::iequals; using boost::algorithm::is_any_of; @@ -1221,4 +1222,15 @@ void Coordinator::GetTExecSummary(TExecSummary* exec_summary) { MemTracker* Coordinator::query_mem_tracker() const { return query_state()->query_mem_tracker(); } + +void Coordinator::BackendsToJson(Document* doc) { + lock_guard<mutex> l(lock_); + Value states(kArrayType); + for (BackendState* state : backend_states_) { + Value val(kObjectType); + state->ToJson(&val, doc); + states.PushBack(val, doc->GetAllocator()); + } + doc->AddMember("backend_states", states, doc->GetAllocator()); +} } http://git-wip-us.apache.org/repos/asf/incubator-impala/blob/ff5e9b6c/be/src/runtime/coordinator.h ---------------------------------------------------------------------- diff --git a/be/src/runtime/coordinator.h b/be/src/runtime/coordinator.h index 03d03df..4edef88 100644 --- a/be/src/runtime/coordinator.h +++ b/be/src/runtime/coordinator.h @@ -18,20 +18,21 @@ #ifndef IMPALA_RUNTIME_COORDINATOR_H #define IMPALA_RUNTIME_COORDINATOR_H -#include <vector> #include <string> -#include <boost/scoped_ptr.hpp> +#include <vector> #include <boost/accumulators/accumulators.hpp> -#include <boost/accumulators/statistics/stats.hpp> -#include <boost/accumulators/statistics/min.hpp> +#include <boost/accumulators/statistics/max.hpp> #include <boost/accumulators/statistics/mean.hpp> #include <boost/accumulators/statistics/median.hpp> -#include <boost/accumulators/statistics/max.hpp> +#include <boost/accumulators/statistics/min.hpp> +#include <boost/accumulators/statistics/stats.hpp> #include <boost/accumulators/statistics/variance.hpp> +#include <boost/scoped_ptr.hpp> +#include <boost/thread/condition_variable.hpp> +#include <boost/thread/mutex.hpp> #include <boost/unordered_map.hpp> #include <boost/unordered_set.hpp> -#include <boost/thread/mutex.hpp> -#include <boost/thread/condition_variable.hpp> +#include <rapidjson/document.h> #include "common/global-types.h" #include "common/hdfs.h" @@ -186,6 +187,10 @@ class Coordinator { // NOLINT: The member variables could be re-ordered to save /// filter to fragment instances. void UpdateFilter(const TUpdateFilterParams& params); + /// Adds to 'document' a serialized array of all backends in a member named + /// 'backend_states'. + void BackendsToJson(rapidjson::Document* document); + private: class BackendState; struct FilterTarget; http://git-wip-us.apache.org/repos/asf/incubator-impala/blob/ff5e9b6c/be/src/service/impala-http-handler.cc ---------------------------------------------------------------------- diff --git a/be/src/service/impala-http-handler.cc b/be/src/service/impala-http-handler.cc index 79903b4..e93aacf 100644 --- a/be/src/service/impala-http-handler.cc +++ b/be/src/service/impala-http-handler.cc @@ -101,6 +101,9 @@ void ImpalaHttpHandler::RegisterHandlers(Webserver* webserver) { webserver->RegisterUrlCallback("/query_memory", "query_memory.tmpl", MakeCallback(this, &ImpalaHttpHandler::QueryMemoryHandler), false); + webserver->RegisterUrlCallback("/query_backends", "query_backends.tmpl", + MakeCallback(this, &ImpalaHttpHandler::QueryBackendsHandler), false); + webserver->RegisterUrlCallback("/cancel_query", "common-pre.tmpl", MakeCallback(this, &ImpalaHttpHandler::CancelQueryHandler), false); @@ -691,6 +694,25 @@ void PlanToJson(const vector<TPlanFragment>& fragments, const TExecSummary& summ } +void ImpalaHttpHandler::QueryBackendsHandler( + const Webserver::ArgumentMap& args, Document* document) { + TUniqueId query_id; + Status status = ParseIdFromArguments(args, &query_id, "query_id"); + Value query_id_val(PrintId(query_id).c_str(), document->GetAllocator()); + document->AddMember("query_id", query_id_val, document->GetAllocator()); + if (!status.ok()) { + // Redact the error message, it may contain part or all of the query. + Value json_error(RedactCopy(status.GetDetail()).c_str(), document->GetAllocator()); + document->AddMember("error", json_error, document->GetAllocator()); + return; + } + + shared_ptr<ClientRequestState> request_state = server_->GetClientRequestState(query_id); + if (request_state.get() == nullptr || request_state->coord() == nullptr) return; + + request_state->coord()->BackendsToJson(document); +} + void ImpalaHttpHandler::QuerySummaryHandler(bool include_json_plan, bool include_summary, const Webserver::ArgumentMap& args, Document* document) { TUniqueId query_id; http://git-wip-us.apache.org/repos/asf/incubator-impala/blob/ff5e9b6c/be/src/service/impala-http-handler.h ---------------------------------------------------------------------- diff --git a/be/src/service/impala-http-handler.h b/be/src/service/impala-http-handler.h index 485f6db..8ad84bd 100644 --- a/be/src/service/impala-http-handler.h +++ b/be/src/service/impala-http-handler.h @@ -91,6 +91,11 @@ class ImpalaHttpHandler { void QuerySummaryHandler(bool include_plan_json, bool include_summary, const Webserver::ArgumentMap& args, rapidjson::Document* document); + /// If 'args' contains a query id, serializes all backend states for that query to + /// 'document'. + void QueryBackendsHandler( + const Webserver::ArgumentMap& args, rapidjson::Document* document); + /// Cancels an in-flight query and writes the result to 'contents'. void CancelQueryHandler(const Webserver::ArgumentMap& args, rapidjson::Document* document); http://git-wip-us.apache.org/repos/asf/incubator-impala/blob/ff5e9b6c/tests/webserver/test_web_pages.py ---------------------------------------------------------------------- diff --git a/tests/webserver/test_web_pages.py b/tests/webserver/test_web_pages.py index 4a2d872..2586399 100644 --- a/tests/webserver/test_web_pages.py +++ b/tests/webserver/test_web_pages.py @@ -17,6 +17,7 @@ from tests.common.impala_cluster import ImpalaCluster from tests.common.impala_test_suite import ImpalaTestSuite +import json import requests class TestWebPage(ImpalaTestSuite): @@ -28,6 +29,7 @@ class TestWebPage(ImpalaTestSuite): RESET_GLOG_LOGLEVEL_URL = "http://localhost:{0}/reset_glog_level" CATALOG_URL = "http://localhost:{0}/catalog" CATALOG_OBJECT_URL = "http://localhost:{0}/catalog_object" + QUERY_BACKENDS_URL = "http://localhost:{0}/query_backends" # log4j changes do not apply to the statestore since it doesn't # have an embedded JVM. So we make two sets of ports to test the # log level endpoints, one without the statestore port and the @@ -54,89 +56,95 @@ class TestWebPage(ImpalaTestSuite): result = impalad.service.read_debug_webpage("query_profile_encoded?query_id=123") assert result.startswith("Could not obtain runtime profile: Query id") - def get_and_check_status(self, url, string_to_search = "", without_ss = True): + def get_and_check_status(self, url, string_to_search = "", ports_to_test = None): """Helper method that polls a given url and asserts the return code is ok and - the response contains the input string. 'without_ss', when true, excludes the - statestore endpoint of the url. Should be applied only for log4j logging changes.""" - ports_to_test = self.TEST_PORTS_WITHOUT_SS if without_ss else self.TEST_PORTS_WITH_SS + the response contains the input string.""" + if ports_to_test is None: + ports_to_test = self.TEST_PORTS_WITH_SS for port in ports_to_test: input_url = url.format(port) response = requests.get(input_url) assert response.status_code == requests.codes.ok\ and string_to_search in response.text, "Offending url: " + input_url + return response.text + + def get_and_check_status_jvm(self, url, string_to_search = ""): + """Calls get_and_check_status() for impalad and catalogd only""" + return self.get_and_check_status(url, string_to_search, + ports_to_test=self.TEST_PORTS_WITHOUT_SS) def test_log_level(self): """Test that the /log_level page outputs are as expected and work well on basic and malformed inputs. This however does not test that the log level changes are actually in effect.""" # Check that the log_level end points are accessible. - self.get_and_check_status(self.GET_JAVA_LOGLEVEL_URL) - self.get_and_check_status(self.SET_JAVA_LOGLEVEL_URL) - self.get_and_check_status(self.RESET_JAVA_LOGLEVEL_URL) - self.get_and_check_status(self.SET_GLOG_LOGLEVEL_URL, without_ss=False) - self.get_and_check_status(self.RESET_GLOG_LOGLEVEL_URL, without_ss=False) + self.get_and_check_status_jvm(self.GET_JAVA_LOGLEVEL_URL) + self.get_and_check_status_jvm(self.SET_JAVA_LOGLEVEL_URL) + self.get_and_check_status_jvm(self.RESET_JAVA_LOGLEVEL_URL) + self.get_and_check_status(self.SET_GLOG_LOGLEVEL_URL) + self.get_and_check_status(self.RESET_GLOG_LOGLEVEL_URL) # Try getting log level of a class. get_loglevel_url = (self.GET_JAVA_LOGLEVEL_URL + "?class" + "=org.apache.impala.catalog.HdfsTable") - self.get_and_check_status(get_loglevel_url, "DEBUG") + self.get_and_check_status_jvm(get_loglevel_url, "DEBUG") # Set the log level of a class to TRACE and confirm the setting is in place set_loglevel_url = (self.SET_JAVA_LOGLEVEL_URL + "?class" + "=org.apache.impala.catalog.HdfsTable&level=trace") - self.get_and_check_status(set_loglevel_url, "Effective log level: TRACE") + self.get_and_check_status_jvm(set_loglevel_url, "Effective log level: TRACE") get_loglevel_url = (self.GET_JAVA_LOGLEVEL_URL + "?class" + "=org.apache.impala.catalog.HdfsTable") - self.get_and_check_status(get_loglevel_url, "TRACE") + self.get_and_check_status_jvm(get_loglevel_url, "TRACE") # Check the log level of a different class and confirm it is still DEBUG get_loglevel_url = (self.GET_JAVA_LOGLEVEL_URL + "?class" + "=org.apache.impala.catalog.HdfsPartition") - self.get_and_check_status(get_loglevel_url, "DEBUG") + self.get_and_check_status_jvm(get_loglevel_url, "DEBUG") # Reset Java logging levels and check the logging level of the class again - self.get_and_check_status(self.RESET_JAVA_LOGLEVEL_URL, "Java log levels reset.") + self.get_and_check_status_jvm(self.RESET_JAVA_LOGLEVEL_URL, "Java log levels reset.") get_loglevel_url = (self.GET_JAVA_LOGLEVEL_URL + "?class" + "=org.apache.impala.catalog.HdfsTable") - self.get_and_check_status(get_loglevel_url, "DEBUG") + self.get_and_check_status_jvm(get_loglevel_url, "DEBUG") # Set a new glog level and make sure the setting has been applied. set_glog_url = (self.SET_GLOG_LOGLEVEL_URL + "?glog=3") - self.get_and_check_status(set_glog_url, "v set to 3", False) + self.get_and_check_status(set_glog_url, "v set to 3") # Try resetting the glog logging defaults again. - self.get_and_check_status( self.RESET_GLOG_LOGLEVEL_URL, "v set to ", False) + self.get_and_check_status( self.RESET_GLOG_LOGLEVEL_URL, "v set to ") # Try to get the log level of an empty class input get_loglevel_url = (self.GET_JAVA_LOGLEVEL_URL + "?class=") - self.get_and_check_status(get_loglevel_url, without_ss=True) + self.get_and_check_status_jvm(get_loglevel_url) # Same as above, for set log level request set_loglevel_url = (self.SET_JAVA_LOGLEVEL_URL + "?class=") - self.get_and_check_status(get_loglevel_url, without_ss=True) + self.get_and_check_status_jvm(get_loglevel_url) # Empty input for setting a glog level request set_glog_url = (self.SET_GLOG_LOGLEVEL_URL + "?glog=") - self.get_and_check_status(set_glog_url, without_ss=False) + self.get_and_check_status(set_glog_url) # Try setting a non-existent log level on a valid class. In such cases, # log4j automatically sets it as DEBUG. This is the behavior of # Level.toLevel() method. set_loglevel_url = (self.SET_JAVA_LOGLEVEL_URL + "?class" + "=org.apache.impala.catalog.HdfsTable&level=foo&") - self.get_and_check_status(set_loglevel_url, "Effective log level: DEBUG") + self.get_and_check_status_jvm(set_loglevel_url, "Effective log level: DEBUG") # Try setting an invalid glog level. set_glog_url = self.SET_GLOG_LOGLEVEL_URL + "?glog=foo" - self.get_and_check_status(set_glog_url, "Bad glog level input", False) + self.get_and_check_status(set_glog_url, "Bad glog level input") # Try a non-existent endpoint on log_level URL. bad_loglevel_url = self.SET_GLOG_LOGLEVEL_URL + "?badurl=foo" - self.get_and_check_status(bad_loglevel_url, without_ss=False) + self.get_and_check_status(bad_loglevel_url) def test_catalog(self): """Tests the /catalog and /catalog_object endpoints.""" - self.get_and_check_status(self.CATALOG_URL, "functional", without_ss=True) - self.get_and_check_status(self.CATALOG_URL, "alltypes", without_ss=True) + self.get_and_check_status_jvm(self.CATALOG_URL, "functional") + self.get_and_check_status_jvm(self.CATALOG_URL, "alltypes") # IMPALA-5028: Test toThrift() of a partitioned table via the WebUI code path. self.__test_catalog_object("functional", "alltypes") self.__test_catalog_object("functional_parquet", "alltypes") @@ -149,8 +157,31 @@ class TestWebPage(ImpalaTestSuite): self.client.execute("invalidate metadata %s.%s" % (db_name, tbl_name)) self.get_and_check_status(self.CATALOG_OBJECT_URL + "?object_type=TABLE&object_name=%s.%s" % (db_name, tbl_name), tbl_name, - without_ss=True) + ports_to_test=self.TEST_PORTS_WITHOUT_SS) self.client.execute("select count(*) from %s.%s" % (db_name, tbl_name)) self.get_and_check_status(self.CATALOG_OBJECT_URL + "?object_type=TABLE&object_name=%s.%s" % (db_name, tbl_name), tbl_name, - without_ss=True) + ports_to_test=self.TEST_PORTS_WITHOUT_SS) + + def test_query_details(self, unique_database): + """Test that /query_backends returns the list of backend states for DML or queries; + nothing for DDL statements""" + CROSS_JOIN = ("select count(*) from functional.alltypes a " + "CROSS JOIN functional.alltypes b CROSS JOIN functional.alltypes c") + for q in [CROSS_JOIN, + "CREATE TABLE {0}.foo AS {1}".format(unique_database, CROSS_JOIN), + "DESCRIBE functional.alltypes"]: + query_handle = self.client.execute_async(q) + try: + response = self.get_and_check_status( + self.QUERY_BACKENDS_URL + "?query_id=%s&json" % query_handle.get_handle().id, + ports_to_test=[25000]) + + response_json = json.loads(response) + + if "DESCRIBE" not in q: + assert len(response_json['backend_states']) > 0 + else: + assert 'backend_states' not in response_json + finally: + self.client.cancel(query_handle) http://git-wip-us.apache.org/repos/asf/incubator-impala/blob/ff5e9b6c/www/query_backends.tmpl ---------------------------------------------------------------------- diff --git a/www/query_backends.tmpl b/www/query_backends.tmpl new file mode 100644 index 0000000..07d1b57 --- /dev/null +++ b/www/query_backends.tmpl @@ -0,0 +1,94 @@ +<!-- +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +--> + +{{> www/common-header.tmpl }} +{{> www/query_detail_tabs.tmpl }} +<br/> +{{?backend_states}} +<div> + <label> + <input type="checkbox" checked="true" id="toggle" onClick="toggleRefresh()"/> + <span id="refresh_on">Auto-refresh on</span> + </label> Last updated: <span id="last-updated"></span> +</div> + +<br/> +<table id="backends" class='table table-hover table-bordered'> + <thead> + <tr> + <th>Host</th> + <th>Num. instances</th> + <th>Num. remaining instances</th> + <th>Done</th> + <th>Peak mem. consumption</th> + <th>Time since last report (ms)</th> + </tr> + </thead> + <tbody> + + </tbody> +</table> + +<script> +document.getElementById("backends-tab").className = "active"; + +var intervalId = 0; +var table = null; +var refresh = function () { + table.ajax.reload(); + document.getElementById("last-updated").textContent = new Date(); +}; + +$(document).ready(function() { + table = $('#backends').DataTable({ + ajax: { url: "/query_backends?query_id={{query_id}}&json", + dataSrc: "backend_states", + }, + "columns": [ {data: 'host'}, + {data: 'num_instances'}, + {data: 'num_remaining_instances'}, + {data: 'done'}, + {data: 'peak_mem_consumption'}, + {data: 'time_since_last_heard_from'}], + "order": [[ 0, "desc" ]], + "pageLength": 100 + }); + intervalId = setInterval( refresh, 1000 ); +}); + +function toggleRefresh() { + if (document.getElementById("toggle").checked == true) { + intervalId = setInterval(refresh, 1000); + document.getElementById("refresh_on").textContent = "Auto-refresh on"; + } else { + clearInterval(intervalId); + document.getElementById("refresh_on").textContent = "Auto-refresh off"; + } +} + +</script> +{{/backend_states}} + +{{^backend_states}} +<div class="alert alert-info" role="alert"> +Query <strong>{{query_id}}</strong> has completed, or has no backends. +</div> +{{/backend_states}} + +{{> www/common-footer.tmpl }} http://git-wip-us.apache.org/repos/asf/incubator-impala/blob/ff5e9b6c/www/query_detail_tabs.tmpl ---------------------------------------------------------------------- diff --git a/www/query_detail_tabs.tmpl b/www/query_detail_tabs.tmpl index 64781f6..0318761 100644 --- a/www/query_detail_tabs.tmpl +++ b/www/query_detail_tabs.tmpl @@ -27,4 +27,5 @@ under the License. <li id="summary-tab" role="presentation"><a href="/query_summary?query_id={{query_id}}">Summary</a></li> <li id="profile-tab" role="presentation"><a href="/query_profile?query_id={{query_id}}">Profile</a></li> <li id="memory-tab" role="presentation"><a href="/query_memory?query_id={{query_id}}">Memory</a></li> + <li id="backends-tab" role="presentation"><a href="/query_backends?query_id={{query_id}}">Backends</a></li> </ul>
