Skip to content

Commit 45356c4

Browse files
authored
Merge pull request #5310 from rmosolgo/limiter-dashboard
Migrate limiter dashboard
2 parents 406a525 + eef1b82 commit 45356c4

File tree

20 files changed

+438
-147
lines changed

20 files changed

+438
-147
lines changed

gemfiles/rails_master.gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,5 @@ gemspec path: "../"
2727

2828
if Bundler.settings["GEMS__GRAPHQL__PRO"]
2929
gem "graphql-pro", source: "https://gems.graphql.pro"
30+
gem "graphql-enterprise", source: "https://gems.graphql.pro"
3031
end

lib/graphql/dashboard.rb

Lines changed: 15 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,18 @@ class Dashboard < Rails::Engine
3737
routes.draw do
3838
root "landings#show"
3939
resources :statics, only: :show, constraints: { id: /[0-9A-Za-z\-.]+/ }
40-
delete "/traces/delete_all", to: "traces#delete_all", as: :traces_delete_all
41-
resources :traces, only: [:index, :show, :destroy]
40+
41+
namespace :detailed_traces do
42+
resources :traces, only: [:index, :show, :destroy] do
43+
collection do
44+
delete :delete_all, to: "traces#delete_all", as: :delete_all
45+
end
46+
end
47+
end
48+
49+
namespace :limiters do
50+
resources :limiters, only: [:show, :update], param: :name
51+
end
4252

4353
namespace :operation_store do
4454
resources :clients, param: :name do
@@ -106,32 +116,6 @@ def show
106116
end
107117
end
108118

109-
class TracesController < ApplicationController
110-
def index
111-
@detailed_trace_installed = !!schema_class.detailed_trace
112-
if @detailed_trace_installed
113-
@last = params[:last]&.to_i || 50
114-
@before = params[:before]&.to_i
115-
@traces = schema_class.detailed_trace.traces(last: @last, before: @before)
116-
end
117-
end
118-
119-
def show
120-
trace = schema_class.detailed_trace.find_trace(params[:id].to_i)
121-
send_data(trace.trace_data)
122-
end
123-
124-
def destroy
125-
schema_class.detailed_trace.delete_trace(params[:id])
126-
head :no_content
127-
end
128-
129-
def delete_all
130-
schema_class.detailed_trace.delete_all_traces
131-
head :no_content
132-
end
133-
end
134-
135119
class StaticsController < ApplicationController
136120
skip_after_action :verify_same_origin_request
137121
# Use an explicit list of files to avoid any chance of reading other files from disk
@@ -140,6 +124,7 @@ class StaticsController < ApplicationController
140124
[
141125
"icon.png",
142126
"header-icon.png",
127+
"charts.min.css",
143128
"dashboard.css",
144129
"dashboard.js",
145130
"bootstrap-5.3.3.min.css",
@@ -160,6 +145,8 @@ def show
160145
end
161146
end
162147

148+
require 'graphql/dashboard/detailed_traces'
149+
require 'graphql/dashboard/limiters'
163150
require 'graphql/dashboard/operation_store'
164151
require 'graphql/dashboard/subscriptions'
165152

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# frozen_string_literal: true
2+
require_relative "./installable"
3+
module Graphql
4+
class Dashboard < Rails::Engine
5+
module DetailedTraces
6+
class TracesController < Graphql::Dashboard::ApplicationController
7+
include Installable
8+
9+
def index
10+
@last = params[:last]&.to_i || 50
11+
@before = params[:before]&.to_i
12+
@traces = schema_class.detailed_trace.traces(last: @last, before: @before)
13+
end
14+
15+
def show
16+
trace = schema_class.detailed_trace.find_trace(params[:id].to_i)
17+
send_data(trace.trace_data)
18+
end
19+
20+
def destroy
21+
schema_class.detailed_trace.delete_trace(params[:id])
22+
flash[:success] = "Trace deleted."
23+
head :no_content
24+
end
25+
26+
def delete_all
27+
schema_class.detailed_trace.delete_all_traces
28+
flash[:success] = "Deleted all traces."
29+
head :no_content
30+
end
31+
32+
private
33+
34+
def feature_installed?
35+
!!schema_class.detailed_trace
36+
end
37+
38+
INSTALLABLE_COMPONENT_HEADER_HTML = "Detailed traces aren't installed yet."
39+
INSTALLABLE_COMPONENT_MESSAGE_HTML = <<~HTML.html_safe
40+
GraphQL-Ruby can instrument production traffic and save tracing artifacts here for later review.
41+
<br>
42+
Read more in <a href="https://graphql-ruby.org/queries/tracing#detailed-traces">the detailed tracing docs</a>.
43+
HTML
44+
end
45+
end
46+
end
47+
end

lib/graphql/dashboard/installable.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ def feature_installed?
1212

1313
def check_installed
1414
if !feature_installed?
15-
dashboard_module = self.class.name.split("::")[-2]
16-
render "graphql/dashboard/#{dashboard_module.underscore}/not_installed"
15+
@component_header_html = self.class::INSTALLABLE_COMPONENT_HEADER_HTML
16+
@component_message_html = self.class::INSTALLABLE_COMPONENT_MESSAGE_HTML
17+
render "graphql/dashboard/not_installed"
1718
end
1819
end
1920
end

lib/graphql/dashboard/limiters.rb

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# frozen_string_literal: true
2+
require_relative "./installable"
3+
module Graphql
4+
class Dashboard < Rails::Engine
5+
module Limiters
6+
class LimitersController < Dashboard::ApplicationController
7+
include Installable
8+
FALLBACK_CSP_NONCE_GENERATOR = ->(_req) { SecureRandom.hex(32) }
9+
10+
def show
11+
name = params[:name]
12+
@title = case name
13+
when "runtime"
14+
"Runtime Limiter"
15+
when "active_operations"
16+
"Active Operation Limiter"
17+
when "mutations"
18+
"Mutation Limiter"
19+
else
20+
raise ArgumentError, "Unknown limiter name: #{name}"
21+
end
22+
23+
limiter = limiter_for(name)
24+
if limiter.nil?
25+
@install_path = "http://graphql-ruby.org/limiters/#{name}"
26+
else
27+
@chart_mode = params[:chart] || "day"
28+
@current_soft = limiter.soft_limit_enabled?
29+
@histogram = limiter.dashboard_histogram(@chart_mode)
30+
31+
# These configs may have already been defined by the application; provide overrides here if not.
32+
request.content_security_policy_nonce_generator ||= FALLBACK_CSP_NONCE_GENERATOR
33+
nonce_dirs = request.content_security_policy_nonce_directives || []
34+
if !nonce_dirs.include?("style-src")
35+
nonce_dirs += ["style-src"]
36+
request.content_security_policy_nonce_directives = nonce_dirs
37+
end
38+
@csp_nonce = request.content_security_policy_nonce
39+
end
40+
end
41+
42+
def update
43+
name = params[:name]
44+
limiter = limiter_for(name)
45+
if limiter
46+
limiter.toggle_soft_limit
47+
flash[:success] = if limiter.soft_limit_enabled?
48+
"Enabled soft limiting -- over-limit traffic will be logged but not rejected."
49+
else
50+
"Disabled soft limiting -- over-limit traffic will be rejected."
51+
end
52+
else
53+
flash[:warning] = "No limiter configured for #{name.inspect}"
54+
end
55+
56+
redirect_to graphql_dashboard.limiters_limiter_path(name, chart: params[:chart])
57+
end
58+
59+
private
60+
61+
def limiter_for(name)
62+
case name
63+
when "runtime"
64+
schema_class.enterprise_runtime_limiter
65+
when "active_operations"
66+
schema_class.enterprise_active_operation_limiter
67+
when "mutations"
68+
schema_class.enterprise_mutation_limiter
69+
else
70+
raise ArgumentError, "Unknown limiter: #{name}"
71+
end
72+
end
73+
74+
def feature_installed?
75+
defined?(GraphQL::Enterprise::Limiter) &&
76+
(
77+
schema_class.enterprise_active_operation_limiter ||
78+
schema_class.enterprise_runtime_limiter ||
79+
(schema_class.respond_to?(:enterprise_mutation_limiter) && schema_class.enterprise_mutation_limiter)
80+
)
81+
end
82+
83+
84+
INSTALLABLE_COMPONENT_HEADER_HTML = "Rate limiters aren't installed on this schema yet."
85+
INSTALLABLE_COMPONENT_MESSAGE_HTML = <<-HTML.html_safe
86+
Check out the docs to get started with GraphQL-Enterprise's
87+
<a href="https://graphql-ruby.org/limiters/runtime.html">runtime limiter</a> or
88+
<a href="https://graphql-ruby.org/limiters/active_operations.html">active operation limiter</a>.
89+
HTML
90+
end
91+
end
92+
end
93+
end

lib/graphql/dashboard/operation_store.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ class BaseController < Dashboard::ApplicationController
1111
def feature_installed?
1212
schema_class.respond_to?(:operation_store) && schema_class.operation_store.is_a?(GraphQL::Pro::OperationStore)
1313
end
14+
15+
INSTALLABLE_COMPONENT_HEADER_HTML = "<code>OperationStore</code> isn't installed for this schema yet.".html_safe
16+
INSTALLABLE_COMPONENT_MESSAGE_HTML = <<-HTML.html_safe
17+
Learn more about improving performance and security with stored operations
18+
in the <a href="https://graphql-ruby.org/operation_store/overview.html"><code>OperationStore</code> docs</a>.
19+
HTML
1420
end
1521

1622
class ClientsController < BaseController

lib/graphql/dashboard/statics/charts.min.css

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/graphql/dashboard/statics/dashboard.css

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,24 @@
77
width: 100%;
88
white-space: pre-wrap;
99
}
10+
11+
#limiter-histogram .column {
12+
max-height: 300px;
13+
}
14+
15+
#limiter-histogram .column td {
16+
--color-1: var(--bs-gray);
17+
--color-2: var(--bs-red);
18+
opacity: 0.6;
19+
}
20+
21+
#limiter-histogram .column td:hover {
22+
opacity: 1;
23+
}
24+
25+
#limiter-histogram .column tbody tr th[scope=row] {
26+
width: 150px;
27+
transform: rotate(-75deg) translateY(55px) translateX(-50px);
28+
left: auto;
29+
--labels-align-inline: end;
30+
}

lib/graphql/dashboard/statics/dashboard.js

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -55,17 +55,34 @@ async function openOnPerfetto(operationName, tracePath) {
5555
function getCsrfToken() {
5656
return document.querySelector("meta[name='csrf-token']").content
5757
}
58-
async function deleteTrace(tracePath, event) {
58+
59+
function deleteTrace(tracePath) {
5960
if (confirm("Are you sure you want to permanently delete this trace?")) {
60-
var response = await fetch(tracePath, { method: "DELETE", headers: {
61+
fetch(tracePath, { method: "DELETE", headers: {
6162
"X-CSRF-Token": getCsrfToken()
62-
} })
63-
if (response.ok) {
64-
var row = event.target.closest("tr")
65-
row.remove()
66-
} else {
67-
console.error("Delete request failed for", tracePath, response)
68-
}
63+
} }).then(function(_response) {
64+
window.location.reload()
65+
})
66+
}
67+
}
68+
69+
function deleteAllTraces(path) {
70+
if (confirm("Are you sure you want to permanently delete ALL traces?")) {
71+
fetch(path, { method: "DELETE", headers: {
72+
"X-CSRF-Token": getCsrfToken()
73+
} }).then(function(_response) {
74+
window.location.reload()
75+
})
76+
}
77+
}
78+
79+
function deleteAllSubscriptions(path) {
80+
if (confirm("This will:\n\n- Remove all subscriptions from the database\n- Stop updates to all current subscribers\n\nAre you sure?")) {
81+
fetch(path, { method: "POST", headers: {
82+
"X-CSRF-Token": getCsrfToken()
83+
} }).then(function(_response) {
84+
window.location.reload()
85+
})
6986
}
7087
}
7188

@@ -114,6 +131,10 @@ document.addEventListener("click", function(event) {
114131
openOnPerfetto(dataset.perfettoOpen, dataset.perfettoPath)
115132
} else if (dataset.perfettoDelete) {
116133
deleteTrace(dataset.perfettoDelete, event)
134+
} else if (dataset.perfettoDeleteAll) {
135+
deleteAllTraces(dataset.perfettoDeleteAll)
136+
} else if (dataset.subscriptionsDeleteAll) {
137+
deleteAllSubscriptions(dataset.subscriptionsDeleteAll)
117138
} else if (event.target.id == "themeToggle") {
118139
toggleTheme()
119140
} else if (dataset.archiveClient || dataset.archiveAll) {

lib/graphql/dashboard/subscriptions.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ class BaseController < Graphql::Dashboard::ApplicationController
88
def feature_installed?
99
schema_class.subscriptions.is_a?(GraphQL::Pro::Subscriptions)
1010
end
11+
12+
INSTALLABLE_COMPONENT_HEADER_HTML = "GraphQL-Pro Subscriptions aren't installed on this schema yet.".html_safe
13+
INSTALLABLE_COMPONENT_MESSAGE_HTML = <<-HTML.html_safe
14+
Deliver live updates over
15+
<a href="https://graphql-ruby.org/subscriptions/pusher_implementation.html">Pusher</a> or
16+
<a href="https://graphql-ruby.org/subscriptions/ably_implementation.html"> Ably</a>
17+
with GraphQL-Pro's subscription integrations.
18+
HTML
1119
end
1220

1321
class TopicsController < BaseController
@@ -80,7 +88,7 @@ def show
8088
def clear_all
8189
schema_class.subscriptions.clear
8290
flash[:success] = "All subscription data cleared."
83-
redirect_to graphql_dashboard.subscriptions_topics_path
91+
head :no_content
8492
end
8593
end
8694
end
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<% content_for(:title, "Profiles") %>
2+
<div class="row justify-content-between mt-3">
3+
<div class="col-auto">
4+
<h3>Detailed Profiles</h3>
5+
</div>
6+
<div class="col-auto">
7+
<%= button_tag "Delete All Traces", class: "btn btn-sm btn-outline-danger", data: { perfetto_delete_all: graphql_dashboard.delete_all_detailed_traces_traces_path } %>
8+
</div>
9+
</div>
10+
11+
<div class="row">
12+
<div class="col">
13+
<table class="table table-striped">
14+
<thead>
15+
<tr>
16+
<th>Operation</th>
17+
<th>Duration (ms) </th>
18+
<th>Timestamp</th>
19+
<th>Open in Perfetto UI</th>
20+
</tr>
21+
</thead>
22+
<tbody>
23+
<% if @traces.empty? %>
24+
<tr>
25+
<td colspan="4" class="text-center">
26+
<em>No traces saved yet. Read about saving traces <%= link_to "in the docs", "https://graphql-ruby.org/queries/tracing#detailed-profiles" %>.</em>
27+
</td>
28+
</tr>
29+
<% end %>
30+
<% @traces.each do |trace| %>
31+
<tr>
32+
<td><%= trace.operation_name %></td>
33+
<td><%= trace.duration_ms.round(2) %></td>
34+
<td><%= Time.at(trace.begin_ms / 1000.0).strftime("%Y-%m-%d %H:%M:%S.%L") %></td>
35+
<td><%= link_to "View ↗", "#", data: { perfetto_open: trace.operation_name, perfetto_path: graphql_dashboard.detailed_traces_trace_path(trace.id) } %></td>
36+
<td><%= link_to "Delete", "#", data: { perfetto_delete: graphql_dashboard.detailed_traces_trace_path(trace.id) }, class: "text-danger" %></td>
37+
</tr>
38+
<% end %>
39+
</tbody>
40+
</table>
41+
<% if @last && @traces.size >= @last %>
42+
<%= link_to("Previous >", graphql_dashboard.detailed_traces_traces_path(last: @last, before: @traces.last.begin_ms), class: "btn btn-outline-primary") %>
43+
<% end %>
44+
</div>
45+
</div>

0 commit comments

Comments
 (0)