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.
This commit is contained in:
@@ -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) {
|
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()))
|
viewCtx := layout.NewViewContext(r, dash.UsernameFromContext(r.Context()), dash.CSRFTokenFromContext(r.Context()))
|
||||||
layoutComponent := layout.Layout(viewCtx, component)
|
layoutComponent := layout.Layout(viewCtx, component)
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
templ Plugin(page string) {
|
templ Plugin(page string, initialJob string) {
|
||||||
{{
|
{{
|
||||||
currentPage := page
|
currentPage := page
|
||||||
if currentPage == "" {
|
if currentPage == "" {
|
||||||
currentPage = "overview"
|
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="row mb-4">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
|
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
@@ -589,6 +589,7 @@ templ Plugin(page string) {
|
|||||||
jobTypes: [],
|
jobTypes: [],
|
||||||
lastDetectionByJobType: {},
|
lastDetectionByJobType: {},
|
||||||
initialPage: String(page.getAttribute('data-plugin-page') || 'overview').trim().toLowerCase(),
|
initialPage: String(page.getAttribute('data-plugin-page') || 'overview').trim().toLowerCase(),
|
||||||
|
initialJob: String(page.getAttribute('data-plugin-job') || '').trim(),
|
||||||
activeTopTab: 'overview',
|
activeTopTab: 'overview',
|
||||||
activeSubTab: 'configuration',
|
activeSubTab: 'configuration',
|
||||||
initialNavigationApplied: false,
|
initialNavigationApplied: false,
|
||||||
@@ -717,6 +718,28 @@ templ Plugin(page string) {
|
|||||||
return 'configuration';
|
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
|
// Parse a job type item (string or object) into a safe object shape
|
||||||
function parseJobTypeItem(item) {
|
function parseJobTypeItem(item) {
|
||||||
var jobType = '';
|
var jobType = '';
|
||||||
@@ -782,8 +805,13 @@ templ Plugin(page string) {
|
|||||||
state.activeTopTab = 'overview';
|
state.activeTopTab = 'overview';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var firstItem = parseJobTypeItem(state.jobTypes[0]);
|
// If a specific job type was provided in the URL, use it
|
||||||
state.selectedJobType = firstItem.jobType;
|
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);
|
state.activeTopTab = topTabKeyForJobType(state.selectedJobType);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3015,6 +3043,7 @@ templ Plugin(page string) {
|
|||||||
if (topTabKey === 'overview') {
|
if (topTabKey === 'overview') {
|
||||||
state.activeTopTab = 'overview';
|
state.activeTopTab = 'overview';
|
||||||
renderNavigationState();
|
renderNavigationState();
|
||||||
|
updateURL(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3026,6 +3055,7 @@ templ Plugin(page string) {
|
|||||||
state.selectedJobType = jobType;
|
state.selectedJobType = jobType;
|
||||||
ensureActiveNavigation();
|
ensureActiveNavigation();
|
||||||
renderNavigationState();
|
renderNavigationState();
|
||||||
|
updateURL(false);
|
||||||
if (state.loadedJobType !== jobType) {
|
if (state.loadedJobType !== jobType) {
|
||||||
try {
|
try {
|
||||||
await loadDescriptorAndConfig(jobType, false);
|
await loadDescriptorAndConfig(jobType, false);
|
||||||
@@ -3053,6 +3083,7 @@ templ Plugin(page string) {
|
|||||||
var subTabKey = normalizeSubTab(subTab.getAttribute('data-plugin-subtab'));
|
var subTabKey = normalizeSubTab(subTab.getAttribute('data-plugin-subtab'));
|
||||||
state.activeSubTab = subTabKey;
|
state.activeSubTab = subTabKey;
|
||||||
renderNavigationState();
|
renderNavigationState();
|
||||||
|
updateURL(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3076,11 +3107,44 @@ templ Plugin(page string) {
|
|||||||
renderNavigationState();
|
renderNavigationState();
|
||||||
await refreshAll();
|
await refreshAll();
|
||||||
|
|
||||||
|
// Set the initial browser history state to match the current view
|
||||||
|
updateURL(true);
|
||||||
|
|
||||||
state.refreshTimer = setInterval(function() {
|
state.refreshTimer = setInterval(function() {
|
||||||
refreshAll();
|
refreshAll();
|
||||||
}, 3000);
|
}, 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() {
|
window.addEventListener('beforeunload', function() {
|
||||||
if (state.refreshTimer) {
|
if (state.refreshTimer) {
|
||||||
clearInterval(state.refreshTimer);
|
clearInterval(state.refreshTimer);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user