Browse Source

feat(plugin): make page tabs and sub-tabs addressable by URLs (#8626)

* feat(plugin): make page tabs and sub-tabs addressable by URLs

Update the plugin page so that clicking tabs and sub-tabs pushes
browser history via history.pushState(), enabling bookmarkable URLs,
browser back/forward navigation, and shareable links.

URL mapping:
  - /plugin              → Overview tab
  - /plugin/configuration → Configuration sub-tab
  - /plugin/detection     → Job Detection sub-tab
  - /plugin/queue         → Job Queue sub-tab
  - /plugin/execution     → Job Execution sub-tab

Job-type-specific URLs use the ?job= query parameter (e.g.,
/plugin/configuration?job=vacuum) so that a specific job type tab
is pre-selected on page load.

Changes:
- Add initialJob parameter to Plugin() template and handler
- Extract ?job= query param in renderPluginPage handler
- Add buildPluginURL/updateURL helpers in JavaScript
- Push history state on top-tab, sub-tab, and job-type clicks
- Listen for popstate to restore tab state on back/forward
- Replace initial history entry on page load via replaceState

* make popstate handler async with proper error handling

Await loadDescriptorAndConfig so data loading completes before
rendering dependent views. Log errors instead of silently
swallowing them.
pull/8632/head
Chris Lu 3 days ago
committed by GitHub
parent
commit
6fc0489dd8
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 3
      weed/admin/handlers/plugin_handlers.go
  2. 72
      weed/admin/view/app/plugin.templ
  3. 19
      weed/admin/view/app/plugin_templ.go

3
weed/admin/handlers/plugin_handlers.go

@ -53,7 +53,8 @@ func (h *PluginHandlers) ShowPluginMonitoring(w http.ResponseWriter, r *http.Req
}
func (h *PluginHandlers) renderPluginPage(w http.ResponseWriter, r *http.Request, page string) {
component := app.Plugin(page)
initialJob := r.URL.Query().Get("job")
component := app.Plugin(page, initialJob)
viewCtx := layout.NewViewContext(r, dash.UsernameFromContext(r.Context()), dash.CSRFTokenFromContext(r.Context()))
layoutComponent := layout.Layout(viewCtx, component)

72
weed/admin/view/app/plugin.templ

@ -1,13 +1,13 @@
package app
templ Plugin(page string) {
templ Plugin(page string, initialJob string) {
{{
currentPage := page
if currentPage == "" {
currentPage = "overview"
}
}}
<div class="container-fluid" id="plugin-page" data-plugin-page={ currentPage }>
<div class="container-fluid" id="plugin-page" data-plugin-page={ currentPage } data-plugin-job={ initialJob }>
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
@ -589,6 +589,7 @@ templ Plugin(page string) {
jobTypes: [],
lastDetectionByJobType: {},
initialPage: String(page.getAttribute('data-plugin-page') || 'overview').trim().toLowerCase(),
initialJob: String(page.getAttribute('data-plugin-job') || '').trim(),
activeTopTab: 'overview',
activeSubTab: 'configuration',
initialNavigationApplied: false,
@ -717,6 +718,28 @@ templ Plugin(page string) {
return 'configuration';
}
function buildPluginURL(topTab, subTab, jobType) {
if (topTab === 'overview') {
return '/plugin';
}
var effectiveSubTab = normalizeSubTab(subTab);
var path = '/plugin/' + encodeURIComponent(effectiveSubTab);
var jt = String(jobType || '').trim();
if (jt) {
path += '?job=' + encodeURIComponent(jt);
}
return path;
}
function updateURL(replace) {
var url = buildPluginURL(state.activeTopTab, state.activeSubTab, state.selectedJobType);
if (replace) {
history.replaceState({ pluginTopTab: state.activeTopTab, pluginSubTab: state.activeSubTab, pluginJob: state.selectedJobType }, '', url);
} else {
history.pushState({ pluginTopTab: state.activeTopTab, pluginSubTab: state.activeSubTab, pluginJob: state.selectedJobType }, '', url);
}
}
// Parse a job type item (string or object) into a safe object shape
function parseJobTypeItem(item) {
var jobType = '';
@ -782,8 +805,13 @@ templ Plugin(page string) {
state.activeTopTab = 'overview';
return;
}
var firstItem = parseJobTypeItem(state.jobTypes[0]);
state.selectedJobType = firstItem.jobType;
// If a specific job type was provided in the URL, use it
if (state.initialJob && hasJobType(state.initialJob)) {
state.selectedJobType = state.initialJob;
} else {
var firstItem = parseJobTypeItem(state.jobTypes[0]);
state.selectedJobType = firstItem.jobType;
}
state.activeTopTab = topTabKeyForJobType(state.selectedJobType);
}
@ -3015,6 +3043,7 @@ templ Plugin(page string) {
if (topTabKey === 'overview') {
state.activeTopTab = 'overview';
renderNavigationState();
updateURL(false);
return;
}
@ -3026,6 +3055,7 @@ templ Plugin(page string) {
state.selectedJobType = jobType;
ensureActiveNavigation();
renderNavigationState();
updateURL(false);
if (state.loadedJobType !== jobType) {
try {
await loadDescriptorAndConfig(jobType, false);
@ -3053,6 +3083,7 @@ templ Plugin(page string) {
var subTabKey = normalizeSubTab(subTab.getAttribute('data-plugin-subtab'));
state.activeSubTab = subTabKey;
renderNavigationState();
updateURL(false);
return;
}
@ -3076,11 +3107,44 @@ templ Plugin(page string) {
renderNavigationState();
await refreshAll();
// Set the initial browser history state to match the current view
updateURL(true);
state.refreshTimer = setInterval(function() {
refreshAll();
}, 3000);
}
window.addEventListener('popstate', async function(event) {
var s = event.state;
if (!s || !s.pluginTopTab) {
// No plugin state — treat as overview
state.activeTopTab = 'overview';
renderNavigationState();
return;
}
state.activeTopTab = String(s.pluginTopTab || 'overview');
state.activeSubTab = normalizeSubTab(s.pluginSubTab);
var jobType = String(s.pluginJob || '').trim();
if (jobType) {
state.selectedJobType = jobType;
}
ensureActiveNavigation();
renderNavigationState();
// If a job type is selected and not loaded yet, load it
if (state.selectedJobType && state.loadedJobType !== state.selectedJobType) {
try {
await loadDescriptorAndConfig(state.selectedJobType, false);
} catch (e) {
console.error('Failed to load data on popstate navigation:', e);
}
}
renderQueueJobs();
renderDetectionJobs();
renderExecutionJobs();
renderExecutionActivities();
});
window.addEventListener('beforeunload', function() {
if (state.refreshTimer) {
clearInterval(state.refreshTimer);

19
weed/admin/view/app/plugin_templ.go
File diff suppressed because it is too large
View File

Loading…
Cancel
Save