Skip to content

Commit 31fdfe7

Browse files
authored
Merge pull request #5299 from rmosolgo/more-dashboard
Add OperationStore UI to GraphQL::Dashboard
2 parents e364d44 + cad2e75 commit 31fdfe7

24 files changed

Lines changed: 855 additions & 7 deletions

File tree

.github/workflows/ci.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,22 @@ jobs:
1515
system_tests:
1616
runs-on: ubuntu-latest
1717
steps:
18+
- uses: shogo82148/actions-setup-redis@v1
19+
with:
20+
redis-version: "7.x"
21+
- run: redis-cli ping
1822
- uses: actions/checkout@v4
1923
- uses: ruby/setup-ruby@v1
2024
with:
2125
ruby-version: 3.4
2226
bundler-cache: true
2327
env:
28+
BUNDLE_GEMS__GRAPHQL__PRO: ${{ secrets.BUNDLE_GEMS__GRAPHQL__PRO }}
2429
BUNDLE_GEMFILE: gemfiles/rails_master.gemfile
2530
- run: bin/rails test:all
2631
working-directory: ./spec/dummy
2732
env:
33+
BUNDLE_GEMS__GRAPHQL__PRO: ${{ secrets.BUNDLE_GEMS__GRAPHQL__PRO }}
2834
BUNDLE_GEMFILE: ../../gemfiles/rails_master.gemfile
2935
# Some coverage goals of these tests:
3036
# - Test once without Rails at all

gemfiles/rails_master.gemfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,7 @@ gem 'capybara'
2424
gem 'selenium-webdriver'
2525

2626
gemspec path: "../"
27+
28+
if Bundler.settings["GEMS__GRAPHQL__PRO"]
29+
gem "graphql-pro", source: "https://gems.graphql.pro"
30+
end

lib/graphql/dashboard.rb

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
# frozen_string_literal: true
22
require 'rails/engine'
3-
43
module Graphql
54
# `GraphQL::Dashboard` is a `Rails::Engine`-based dashboard for viewing metadata about your GraphQL schema.
65
#
@@ -40,6 +39,27 @@ class Dashboard < Rails::Engine
4039
resources :statics, only: :show, constraints: { id: /[0-9A-Za-z\-.]+/ }
4140
delete "/traces/delete_all", to: "traces#delete_all", as: :traces_delete_all
4241
resources :traces, only: [:index, :show, :destroy]
42+
43+
namespace :operation_store do
44+
resources :clients, param: :name do
45+
resources :operations, param: :digest, only: [:index] do
46+
collection do
47+
get :archived, to: "operations#index", archived_status: :archived, as: :archived
48+
post :archive, to: "operations#update", modification: :archive, as: :archive
49+
post :unarchive, to: "operations#update", modification: :unarchive, as: :unarchive
50+
end
51+
end
52+
end
53+
54+
resources :operations, param: :digest, only: [:index, :show] do
55+
collection do
56+
get :archived, to: "operations#index", archived_status: :archived, as: :archived
57+
post :archive, to: "operations#update", modification: :archive, as: :archive
58+
post :unarchive, to: "operations#update", modification: :unarchive, as: :unarchive
59+
end
60+
end
61+
resources :index_entries, only: [:index, :show], param: :name, constraints: { name: /[A-Za-z0-9_.]+/}
62+
end
4363
end
4464

4565
class ApplicationController < ActionController::Base
@@ -134,6 +154,8 @@ def show
134154
end
135155
end
136156

157+
require 'graphql/dashboard/operation_store'
158+
137159
# Rails expects the engine to be called `Graphql::Dashboard`,
138160
# but `GraphQL::Dashboard` is consistent with this gem's naming.
139161
# So define both constants to refer to the same class.
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
# frozen_string_literal: true
2+
module Graphql
3+
class Dashboard < Rails::Engine
4+
module OperationStore
5+
module CheckInstalled
6+
def self.included(child_module)
7+
child_module.before_action(:check_installed)
8+
end
9+
10+
def check_installed
11+
if !schema_class.respond_to?(:operation_store) || schema_class.operation_store.nil?
12+
render "graphql/dashboard/operation_store/not_installed"
13+
end
14+
end
15+
end
16+
class ClientsController < Dashboard::ApplicationController
17+
include CheckInstalled
18+
19+
def index
20+
@order_by = params[:order_by] || "name"
21+
@order_dir = params[:order_dir].presence || "asc"
22+
clients_page = schema_class.operation_store.all_clients(
23+
page: params[:page]&.to_i || 1,
24+
per_page: params[:per_page]&.to_i || 25,
25+
order_by: @order_by,
26+
order_dir: @order_dir,
27+
)
28+
29+
@clients_page = clients_page
30+
end
31+
32+
def new
33+
@client = init_client(secret: SecureRandom.hex(32))
34+
end
35+
36+
def create
37+
client_params = params.require(:client).permit(:name, :secret)
38+
schema_class.operation_store.upsert_client(client_params[:name], client_params[:secret])
39+
flash[:success] = "Created #{client_params[:name].inspect}"
40+
redirect_to graphql_dashboard.operation_store_clients_path
41+
end
42+
43+
def edit
44+
@client = schema_class.operation_store.get_client(params[:name])
45+
end
46+
47+
def update
48+
client_name = params[:name]
49+
client_secret = params.require(:client).permit(:secret)[:secret]
50+
schema_class.operation_store.upsert_client(client_name, client_secret)
51+
flash[:success] = "Updated #{client_name.inspect}"
52+
redirect_to graphql_dashboard.operation_store_clients_path
53+
end
54+
55+
def destroy
56+
client_name = params[:name]
57+
schema_class.operation_store.delete_client(client_name)
58+
flash[:success] = "Deleted #{client_name.inspect}"
59+
redirect_to graphql_dashboard.operation_store_clients_path
60+
end
61+
62+
private
63+
64+
def init_client(name: nil, secret: nil)
65+
GraphQL::Pro::OperationStore::ClientRecord.new(
66+
name: name,
67+
secret: secret,
68+
created_at: nil,
69+
operations_count: 0,
70+
archived_operations_count: 0,
71+
last_synced_at: nil,
72+
last_used_at: nil,
73+
)
74+
end
75+
end
76+
77+
class OperationsController < Dashboard::ApplicationController
78+
include CheckInstalled
79+
80+
def index
81+
@client_operations = client_name = params[:client_name]
82+
per_page = params[:per_page]&.to_i || 25
83+
page = params[:page]&.to_i || 1
84+
@is_archived = params[:archived_status] == :archived
85+
order_by = params[:order_by] || "name"
86+
order_dir = params[:order_dir]&.to_sym || :asc
87+
if @client_operations
88+
@operations_page = schema_class.operation_store.get_client_operations_by_client(
89+
client_name,
90+
page: page,
91+
per_page: per_page,
92+
is_archived: @is_archived,
93+
order_by: order_by,
94+
order_dir: order_dir,
95+
)
96+
opposite_archive_mode_count = schema_class.operation_store.get_client_operations_by_client(
97+
client_name,
98+
page: 1,
99+
per_page: 1,
100+
is_archived: !@is_archived,
101+
order_by: order_by,
102+
order_dir: order_dir,
103+
).total_count
104+
else
105+
@operations_page = schema_class.operation_store.all_operations(
106+
page: page,
107+
per_page: per_page,
108+
is_archived: @is_archived,
109+
order_by: order_by,
110+
order_dir: order_dir,
111+
)
112+
opposite_archive_mode_count = schema_class.operation_store.all_operations(
113+
page: 1,
114+
per_page: 1,
115+
is_archived: !@is_archived,
116+
order_by: order_by,
117+
order_dir: order_dir,
118+
).total_count
119+
end
120+
121+
if @is_archived
122+
@archived_operations_count = @operations_page.total_count
123+
@unarchived_operations_count = opposite_archive_mode_count
124+
else
125+
@archived_operations_count = opposite_archive_mode_count
126+
@unarchived_operations_count = @operations_page.total_count
127+
end
128+
end
129+
130+
def show
131+
digest = params[:digest]
132+
@operation = schema_class.operation_store.get_operation_by_digest(digest)
133+
if @operation
134+
# Parse & re-format the query
135+
document = GraphQL.parse(@operation.body)
136+
@graphql_source = document.to_query_string
137+
138+
@client_operations = schema_class.operation_store.get_client_operations_by_digest(digest)
139+
@entries = schema_class.operation_store.get_index_entries_by_digest(digest)
140+
end
141+
end
142+
143+
def update
144+
is_archived = case params[:modification]
145+
when :archive
146+
true
147+
when :unarchive
148+
false
149+
else
150+
raise ArgumentError, "Unexpected modification: #{params[:modification].inspect}"
151+
end
152+
153+
if (client_name = params[:client_name])
154+
operation_aliases = params[:operation_aliases]
155+
schema_class.operation_store.archive_client_operations(
156+
client_name: client_name,
157+
operation_aliases: operation_aliases,
158+
is_archived: is_archived
159+
)
160+
flash[:success] = "#{is_archived ? "Archived" : "Activated"} #{operation_aliases.size} #{"operation".pluralize(operation_aliases.size)}"
161+
else
162+
digests = params[:digests]
163+
schema_class.operation_store.archive_operations(
164+
digests: digests,
165+
is_archived: is_archived
166+
)
167+
flash[:success] = "#{is_archived ? "Archived" : "Activated"} #{digests.size} #{"operation".pluralize(digests.size)}"
168+
end
169+
head :no_content
170+
end
171+
end
172+
173+
class IndexEntriesController < Dashboard::ApplicationController
174+
def index
175+
@search_term = if request.params["q"] && request.params["q"].length > 0
176+
request.params["q"]
177+
else
178+
nil
179+
end
180+
181+
@index_entries_page = schema_class.operation_store.all_index_entries(
182+
search_term: @search_term,
183+
page: params[:page]&.to_i || 1,
184+
per_page: params[:per_page]&.to_i || 25,
185+
)
186+
end
187+
188+
def show
189+
name = params[:name]
190+
@entry = schema_class.operation_store.index.get_entry(name)
191+
@chain = schema_class.operation_store.index.index_entry_chain(name)
192+
@operations = schema_class.operation_store.get_operations_by_index_entry(name)
193+
end
194+
end
195+
end
196+
end
197+
end
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
11
#header-icon {
22
max-height: 2em;
33
}
4+
5+
.graphql-highlight {
6+
font-family:'Courier New', Courier, monospace;
7+
width: 100%;
8+
white-space: pre-wrap;
9+
}

lib/graphql/dashboard/statics/dashboard.js

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,13 @@ async function openOnPerfetto(operationName, tracePath) {
5252
}, 100)
5353
}
5454

55+
function getCsrfToken() {
56+
return document.querySelector("meta[name='csrf-token']").content
57+
}
5558
async function deleteTrace(tracePath, event) {
5659
if (confirm("Are you sure you want to permanently delete this trace?")) {
5760
var response = await fetch(tracePath, { method: "DELETE", headers: {
58-
"X-CSRF-Token": document.querySelector("meta[name='csrf-token']").content
61+
"X-CSRF-Token": getCsrfToken()
5962
} })
6063
if (response.ok) {
6164
var row = event.target.closest("tr")
@@ -66,6 +69,45 @@ async function deleteTrace(tracePath, event) {
6669
}
6770
}
6871

72+
function sendArchive(clientName) {
73+
var values = []
74+
document.querySelectorAll(".archive-check:checked").forEach(function(el) {
75+
values.push(el.value)
76+
})
77+
if (values.length == 0) {
78+
return
79+
}
80+
var mode = window.location.pathname.includes("/archived") ? "/unarchive" : "/archive"
81+
if (mode == "/archive") {
82+
if (!confirm("Are you sure you want to archive these operations? They won't be usable by clients while archived.")) {
83+
return
84+
}
85+
} else {
86+
if (!confirm("Are you sure you want to reactivate these operations? They'll be available to clients again.")) {
87+
return
88+
}
89+
}
90+
var url = window.location.pathname.replace("/archived", "")
91+
url += mode
92+
var data
93+
94+
if (clientName) {
95+
data = {
96+
operation_aliases: values
97+
}
98+
} else {
99+
data = {
100+
digests: values
101+
}
102+
}
103+
fetch(url, { method: "POST", body: JSON.stringify(data), headers: {
104+
"X-CSRF-Token": getCsrfToken(),
105+
"Content-Type": "application/json",
106+
}}).then(function(_response) {
107+
window.location.reload()
108+
})
109+
}
110+
69111
document.addEventListener("click", function(event) {
70112
var dataset = event.target.dataset
71113
if (dataset.perfettoOpen) {
@@ -74,5 +116,7 @@ document.addEventListener("click", function(event) {
74116
deleteTrace(dataset.perfettoDelete, event)
75117
} else if (event.target.id == "themeToggle") {
76118
toggleTheme()
119+
} else if (dataset.archiveClient || dataset.archiveAll) {
120+
sendArchive(dataset.archiveClient)
77121
}
78122
})
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<%= form_tag((@client.persisted? ? graphql_dashboard.operation_store_client_path(name: @client.name) : graphql_dashboard.operation_store_clients_path), method: (@client.persisted? ? "patch" : "post")) do %>
2+
<div class="row">
3+
<label class="col-2 col-form-label">Name</label>
4+
<div class="col">
5+
<%= text_field_tag "client[name]", @client.name, class: "form-control", disabled: @client.persisted? %>
6+
<div class="form-text">a unique identifier for this owner of persisted operations</div>
7+
</div>
8+
</div>
9+
<div class="row">
10+
<label class="col-2 col-form-label">Secret</label>
11+
<div class="col">
12+
<%= textarea_tag "client[secret]", @client.secret, class: "form-control" %>
13+
<div class="form-text">authentication credential for <code>sync</code> transactions</div>
14+
</div>
15+
</div>
16+
<div class="row">
17+
<div class="col-auto">
18+
<%= submit_tag "Save", class: "btn btn-outline-primary" %>
19+
</div>
20+
<div class="col-auto">
21+
<%= link_to "Back", graphql_dashboard.operation_store_clients_path, class: "btn btn-outline-secondary" %>
22+
</div>
23+
<% end %>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<% content_for(:title, "Edit #{@client.name}") %>
2+
<div class="row">
3+
<div class="col">
4+
<h1>Edit <%= @client.name %></h1>
5+
</div>
6+
<div>
7+
<%= render partial: "graphql/dashboard/operation_store/clients/form" %>
8+
9+
<hr class="mt-5"/>
10+
<div class="row mt-5">
11+
<div class="col">
12+
<div class="alert alert-danger">
13+
<h4>Delete <%= @client.name %></h4>
14+
<p>If you delete this client, it will no longer be able to use stored operations.</p>
15+
<p>There is no way to undo this action.</p>
16+
<%= form_tag(graphql_dashboard.operation_store_client_path(name: @client.name), method: "delete") do %>
17+
<%= submit_tag "Permanently Delete #{@client.name.inspect}", class: "btn btn-outline-danger" %>
18+
<% end %>
19+
</div>
20+
</div>
21+
</div>

0 commit comments

Comments
 (0)