feat(admin): improve Holesail tunnel management UX

- Add Tunnels list / Add a tunnel tabs with hash navigation
- Show busy overlay during start/stop/remove/create AJAX
- Reload with #magesail-list after successful actions
- Only reload stop/remove on success; fix error handling
- Remove redundant Stores configuration link; drop getConfigUrl()
This commit is contained in:
2026-03-21 01:50:24 -05:00
parent 3261cae37f
commit 24f58cb06c
5 changed files with 311 additions and 28 deletions
-5
View File
@@ -161,11 +161,6 @@ class Tunnel extends Template
return $out;
}
public function getConfigUrl(): string
{
return $this->getUrl('magesail/settings/index');
}
public function getStatusUrl(): string
{
return $this->getUrl('*/*/status');
+3 -1
View File
@@ -1,6 +1,8 @@
"Holesail Tunnel Management","Holesail Tunnel Management"
"Please wait…","Please wait…"
"Stop failed","Stop failed"
"Remove failed","Remove failed"
"Tunnel status","Tunnel status"
"Stores configuration: MageSail (Holesail Tunnel)","Stores configuration: MageSail (Holesail Tunnel)"
"MageSail","MageSail"
"Tunnel is running (PID: %1).","Tunnel is running (PID: %1)."
"Share key","Share key"
1 Holesail Tunnel Management Holesail Tunnel Management
2 Please wait… Please wait…
3 Stop failed Stop failed
4 Remove failed Remove failed
5 Tunnel status Tunnel status
Stores configuration: MageSail (Holesail Tunnel) Stores configuration: MageSail (Holesail Tunnel)
6 MageSail MageSail
7 Tunnel is running (PID: %1). Tunnel is running (PID: %1).
8 Share key Share key
@@ -5,7 +5,6 @@ $registry = $block->listTunnelsFromRegistry();
$formKey = $block->escapeHtml($block->getFormKey());
$statusUrl = $block->getStatusUrl();
$indexUrl = $block->getIndexUrl();
$configUrl = $block->escapeUrl($block->getConfigUrl());
$websiteOptions = $block->getWebsiteOptions();
$groupsJson = $block->escapeHtmlAttr($block->getStoreGroupsByWebsiteJson());
$portUi = $block->getInitialLocalPortUiState();
@@ -16,29 +15,52 @@ $nginxReloadHint = (string) __(
'Reload NGINX on this server so the updated global map is active (for example: sudo systemctl reload nginx). ' .
'Or set Reload wrapper script in Admin under MageSail → Holesail Tunnel → Configuration (NGINX Configuration group) so Magento can reload automatically next time.'
);
$defaultMagesailTab = \count($registry) === 0 ? 'add' : 'list';
?>
<div class="magesail-tunnel"
id="magesail-root"
data-status-url="<?= $block->escapeUrl($statusUrl) ?>"
data-index-url="<?= $block->escapeUrl($indexUrl) ?>"
data-form-key="<?= $formKey ?>"
data-groups-by-website="<?= $groupsJson ?>">
data-groups-by-website="<?= $groupsJson ?>"
data-default-tab="<?= $block->escapeHtmlAttr($defaultMagesailTab) ?>">
<div class="page-title-wrapper">
<h1 class="page-title"><?= $block->escapeHtml(__('Holesail Tunnel Management')) ?></h1>
</div>
<div id="magesail-flash" class="message" style="display:none"></div>
<div class="magesail-tab-bar" role="tablist" aria-label="<?= $block->escapeHtmlAttr(__('Holesail tunnel sections')) ?>">
<button type="button"
role="tab"
id="magesail-tab-trigger-list"
class="magesail-tab<?= $defaultMagesailTab === 'list' ? ' magesail-tab--active' : '' ?>"
aria-selected="<?= $defaultMagesailTab === 'list' ? 'true' : 'false' ?>"
aria-controls="magesail-tab-panel-list"
tabindex="<?= $defaultMagesailTab === 'list' ? '0' : '-1' ?>">
<?= $block->escapeHtml(__('Tunnels list')) ?>
</button>
<button type="button"
role="tab"
id="magesail-tab-trigger-add"
class="magesail-tab<?= $defaultMagesailTab === 'add' ? ' magesail-tab--active' : '' ?>"
aria-selected="<?= $defaultMagesailTab === 'add' ? 'true' : 'false' ?>"
aria-controls="magesail-tab-panel-add"
tabindex="<?= $defaultMagesailTab === 'add' ? '0' : '-1' ?>">
<?= $block->escapeHtml(__('Add a tunnel')) ?>
</button>
</div>
<div id="magesail-tab-panel-list"
class="magesail-tab-panel<?= $defaultMagesailTab === 'list' ? ' magesail-tab-panel--active' : '' ?>"
role="tabpanel"
aria-labelledby="magesail-tab-trigger-list"
<?= $defaultMagesailTab === 'list' ? '' : 'hidden="hidden"' ?>>
<div class="admin__page-section">
<div class="admin__page-section-title">
<span class="title"><?= $block->escapeHtml(__('Tunnels')) ?></span>
</div>
<div class="admin__page-section-content">
<p>
<a href="<?= $configUrl ?>" class="action-secondary">
<?= $block->escapeHtml(__('Stores configuration: MageSail (Holesail Tunnel)')) ?>
</a>
</p>
<?php if ($nginxPending): ?>
<div class="message message-warning warning" id="magesail-nginx-reload-banner" role="alert">
<span><?= $block->escapeHtml($nginxReloadHint) ?></span>
@@ -63,7 +85,14 @@ $nginxReloadHint = (string) __(
</thead>
<tbody>
<?php if (!\count($registry)): ?>
<tr><td colspan="7"><?= $block->escapeHtml(__('No tunnels yet. Add one below.')) ?></td></tr>
<tr class="magesail-empty-row">
<td colspan="7">
<p style="margin:0 0 0.75rem"><?= $block->escapeHtml(__('No tunnels yet. Add one in the Add a tunnel tab.')) ?></p>
<button type="button" class="action-secondary magesail-tab-goto-add">
<?= $block->escapeHtml(__('Go to Add a tunnel')) ?>
</button>
</td>
</tr>
<?php endif; ?>
<?php foreach ($registry as $row):
$tid = (string) ($row['id'] ?? '');
@@ -132,7 +161,13 @@ $nginxReloadHint = (string) __(
</table>
</div>
</div>
</div>
<div id="magesail-tab-panel-add"
class="magesail-tab-panel<?= $defaultMagesailTab === 'add' ? ' magesail-tab-panel--active' : '' ?>"
role="tabpanel"
aria-labelledby="magesail-tab-trigger-add"
<?= $defaultMagesailTab === 'add' ? '' : 'hidden="hidden"' ?>>
<div class="admin__page-section">
<div class="admin__page-section-title">
<span class="title"><?= $block->escapeHtml(__('Add tunnel')) ?></span>
@@ -218,6 +253,19 @@ $nginxReloadHint = (string) __(
</div>
</div>
</div>
</div>
<div id="magesail-busy-overlay"
class="magesail-busy-overlay"
hidden="hidden"
role="status"
aria-live="polite"
aria-label="<?= $block->escapeHtmlAttr(__('Please wait…')) ?>">
<div class="magesail-busy-inner">
<span class="magesail-busy-spinner" aria-hidden="true"></span>
<span class="magesail-busy-text"><?= $block->escapeHtml(__('Please wait…')) ?></span>
</div>
</div>
</div>
<script>
@@ -231,6 +279,108 @@ require(['jquery', 'domReady!'], function ($) {
var logPollTimers = {};
var copiedLabel = <?= json_encode((string) __('Copied!')) ?>;
var $tabList = root.find('.magesail-tab-bar');
var $tabs = $tabList.find('.magesail-tab');
var $panels = root.find('.magesail-tab-panel');
function setHashForTab(name) {
var frag = name === 'add' ? '#magesail-add' : '#magesail-list';
if (window.history && window.history.replaceState) {
var base = window.location.pathname + window.location.search;
window.history.replaceState(null, '', base + frag);
} else {
window.location.hash = frag;
}
}
function setActiveTab(name) {
if (name !== 'list' && name !== 'add') {
return;
}
$tabs.each(function () {
var $t = $(this);
var isList = $t.attr('id') === 'magesail-tab-trigger-list';
var active = (isList && name === 'list') || (!isList && name === 'add');
$t.toggleClass('magesail-tab--active', active)
.attr('aria-selected', active ? 'true' : 'false')
.attr('tabindex', active ? '0' : '-1');
});
$panels.each(function () {
var $p = $(this);
var isListPanel = $p.attr('id') === 'magesail-tab-panel-list';
var active = (isListPanel && name === 'list') || (!isListPanel && name === 'add');
$p.toggleClass('magesail-tab-panel--active', active);
if (active) {
$p.removeAttr('hidden');
} else {
$p.attr('hidden', 'hidden');
}
});
setHashForTab(name);
}
function tabFromHash() {
var h = (window.location.hash || '').toLowerCase();
if (h === '#magesail-add' || h === '#add') {
return 'add';
}
if (h === '#magesail-list' || h === '#list') {
return 'list';
}
return null;
}
var initial = tabFromHash() || (root.data('default-tab') || 'list');
setActiveTab(initial);
$tabs.on('click', function () {
setActiveTab($(this).attr('id') === 'magesail-tab-trigger-list' ? 'list' : 'add');
});
$tabs.on('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
$(this).trigger('click');
return;
}
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') {
return;
}
e.preventDefault();
var idx = $tabs.index(this);
var dir = e.key === 'ArrowRight' ? 1 : -1;
var $next = $tabs.eq((idx + dir + $tabs.length) % $tabs.length);
setActiveTab($next.attr('id') === 'magesail-tab-trigger-list' ? 'list' : 'add');
$next.focus();
});
root.on('click', '.magesail-tab-goto-add', function () {
setActiveTab('add');
document.getElementById('magesail-tab-trigger-add').focus();
});
var $busyOverlay = $('#magesail-busy-overlay');
function setBusy(on) {
if (on) {
root.attr('aria-busy', 'true');
$busyOverlay.removeAttr('hidden');
} else {
root.removeAttr('aria-busy');
$busyOverlay.attr('hidden', 'hidden');
}
}
function reloadToListTab() {
var base = window.location.pathname + window.location.search;
if (window.history && window.history.replaceState) {
window.history.replaceState(null, '', base + '#magesail-list');
} else {
window.location.hash = '#magesail-list';
}
window.location.reload();
}
function flash(msg, isError) {
var el = $('#magesail-flash');
el.removeClass('message-success success message-error error message-warning warning')
@@ -290,7 +440,7 @@ require(['jquery', 'domReady!'], function ($) {
function applyTunnelRow(tunnelId, data) {
var row = $('tr[data-tunnel-id="' + tunnelId.replace(/"/g, '\\"') + '"]').first();
if (!row.length) {
location.reload();
reloadToListTab();
return;
}
var statusCell = row.find('.magesail-status-cell');
@@ -350,6 +500,8 @@ require(['jquery', 'domReady!'], function ($) {
$(document).on('click', '.magesail-btn-start-existing', function () {
var tid = $(this).data('tunnel-id');
var btn = $(this).prop('disabled', true);
var willReload = false;
setBusy(true);
$.ajax({
url: indexUrl,
type: 'POST',
@@ -359,7 +511,8 @@ require(['jquery', 'domReady!'], function ($) {
}).done(function (res) {
if (res.success) {
flash(res.message || '', false);
location.reload();
willReload = true;
reloadToListTab();
} else {
flash(res.message || 'Start failed', true);
}
@@ -367,13 +520,18 @@ require(['jquery', 'domReady!'], function ($) {
var m = (xhr.responseJSON && xhr.responseJSON.message) ? xhr.responseJSON.message : 'Request failed';
flash(m, true);
}).always(function () {
btn.prop('disabled', false);
if (!willReload) {
setBusy(false);
btn.prop('disabled', false);
}
});
});
$(document).on('click', '.magesail-btn-stop', function () {
var tid = $(this).data('tunnel-id');
var btn = $(this).prop('disabled', true);
var willReload = false;
setBusy(true);
$.ajax({
url: indexUrl,
type: 'POST',
@@ -381,12 +539,20 @@ require(['jquery', 'domReady!'], function ($) {
headers: { 'X-Requested-With': 'XMLHttpRequest' },
data: { form_key: formKey, action: 'stop', ajax: '1', tunnel_id: tid }
}).done(function (res) {
flash(res.message || '', false);
location.reload();
if (res.success) {
flash(res.message || '', false);
willReload = true;
reloadToListTab();
} else {
flash(res.message || <?= json_encode((string) __('Stop failed')) ?>, true);
}
}).fail(function () {
flash('Stop failed', true);
flash(<?= json_encode((string) __('Stop failed')) ?>, true);
}).always(function () {
btn.prop('disabled', false);
if (!willReload) {
setBusy(false);
btn.prop('disabled', false);
}
});
});
@@ -396,6 +562,8 @@ require(['jquery', 'domReady!'], function ($) {
return;
}
var btn = $(this).prop('disabled', true);
var willReload = false;
setBusy(true);
$.ajax({
url: indexUrl,
type: 'POST',
@@ -403,12 +571,20 @@ require(['jquery', 'domReady!'], function ($) {
headers: { 'X-Requested-With': 'XMLHttpRequest' },
data: { form_key: formKey, action: 'delete', ajax: '1', tunnel_id: tid }
}).done(function (res) {
flash(res.message || '', false);
location.reload();
if (res.success) {
flash(res.message || '', false);
willReload = true;
reloadToListTab();
} else {
flash(res.message || <?= json_encode((string) __('Remove failed')) ?>, true);
}
}).fail(function () {
flash('Remove failed', true);
flash(<?= json_encode((string) __('Remove failed')) ?>, true);
}).always(function () {
btn.prop('disabled', false);
if (!willReload) {
setBusy(false);
btn.prop('disabled', false);
}
});
});
@@ -430,6 +606,8 @@ require(['jquery', 'domReady!'], function ($) {
return;
}
var btn = $(this).prop('disabled', true);
var willReload = false;
setBusy(true);
$.ajax({
url: indexUrl,
type: 'POST',
@@ -454,7 +632,8 @@ require(['jquery', 'domReady!'], function ($) {
if (res.nginx_reload_required && res.nginx_reload_message) {
flashWarning(res.nginx_reload_message);
}
location.reload();
willReload = true;
reloadToListTab();
} else {
flash(res.message || 'Failed', true);
}
@@ -462,7 +641,10 @@ require(['jquery', 'domReady!'], function ($) {
var m = (xhr.responseJSON && xhr.responseJSON.message) ? xhr.responseJSON.message : 'Request failed';
flash(m, true);
}).always(function () {
btn.prop('disabled', false);
if (!willReload) {
setBusy(false);
btn.prop('disabled', false);
}
});
});
@@ -1,7 +1,109 @@
/* MageSail tunnel admin */
.magesail-tunnel#magesail-root {
position: relative;
min-height: 12rem;
}
.magesail-tunnel .magesail-busy-overlay {
position: absolute;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(1px);
}
.magesail-tunnel .magesail-busy-overlay[hidden] {
display: none !important;
}
.magesail-tunnel .magesail-busy-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 1.5rem 2rem;
background: #fff;
border: 1px solid #e3e3e3;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.magesail-tunnel .magesail-busy-spinner {
display: block;
width: 2.5rem;
height: 2.5rem;
border: 3px solid #e3e3e3;
border-top-color: #eb5202;
border-radius: 50%;
animation: magesail-spin 0.85s linear infinite;
}
.magesail-tunnel .magesail-busy-text {
font-size: 1.4rem;
color: #41362f;
}
.magesail-tunnel .admin__page-section {
margin-bottom: 2rem;
}
.magesail-tunnel .magesail-tab-bar {
display: flex;
flex-wrap: wrap;
gap: 0;
margin: 0 0 1.5rem;
border-bottom: 1px solid #e3e3e3;
}
.magesail-tunnel .magesail-tab {
position: relative;
margin: 0 0 -1px;
padding: 0.85rem 1.25rem;
font-size: 1.4rem;
font-weight: 400;
line-height: 1.36;
color: #41362f;
background: #f8f8f8;
border: 1px solid #e3e3e3;
border-bottom: none;
border-radius: 0;
cursor: pointer;
box-shadow: none;
}
.magesail-tunnel .magesail-tab + .magesail-tab {
border-left: none;
}
.magesail-tunnel .magesail-tab:hover {
background: #fff;
color: #231d1a;
}
.magesail-tunnel .magesail-tab:focus {
outline: 2px solid #007bdb;
outline-offset: 1px;
z-index: 1;
}
.magesail-tunnel .magesail-tab--active {
font-weight: 600;
color: #231d1a;
background: #fff;
border-bottom: 1px solid #fff;
box-shadow: inset 0 3px 0 #eb5202;
}
.magesail-tunnel .magesail-tab-panel {
display: none;
}
.magesail-tunnel .magesail-tab-panel.magesail-tab-panel--active {
display: block;
}
.magesail-tunnel .magesail-key-wrap {
display: flex;
flex-wrap: wrap;
+3 -1
View File
@@ -12,6 +12,8 @@ Defined in [`Magesail/etc/adminhtml/menu.xml`](../Magesail/etc/adminhtml/menu.xm
- **Layout / template**: [`view/adminhtml/layout/magesail_tunnel_index.xml`](../Magesail/view/adminhtml/layout/magesail_tunnel_index.xml), [`view/adminhtml/templates/tunnel/index.phtml`](../Magesail/view/adminhtml/templates/tunnel/index.phtml)
- **Block**: [`Block/Adminhtml/Tunnel.php`](../Magesail/Block/Adminhtml/Tunnel.php)
The same URL shows two in-page tabs: **Tunnels list** (grid, log tails, start/stop/remove) and **Add a tunnel** (provision form). With no tunnels yet, the **Add a tunnel** tab is selected by default. Optional URL fragments `#magesail-list` and `#magesail-add` (also `#list` / `#add`) open the corresponding tab after load. Long-running actions (**Create and start tunnel**, **Start** / **Stop** / **Remove** on a row) show an in-page **Please wait…** overlay; after a successful create or any successful reload, the URL is set to `#magesail-list` before reload so **Tunnels list** is active.
**Tunnel table**
- Lists all tunnels from [`TunnelRegistry`](../Magesail/Model/TunnelRegistry.php) (`var/magesail_tunnels.json`): label, **store code**, **local port**, hostname, run/stop state, Holesail key (when running), and actions.
@@ -21,7 +23,7 @@ Defined in [`Magesail/etc/adminhtml/menu.xml`](../Magesail/etc/adminhtml/menu.xm
- **Cron (dead process)** — If **Auto-restart on crash** is **No** under **MageSail → Holesail Tunnel → Configuration**, the **daily** monitor applies the same **Remove**-style teardown to any tunnel whose PID file exists but the process is no longer running (see [cron-and-monitoring.md](cron-and-monitoring.md)).
- Each row can expand **Log tail** for `var/log/magesail_tunnel_<id>.log`.
**Add tunnel** (new row)
**Add tunnel** (Add a tunnel tab — new tunnel)
- **Website** and **Store group** — required; the new dedicated store view is created under that scope.
- **Local port** — preset **443**, **80**, or **8080**, or **Custom…** with any port **165535**. Multiple tunnels may use the same port in configuration; whether more than one Node process can bind depends on the OS (see field note in Admin).