wrap_it_ruby 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/assets/javascripts/wrap_it_ruby/interception.js +54 -31
- data/app/assets/stylesheets/wrap_it_ruby/application.css +71 -1
- data/app/controllers/wrap_it_ruby/menu_settings_controller.rb +64 -0
- data/app/controllers/wrap_it_ruby/proxy_controller.rb +2 -1
- data/app/helpers/wrap_it_ruby/menu_helper.rb +298 -22
- data/app/javascript/wrap_it_ruby/controllers/sortable_tree_controller.js +198 -0
- data/app/views/wrap_it_ruby/menu_settings/_tree.html.erb +1 -0
- data/app/views/wrap_it_ruby/menu_settings/index.html.erb +1 -0
- data/config/importmap.rb +7 -3
- data/config/routes.rb +7 -1
- data/lib/wrap_it_ruby/engine.rb +14 -13
- data/lib/wrap_it_ruby/middleware/proxy_middleware.rb +159 -62
- data/lib/wrap_it_ruby/middleware/root_relative_proxy_middleware.rb +10 -2
- data/lib/wrap_it_ruby/middleware/script_injection_middleware.rb +7 -1
- data/lib/wrap_it_ruby/middleware/websocket_proxy.rb +92 -0
- data/lib/wrap_it_ruby/version.rb +1 -1
- data/lib/wrap_it_ruby.rb +2 -2
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7f6ed92be744074125808b44a1aa65cf1dafd00be14c8190824904841d275b6d
|
|
4
|
+
data.tar.gz: 4d3efb23f4ffd7406fd786fd41d98f6f4cb0b2ba35c9e1fd5c43d6c7027771a3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c7964a50fad62192b45d0e2a238217a6be39c6142f95b8710f5ad61a3f5b3bf3c4c0346f47d898908cb9ed74282f452c29c9add1c32bbefc9c1f207d91b08b3d
|
|
7
|
+
data.tar.gz: '097445c1d52ed9248c6feff3e1606642c1087e8ae68be7a2d99cf175cecbbc6e0c0f87a70b503496a640c1cc8232a8bb100d0ad82eaac187954a726e89cb9bcb'
|
|
@@ -14,6 +14,13 @@
|
|
|
14
14
|
|
|
15
15
|
console.log('[interception]', 'loaded');
|
|
16
16
|
|
|
17
|
+
// Block service worker registration — proxied apps share the proxy
|
|
18
|
+
// origin so a SW registered by one app (e.g. code-server) would
|
|
19
|
+
// intercept requests for all proxied sites with stale cached paths.
|
|
20
|
+
if (navigator.serviceWorker) {
|
|
21
|
+
navigator.serviceWorker.register = () => Promise.resolve()
|
|
22
|
+
}
|
|
23
|
+
|
|
17
24
|
var _fetch = window.fetch;
|
|
18
25
|
var _XHR = window.XMLHttpRequest;
|
|
19
26
|
var _xhrOpen = _XHR.prototype.open;
|
|
@@ -92,15 +99,16 @@
|
|
|
92
99
|
const url = isReq ? input.url : String(input instanceof URL ? input.href : input)
|
|
93
100
|
|
|
94
101
|
const headers = new Headers(init.headers || (isReq ? input.headers : {}))
|
|
95
|
-
headers.set('x-proxy-host',
|
|
102
|
+
headers.set('x-proxy-host', PROXY_HOST)
|
|
96
103
|
|
|
97
104
|
const newInit = {
|
|
98
105
|
method: init.method || (isReq ? input.method : 'GET'),
|
|
99
106
|
headers,
|
|
100
107
|
body: 'body' in init ? init.body : (isReq ? input.body : null),
|
|
108
|
+
referrer: `/_proxy/${PROXY_HOST}/`,
|
|
101
109
|
}
|
|
102
110
|
|
|
103
|
-
const passthrough = ['credentials', 'mode', 'cache', 'redirect',
|
|
111
|
+
const passthrough = ['credentials', 'mode', 'cache', 'redirect',
|
|
104
112
|
'referrerPolicy', 'integrity', 'keepalive', 'signal']
|
|
105
113
|
for (const key of passthrough) {
|
|
106
114
|
if (key in init) newInit[key] = init[key]
|
|
@@ -134,39 +142,54 @@
|
|
|
134
142
|
_xhrOpen.call(this, meta.method, rewritten, ...meta.openArgs)
|
|
135
143
|
}
|
|
136
144
|
|
|
137
|
-
_xhrSetHeader.call(this, 'x-proxy-host',
|
|
145
|
+
_xhrSetHeader.call(this, 'x-proxy-host', PROXY_HOST)
|
|
138
146
|
return _xhrSend.call(this, body)
|
|
139
147
|
}
|
|
140
148
|
|
|
141
149
|
// ============================= WebSocket ===================================
|
|
142
150
|
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
151
|
+
// Rewrite WebSocket URLs so they go through the proxy.
|
|
152
|
+
// wss://code.cia.net/ws → wss://4000.cia.net/_proxy/code.cia.net/ws
|
|
153
|
+
const rewriteWsUrl = (url) => {
|
|
154
|
+
const raw = typeof url === 'string' ? url : url.toString()
|
|
155
|
+
try {
|
|
156
|
+
const parsed = new URL(raw)
|
|
157
|
+
if (parsed.host === window.__hostingSite) return raw
|
|
158
|
+
// Rewrite wss://upstream/path → wss://proxy/_proxy/upstream/path
|
|
159
|
+
return `${parsed.protocol}//${window.__hostingSite}/_proxy/${parsed.host}${parsed.pathname}${parsed.search}`
|
|
160
|
+
} catch {
|
|
161
|
+
// Relative URL — prefix with proxy host
|
|
162
|
+
if (raw.startsWith('/')) {
|
|
163
|
+
return `wss://${window.__hostingSite}/_proxy/${PROXY_HOST}${raw}`
|
|
164
|
+
}
|
|
165
|
+
return raw
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
window.WebSocket = function (url, protocols) {
|
|
170
|
+
const rewritten = rewriteWsUrl(url)
|
|
171
|
+
console.log('[interception] WebSocket', url, '->', rewritten)
|
|
172
|
+
if (protocols !== undefined) return new _WebSocket(rewritten, protocols)
|
|
173
|
+
return new _WebSocket(rewritten)
|
|
174
|
+
}
|
|
175
|
+
window.WebSocket.prototype = _WebSocket.prototype
|
|
176
|
+
window.WebSocket.CONNECTING = _WebSocket.CONNECTING
|
|
177
|
+
window.WebSocket.OPEN = _WebSocket.OPEN
|
|
178
|
+
window.WebSocket.CLOSING = _WebSocket.CLOSING
|
|
179
|
+
window.WebSocket.CLOSED = _WebSocket.CLOSED
|
|
180
|
+
|
|
181
|
+
// ============================ EventSource ==================================
|
|
182
|
+
|
|
183
|
+
if (_EventSource) {
|
|
184
|
+
window.EventSource = function (url, dict) {
|
|
185
|
+
const raw = typeof url === 'string' ? url : url.href
|
|
186
|
+
const rewritten = raw.startsWith('/') ? `/_proxy/${PROXY_HOST}${raw}` : rewriteUrl(raw)
|
|
187
|
+
return new _EventSource(rewritten, dict)
|
|
188
|
+
}
|
|
189
|
+
window.EventSource.prototype = _EventSource.prototype
|
|
190
|
+
window.EventSource.CONNECTING = _EventSource.CONNECTING
|
|
191
|
+
window.EventSource.OPEN = _EventSource.OPEN
|
|
192
|
+
window.EventSource.CLOSED = _EventSource.CLOSED
|
|
193
|
+
}
|
|
171
194
|
|
|
172
195
|
})(window);
|
|
@@ -8,6 +8,32 @@ html, body {
|
|
|
8
8
|
background-color: white;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
#site-content {
|
|
12
|
+
position: relative;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
#site-content::after {
|
|
16
|
+
content: "";
|
|
17
|
+
position: absolute;
|
|
18
|
+
inset: 0;
|
|
19
|
+
box-shadow: inset 0px 0px 4px 1px rgb(0 0 0 / 19%);
|
|
20
|
+
pointer-events: none;
|
|
21
|
+
border-radius: inherit;
|
|
22
|
+
border: 1px solid rgb(151 151 151 / 79%);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/*
|
|
26
|
+
.iframe-wrapper::after {
|
|
27
|
+
content: "";
|
|
28
|
+
position: absolute;
|
|
29
|
+
inset: 0;
|
|
30
|
+
border: 1px solid rgb(151 151 151 / 79%);
|
|
31
|
+
box-shadow: inset 0px 0px 4px 1px rgb(0 0 0 / 19%);
|
|
32
|
+
pointer-events: none;
|
|
33
|
+
border-radius: inherit;
|
|
34
|
+
}
|
|
35
|
+
*/
|
|
36
|
+
|
|
11
37
|
#site-wrapper {
|
|
12
38
|
display: flex;
|
|
13
39
|
flex-direction: column;
|
|
@@ -25,11 +51,55 @@ html, body {
|
|
|
25
51
|
margin: 7px;
|
|
26
52
|
margin-top: 0px;
|
|
27
53
|
border-radius: 15px;
|
|
28
|
-
background-color: grey;
|
|
54
|
+
#background-color: grey;
|
|
29
55
|
overflow: hidden;
|
|
30
56
|
}
|
|
31
57
|
}
|
|
32
58
|
|
|
59
|
+
/*! Sortable Tree 0.7.6, (c) 2025 Marc Anton Dahmen, MIT license */
|
|
60
|
+
:root {
|
|
61
|
+
--st-label-height: 2.5rem;
|
|
62
|
+
--st-subnodes-padding-left: 1.5rem;
|
|
63
|
+
--st-collapse-icon-height: var(--st-label-height);
|
|
64
|
+
--st-collapse-icon-width: 1.25rem;
|
|
65
|
+
--st-collapse-icon-size: 0.75rem;
|
|
66
|
+
}
|
|
67
|
+
sortable-tree-node {
|
|
68
|
+
position: relative;
|
|
69
|
+
z-index: 1;
|
|
70
|
+
display: flex;
|
|
71
|
+
flex-direction: column;
|
|
72
|
+
}
|
|
73
|
+
sortable-tree-node * {
|
|
74
|
+
user-select: none;
|
|
75
|
+
}
|
|
76
|
+
sortable-tree-node > :first-child {
|
|
77
|
+
display: flex;
|
|
78
|
+
align-items: center;
|
|
79
|
+
height: var(--st-label-height);
|
|
80
|
+
}
|
|
81
|
+
sortable-tree-node > :nth-child(2) {
|
|
82
|
+
display: none;
|
|
83
|
+
flex-direction: column;
|
|
84
|
+
padding-left: var(--st-subnodes-padding-left);
|
|
85
|
+
}
|
|
86
|
+
sortable-tree-node[open] > div:nth-child(2) {
|
|
87
|
+
display: flex;
|
|
88
|
+
}
|
|
89
|
+
sortable-tree-node > :nth-child(3) {
|
|
90
|
+
position: absolute;
|
|
91
|
+
display: flex;
|
|
92
|
+
align-items: center;
|
|
93
|
+
top: 0;
|
|
94
|
+
left: calc(var(--st-collapse-icon-width) * -1);
|
|
95
|
+
height: var(--st-collapse-icon-height);
|
|
96
|
+
font-size: var(--st-collapse-icon-size);
|
|
97
|
+
cursor: pointer;
|
|
98
|
+
}
|
|
99
|
+
sortable-tree-node > :nth-child(2):empty + span {
|
|
100
|
+
display: none;
|
|
101
|
+
}
|
|
102
|
+
|
|
33
103
|
#proxy-content {
|
|
34
104
|
height: 100%;
|
|
35
105
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WrapItRuby
|
|
4
|
+
class MenuSettingsController < ::ApplicationController
|
|
5
|
+
def index
|
|
6
|
+
@menu_items = ::MenuItem.roots.includes(children: :children)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def create
|
|
10
|
+
::MenuItem.create!(menu_item_params)
|
|
11
|
+
respond_with_tree_refresh
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def update
|
|
15
|
+
item = ::MenuItem.find(params[:id])
|
|
16
|
+
item.update!(menu_item_params)
|
|
17
|
+
respond_with_tree_refresh
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def destroy
|
|
21
|
+
item = ::MenuItem.find(params[:id])
|
|
22
|
+
item.destroy!
|
|
23
|
+
respond_with_tree_refresh
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def sort
|
|
27
|
+
ordering = params.require(:ordering)
|
|
28
|
+
|
|
29
|
+
::MenuItem.transaction do
|
|
30
|
+
ordering.each do |entry|
|
|
31
|
+
item = ::MenuItem.find(entry[:id])
|
|
32
|
+
new_parent_id = entry[:parent_id].presence
|
|
33
|
+
|
|
34
|
+
if item.parent_id.to_s != new_parent_id.to_s
|
|
35
|
+
item.remove_from_list
|
|
36
|
+
item.update!(parent_id: new_parent_id)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
item.insert_at(entry[:position].to_i)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
respond_with_tree_refresh
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def menu_item_params
|
|
49
|
+
params.permit(:label, :icon, :route, :url, :item_type, :parent_id)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def respond_with_tree_refresh
|
|
53
|
+
respond_to do |format|
|
|
54
|
+
format.turbo_stream do
|
|
55
|
+
render turbo_stream: turbo_stream.replace(
|
|
56
|
+
'menu-tree-container',
|
|
57
|
+
partial: 'wrap_it_ruby/menu_settings/tree'
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
format.html { head :no_content }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -11,7 +11,8 @@ module WrapItRuby
|
|
|
11
11
|
target_domain = menu_item["url"]
|
|
12
12
|
|
|
13
13
|
target_url = "#{target_domain}/#{target_path}"
|
|
14
|
-
|
|
14
|
+
proxy_host = ENV["WRAP_IT_PROXY_HOST"]
|
|
15
|
+
@iframe_src = proxy_host ? "//#{proxy_host}/_proxy/#{target_url}" : "/_proxy/#{target_url}"
|
|
15
16
|
end
|
|
16
17
|
end
|
|
17
18
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
require
|
|
1
|
+
require 'yaml'
|
|
2
2
|
|
|
3
3
|
module WrapItRuby
|
|
4
4
|
# Loads and queries the menu configuration from the host app's
|
|
5
|
-
# config/menu.yml file
|
|
5
|
+
# config/menu.yml file, or from the database when menu_item_class
|
|
6
|
+
# is configured.
|
|
6
7
|
#
|
|
7
8
|
# Can be used as a module (extend self) or included in controllers/helpers.
|
|
8
9
|
#
|
|
@@ -13,46 +14,321 @@ module WrapItRuby
|
|
|
13
14
|
module MenuHelper
|
|
14
15
|
def menu_config = load_menu
|
|
15
16
|
|
|
16
|
-
# Renders the
|
|
17
|
-
# Must be called from a
|
|
17
|
+
# Renders the menu entries using rails-active-ui component helpers.
|
|
18
|
+
# Must be called from inside a Menu { } block in the layout.
|
|
19
|
+
#
|
|
20
|
+
# Supports arbitrary nesting depth:
|
|
21
|
+
# - Top-level entries with "items" render as Fomantic-UI simple
|
|
22
|
+
# dropdown menu items (hover to open).
|
|
23
|
+
# - Nested entries with "items" render as flyout sub-dropdowns
|
|
24
|
+
# (dropdown icon + nested .menu inside an .item).
|
|
25
|
+
# - Leaf entries render as plain linked menu items.
|
|
18
26
|
def render_menu
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
WrapItRuby::MenuHelper.menu_config.each do |entry|
|
|
28
|
+
render_menu_entry(entry, top_level: true)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Renders the menu-settings, edit, and add modals.
|
|
33
|
+
# Call this outside the Menu { } block so modals are not
|
|
34
|
+
# nested inside the menu bar.
|
|
35
|
+
def render_menu_modals
|
|
36
|
+
# Settings modal — sortable tree + add button
|
|
37
|
+
concat tag.div(id: 'menu-settings-modal', class: 'ui large modal') {
|
|
38
|
+
safe_join([
|
|
39
|
+
tag.i(class: 'close icon'),
|
|
40
|
+
tag.div(class: 'header') { tag.i(class: 'bars icon') + ' Menu Settings' },
|
|
41
|
+
tag.div(class: 'scrolling content') {
|
|
42
|
+
tag.div(id: 'menu-tree-container') { sortable_menu_tree }
|
|
43
|
+
},
|
|
44
|
+
tag.div(class: 'actions') {
|
|
45
|
+
safe_join([
|
|
46
|
+
tag.button(class: 'ui green button', onclick: 'menuSettingsShowAdd()') {
|
|
47
|
+
tag.i(class: 'plus icon') + ' Add Item'
|
|
48
|
+
}
|
|
49
|
+
])
|
|
50
|
+
}
|
|
51
|
+
])
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# Edit modal — stacked on top of settings modal
|
|
55
|
+
concat tag.div(id: 'menu-edit-modal', class: 'ui small modal') {
|
|
56
|
+
safe_join([
|
|
57
|
+
tag.i(class: 'close icon'),
|
|
58
|
+
tag.div(class: 'header') { 'Edit Menu Item' },
|
|
59
|
+
tag.div(class: 'content') {
|
|
60
|
+
tag.form(class: 'ui form', id: 'menu-edit-form') {
|
|
61
|
+
safe_join([
|
|
62
|
+
tag.input(type: 'hidden', name: 'id', id: 'menu-edit-id'),
|
|
63
|
+
tag.div(class: 'two fields') {
|
|
64
|
+
safe_join([
|
|
65
|
+
tag.div(class: 'field') {
|
|
66
|
+
tag.label {
|
|
67
|
+
'Label'
|
|
68
|
+
} + tag.input(type: 'text', name: 'label', id: 'menu-edit-label')
|
|
69
|
+
},
|
|
70
|
+
tag.div(class: 'field') {
|
|
71
|
+
tag.label {
|
|
72
|
+
'Icon'
|
|
73
|
+
} + tag.input(type: 'text', name: 'icon', id: 'menu-edit-icon',
|
|
74
|
+
placeholder: 'e.g. server')
|
|
75
|
+
}
|
|
76
|
+
])
|
|
77
|
+
},
|
|
78
|
+
tag.div(class: 'two fields', id: 'menu-edit-proxy-fields') {
|
|
79
|
+
safe_join([
|
|
80
|
+
tag.div(class: 'field') {
|
|
81
|
+
tag.label {
|
|
82
|
+
'Route'
|
|
83
|
+
} + tag.input(type: 'text', name: 'route', id: 'menu-edit-route',
|
|
84
|
+
placeholder: '/path')
|
|
85
|
+
},
|
|
86
|
+
tag.div(class: 'field') {
|
|
87
|
+
tag.label {
|
|
88
|
+
'URL'
|
|
89
|
+
} + tag.input(type: 'text', name: 'url', id: 'menu-edit-url',
|
|
90
|
+
placeholder: 'upstream.example.com')
|
|
91
|
+
}
|
|
92
|
+
])
|
|
93
|
+
},
|
|
94
|
+
tag.div(class: 'field') {
|
|
95
|
+
tag.label { 'Type' } +
|
|
96
|
+
tag.select(name: 'item_type', id: 'menu-edit-type', class: 'ui dropdown',
|
|
97
|
+
onchange: "menuSettingsToggleProxyFields('menu-edit')") {
|
|
98
|
+
safe_join([
|
|
99
|
+
tag.option(value: 'group') { 'Group' },
|
|
100
|
+
tag.option(value: 'proxy') { 'Proxy' }
|
|
101
|
+
])
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
])
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
tag.div(class: 'actions') {
|
|
108
|
+
safe_join([
|
|
109
|
+
tag.button(class: 'ui red left floated button', onclick: 'menuSettingsDelete()') {
|
|
110
|
+
tag.i(class: 'trash icon') + ' Delete'
|
|
111
|
+
},
|
|
112
|
+
tag.button(class: 'ui button', onclick: "$('#menu-edit-modal').modal('hide')") {
|
|
113
|
+
'Cancel'
|
|
114
|
+
},
|
|
115
|
+
tag.button(class: 'ui green button', onclick: 'menuSettingsSave()') {
|
|
116
|
+
tag.i(class: 'save icon') + ' Save'
|
|
117
|
+
}
|
|
118
|
+
])
|
|
119
|
+
}
|
|
120
|
+
])
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# Add modal — stacked on top of settings modal
|
|
124
|
+
concat tag.div(id: 'menu-add-modal', class: 'ui small modal') {
|
|
125
|
+
safe_join([
|
|
126
|
+
tag.i(class: 'close icon'),
|
|
127
|
+
tag.div(class: 'header') { 'Add Menu Item' },
|
|
128
|
+
tag.div(class: 'content') {
|
|
129
|
+
tag.form(class: 'ui form', id: 'menu-add-form') {
|
|
130
|
+
safe_join([
|
|
131
|
+
tag.div(class: 'field') {
|
|
132
|
+
tag.label { 'Type' } +
|
|
133
|
+
tag.select(name: 'item_type', id: 'menu-add-type', class: 'ui dropdown',
|
|
134
|
+
onchange: "menuSettingsToggleProxyFields('menu-add')") {
|
|
135
|
+
safe_join([
|
|
136
|
+
tag.option(value: 'group') { 'Group' },
|
|
137
|
+
tag.option(value: 'proxy') { 'Proxy' }
|
|
138
|
+
])
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
tag.div(class: 'two fields') {
|
|
142
|
+
safe_join([
|
|
143
|
+
tag.div(class: 'field') {
|
|
144
|
+
tag.label {
|
|
145
|
+
'Label'
|
|
146
|
+
} + tag.input(type: 'text', name: 'label', id: 'menu-add-label')
|
|
147
|
+
},
|
|
148
|
+
tag.div(class: 'field') {
|
|
149
|
+
tag.label {
|
|
150
|
+
'Icon'
|
|
151
|
+
} + tag.input(type: 'text', name: 'icon', id: 'menu-add-icon',
|
|
152
|
+
placeholder: 'e.g. server')
|
|
153
|
+
}
|
|
154
|
+
])
|
|
155
|
+
},
|
|
156
|
+
tag.div(class: 'two fields', id: 'menu-add-proxy-fields', style: 'display:none') {
|
|
157
|
+
safe_join([
|
|
158
|
+
tag.div(class: 'field') {
|
|
159
|
+
tag.label {
|
|
160
|
+
'Route'
|
|
161
|
+
} + tag.input(type: 'text', name: 'route', id: 'menu-add-route',
|
|
162
|
+
placeholder: '/path')
|
|
163
|
+
},
|
|
164
|
+
tag.div(class: 'field') {
|
|
165
|
+
tag.label {
|
|
166
|
+
'URL'
|
|
167
|
+
} + tag.input(type: 'text', name: 'url', id: 'menu-add-url',
|
|
168
|
+
placeholder: 'upstream.example.com')
|
|
169
|
+
}
|
|
170
|
+
])
|
|
171
|
+
},
|
|
172
|
+
tag.div(class: 'field') {
|
|
173
|
+
tag.label { 'Parent' } +
|
|
174
|
+
tag.select(name: 'parent_id', id: 'menu-add-parent', class: 'ui dropdown') {
|
|
175
|
+
safe_join([
|
|
176
|
+
tag.option(value: '') { 'Root (top level)' },
|
|
177
|
+
*menu_group_options
|
|
178
|
+
])
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
])
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
tag.div(class: 'actions') {
|
|
185
|
+
safe_join([
|
|
186
|
+
tag.button(class: 'ui button', onclick: "$('#menu-add-modal').modal('hide')") {
|
|
187
|
+
'Cancel'
|
|
188
|
+
},
|
|
189
|
+
tag.button(class: 'ui green button', onclick: 'menuSettingsCreate()') {
|
|
190
|
+
tag.i(class: 'plus icon') + ' Add'
|
|
191
|
+
}
|
|
192
|
+
])
|
|
193
|
+
}
|
|
194
|
+
])
|
|
29
195
|
}
|
|
30
196
|
end
|
|
31
197
|
|
|
32
198
|
def all_menu_items
|
|
33
|
-
menu_config
|
|
199
|
+
flatten_items(menu_config)
|
|
34
200
|
end
|
|
35
201
|
|
|
36
202
|
def all_proxy_menu_items
|
|
37
|
-
all_menu_items.select { |item| item[
|
|
203
|
+
all_menu_items.select { |item| item['type'] == 'proxy' }
|
|
38
204
|
end
|
|
39
205
|
|
|
40
206
|
def proxy_paths
|
|
41
207
|
all_menu_items
|
|
42
|
-
.select { |item| item[
|
|
43
|
-
.map { |item| item[
|
|
208
|
+
.select { |item| item['type'] == 'proxy' }
|
|
209
|
+
.map { |item| item['route'] }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def reset_menu_cache!
|
|
213
|
+
@menu_config = nil
|
|
44
214
|
end
|
|
45
215
|
|
|
46
216
|
extend self
|
|
47
217
|
|
|
48
218
|
private
|
|
49
219
|
|
|
50
|
-
|
|
51
|
-
|
|
220
|
+
def render_menu_entry(entry, top_level: false)
|
|
221
|
+
if entry['items']
|
|
222
|
+
if top_level
|
|
223
|
+
MenuItem(dropdown: true, icon: entry['icon']) do
|
|
224
|
+
text entry['label']
|
|
225
|
+
concat tag.i(class: 'dropdown icon')
|
|
226
|
+
SubMenu do
|
|
227
|
+
entry['items'].each { |child| render_menu_entry(child) }
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
else
|
|
231
|
+
MenuItem do
|
|
232
|
+
concat tag.i(class: 'dropdown icon')
|
|
233
|
+
text entry['label']
|
|
234
|
+
SubMenu do
|
|
235
|
+
entry['items'].each { |child| render_menu_entry(child) }
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
else
|
|
240
|
+
MenuItem(href: entry['route'], icon: entry['icon']) { text entry['label'] }
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Renders a sortable-tree container.
|
|
245
|
+
# Pass an array of MenuItem records (roots with children eager-loaded).
|
|
246
|
+
# The Stimulus controller reads the JSON and initializes sortable-tree.
|
|
247
|
+
def sortable_menu_tree
|
|
248
|
+
items = ::MenuItem.roots.includes(children: :children)
|
|
249
|
+
nodes_json = menu_items_to_nodes(items).to_json
|
|
250
|
+
sort_url = wrap_it_ruby.sort_menu_setting_path(id: 'bulk')
|
|
251
|
+
|
|
252
|
+
tag.div(
|
|
253
|
+
data: {
|
|
254
|
+
controller: 'wrap-it-ruby--sortable-tree',
|
|
255
|
+
"wrap-it-ruby--sortable-tree-nodes-value": nodes_json,
|
|
256
|
+
"wrap-it-ruby--sortable-tree-sort-url-value": sort_url,
|
|
257
|
+
"wrap-it-ruby--sortable-tree-lock-root-value": false,
|
|
258
|
+
"wrap-it-ruby--sortable-tree-collapse-level-value": 3
|
|
259
|
+
}
|
|
260
|
+
)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Converts MenuItem records to the sortable-tree nodes format:
|
|
264
|
+
# [{ data: { id:, title:, icon:, route:, url: }, nodes: [...] }]
|
|
265
|
+
def menu_items_to_nodes(items)
|
|
266
|
+
items.map do |item|
|
|
267
|
+
{
|
|
268
|
+
data: {
|
|
269
|
+
id: item.id,
|
|
270
|
+
title: item.label,
|
|
271
|
+
icon: item.icon,
|
|
272
|
+
route: item.route,
|
|
273
|
+
url: item.url,
|
|
274
|
+
item_type: item.item_type
|
|
275
|
+
},
|
|
276
|
+
nodes: item.children.any? ? menu_items_to_nodes(item.children) : []
|
|
277
|
+
}
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Builds <option> tags for all group items (for parent dropdown in add modal).
|
|
282
|
+
# Indents sub-groups with dashes to show hierarchy.
|
|
283
|
+
def menu_group_options(items = nil, depth = 0)
|
|
284
|
+
items ||= ::MenuItem.groups.where(parent_id: nil).order(:position).includes(children: :children)
|
|
285
|
+
items.flat_map do |item|
|
|
286
|
+
prefix = "\u00A0\u00A0" * depth + (depth > 0 ? "\u2514 " : '')
|
|
287
|
+
opts = [tag.option(value: item.id) { "#{prefix}#{item.label}" }]
|
|
288
|
+
sub_groups = item.children.select(&:group?)
|
|
289
|
+
opts += menu_group_options(sub_groups, depth + 1) if sub_groups.any?
|
|
290
|
+
opts
|
|
52
291
|
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def flatten_items(items)
|
|
295
|
+
items.flat_map { |item| [item, *flatten_items(item.fetch('items', []))] }
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def menu_file
|
|
299
|
+
Rails.root.join('config/menu.yml')
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def load_menu
|
|
303
|
+
@menu_config ||= if database_menu_available?
|
|
304
|
+
load_menu_from_database
|
|
305
|
+
else
|
|
306
|
+
YAML.load_file(menu_file)
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def database_menu_available?
|
|
311
|
+
defined?(::MenuItem) && ::MenuItem.table_exists? && ::MenuItem.exists?
|
|
312
|
+
rescue StandardError
|
|
313
|
+
false
|
|
314
|
+
end
|
|
53
315
|
|
|
54
|
-
|
|
55
|
-
|
|
316
|
+
def load_menu_from_database
|
|
317
|
+
::MenuItem.roots.includes(children: :children).map { |item| item_to_hash(item) }
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def item_to_hash(item)
|
|
321
|
+
hash = { 'label' => item.label, 'icon' => item.icon }
|
|
322
|
+
|
|
323
|
+
if item.children.any?
|
|
324
|
+
hash['items'] = item.children.map { |child| item_to_hash(child) }
|
|
325
|
+
else
|
|
326
|
+
hash['route'] = item.route
|
|
327
|
+
hash['url'] = item.url
|
|
328
|
+
hash['type'] = item.item_type
|
|
56
329
|
end
|
|
330
|
+
|
|
331
|
+
hash
|
|
332
|
+
end
|
|
57
333
|
end
|
|
58
334
|
end
|