admin: show maintenance lock blockers in scheduler UI (#12184)

* admin: expose maintenance lock waiters and current owner

* admin: display maintenance lock waits across scheduler pages

* admin: preserve the active lock message while acquisitions wait

* admin: synchronize lock fairness assertions with waiter registration

* admin: display lock wait start times alongside elapsed duration
This commit is contained in:
Chris Lu authored and GitHub committed 2026-10-28 21:28:15 -07:00
1 parent 6cd14f06c9
commit e7c1713457
9 files changed
+219 -26

No files matched your search

+30 -3
View File
@@ -1,6 +1,7 @@
package dash
import (
"sort"
"sync"
"time"
@@ -52,6 +53,7 @@ type AdminLockManager struct {
waitingSince time.Time
waitingReason string
currentReason string
waiters map[*LockWait]struct{}
}
func NewAdminLockManager(masterClient *wdclient.MasterClient, clientName string) *AdminLockManager {
@@ -81,13 +83,23 @@ func (m *AdminLockManager) Acquire(reason string) (func(), error) {
}
m.mu.Lock()
waiter := &LockWait{Reason: reason, Since: time.Now().UTC()}
if m.waiters == nil {
m.waiters = make(map[*LockWait]struct{})
}
m.waiters[waiter] = struct{}{}
defer func() {
m.mu.Lock()
delete(m.waiters, waiter)
m.mu.Unlock()
}()
for m.acquiring || m.releasing || (m.holdCount > 0 && time.Since(m.heldSince) > m.fairnessWindow) {
m.cond.Wait()
}
if reason != "" {
m.locker.SetMessage(reason)
m.currentReason = reason
}
for m.acquiring || m.releasing || (m.holdCount > 0 && time.Since(m.heldSince) > m.fairnessWindow) {
m.cond.Wait()
}
if m.holdCount == 0 {
m.acquiring = true
m.waitingSince = time.Now().UTC()
@@ -153,6 +165,11 @@ func (m *AdminLockManager) Release() {
}
}
type LockWait struct {
Reason string `json:"reason"`
Since time.Time `json:"since"`
}
type LockStatus struct {
Held bool `json:"held"`
HoldCount int `json:"hold_count"`
@@ -162,6 +179,10 @@ type LockStatus struct {
LastAcquiredAt *time.Time `json:"last_acquired_at,omitempty"`
LastReleasedAt *time.Time `json:"last_released_at,omitempty"`
WaitingSince *time.Time `json:"waiting_since,omitempty"`
Waiters []LockWait `json:"waiters,omitempty"`
Holder string `json:"holder,omitempty"`
HolderMessage string `json:"holder_message,omitempty"`
HolderError string `json:"holder_error,omitempty"`
}
func (m *AdminLockManager) Status() LockStatus {
@@ -191,5 +212,11 @@ func (m *AdminLockManager) Status() LockStatus {
at := m.waitingSince
status.WaitingSince = &at
}
for waiter := range m.waiters {
status.Waiters = append(status.Waiters, *waiter)
}
sort.Slice(status.Waiters, func(i, j int) bool {
return status.Waiters[i].Since.Before(status.Waiters[j].Since)
})
return status
}
+50 -3
View File
@@ -4,10 +4,14 @@ import (
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
)
type fakeAdminLocker struct {
releaseDelay time.Duration
releaseDelay time.Duration
requestStarted chan struct{}
requestGate <-chan struct{}
mu sync.Mutex
requests []time.Time
@@ -15,11 +19,44 @@ type fakeAdminLocker struct {
}
func (f *fakeAdminLocker) RequestLock(clientName string) {
if f.requestStarted != nil {
close(f.requestStarted)
}
if f.requestGate != nil {
<-f.requestGate
}
f.mu.Lock()
f.requests = append(f.requests, time.Now())
f.mu.Unlock()
}
func TestAdminLockManagerStatusWhileWaiting(t *testing.T) {
for _, reason := range []string{"plugin scheduler vacuum detection", "plugin scheduler vacuum job vacuum-1"} {
t.Run(reason, func(t *testing.T) {
gate := make(chan struct{})
locker := &fakeAdminLocker{requestStarted: make(chan struct{}), requestGate: gate}
m := newTestLockManager(locker, 0, time.Minute)
done := make(chan func(), 1)
go func() {
release, _ := m.Acquire(reason)
done <- release
}()
<-locker.requestStarted
status := m.Status()
close(gate)
release := <-done
defer release()
if len(status.Waiters) != 1 || status.Waiters[0].Reason != reason || status.Waiters[0].Since.IsZero() {
t.Fatalf("missing lock wait: %+v", status)
}
status = m.Status()
if len(status.Waiters) != 0 || !status.Held {
t.Fatalf("an acquired lock must not be reported as blocked: %+v", status)
}
})
}
}
func (f *fakeAdminLocker) ReleaseLock() {
if f.releaseDelay > 0 {
time.Sleep(f.releaseDelay)
@@ -97,6 +134,8 @@ func TestAdminLockManagerFairnessWindowForcesFullRelease(t *testing.T) {
m := newTestLockManager(locker, 20*time.Millisecond, 50*time.Millisecond)
release1, _ := m.Acquire("long-hold")
releaseFirst := sync.OnceFunc(release1)
defer releaseFirst()
m.mu.Lock()
m.heldSince = time.Now().Add(-time.Second)
m.mu.Unlock()
@@ -107,13 +146,21 @@ func TestAdminLockManagerFairnessWindowForcesFullRelease(t *testing.T) {
acquired <- release2
}()
require.Eventually(t, func() bool { return len(m.Status().Waiters) == 1 }, time.Second, time.Millisecond)
select {
case <-acquired:
t.Fatalf("late acquire joined a hold older than the fairness window")
case <-time.After(50 * time.Millisecond):
default:
}
release1()
status := m.Status()
if status.Message != "long-hold" {
t.Errorf("waiter replaced the current holder's message: %q", status.Message)
}
if len(status.Waiters) != 1 || status.Waiters[0].Reason != "late-joiner" {
t.Errorf("missing fairness waiter: %+v", status.Waiters)
}
releaseFirst()
select {
case release2 := <-acquired:
+22 -2
View File
@@ -304,6 +304,26 @@ func (s *AdminServer) GetPluginSchedulerStatusAPI(w http.ResponseWriter, r *http
laneFilter := strings.TrimSpace(r.URL.Query().Get("lane"))
var lockStatus LockStatus
if s.pluginLock != nil && (laneFilter == "" || plugin.LaneRequiresLock(plugin.SchedulerLane(laneFilter))) {
lockStatus = s.pluginLock.Status()
if len(lockStatus.Waiters) > 0 && s.masterClient != nil {
ctx, cancel := context.WithTimeout(r.Context(), time.Second)
err := s.masterClient.WithClient(ctx, false, func(client master_pb.SeaweedClient) error {
holder, err := client.GetAdminLockStatus(ctx, &master_pb.GetAdminLockStatusRequest{LockName: cluster.AdminShellLockName})
if err == nil && holder.GetIsLocked() {
lockStatus.Holder = holder.GetClientName()
lockStatus.HolderMessage = holder.GetMessage()
}
return err
})
cancel()
if err != nil {
lockStatus.HolderError = "Lock owner unavailable"
}
}
}
if laneFilter != "" {
lane := plugin.SchedulerLane(laneFilter)
response := map[string]interface{}{
@@ -311,7 +331,7 @@ func (s *AdminServer) GetPluginSchedulerStatusAPI(w http.ResponseWriter, r *http
"scheduler": pluginSvc.GetLaneSchedulerStatus(lane),
}
if s.pluginLock != nil {
response["lock"] = s.pluginLock.Status()
response["lock"] = lockStatus
}
writeJSON(w, http.StatusOK, response)
return
@@ -322,7 +342,7 @@ func (s *AdminServer) GetPluginSchedulerStatusAPI(w http.ResponseWriter, r *http
"scheduler": pluginSvc.GetSchedulerStatus(),
}
if s.pluginLock != nil {
response["lock"] = s.pluginLock.Status()
response["lock"] = lockStatus
}
writeJSON(w, http.StatusOK, response)
+8 -4
View File
@@ -69,6 +69,7 @@ templ Plugin(page string, initialJob string, lane string) {
</div>
</div>
@PluginSchedulerStatus()
<div class="plugin-section plugin-section-overview">
<div class="row mb-4">
<div class="col-md-3 mb-3">
@@ -98,7 +99,7 @@ templ Plugin(page string, initialJob string, lane string) {
<div class="col-md-3 mb-3">
<div class="card border-secondary h-100">
<div class="card-body text-center">
<div class="text-uppercase text-muted small mb-1">Next Run</div>
<div class="text-uppercase text-muted small mb-1">Next scheduled check</div>
<h4 class="mb-0 plugin-scheduler-next-run">-</h4>
<div class="text-muted small plugin-scheduler-next-run-meta"></div>
</div>
@@ -258,7 +259,7 @@ templ Plugin(page string, initialJob string, lane string) {
</div>
</div>
<div class="col-12">
<label class="form-label" for="plugin-admin-detection-interval">Detection Interval (min)</label>
<label class="form-label" for="plugin-admin-detection-interval">Scheduled detection interval (min)</label>
<input type="number" class="form-control" id="plugin-admin-detection-interval" min="0"/>
<div class="form-text">How often to check for new work.</div>
</div>
@@ -301,7 +302,7 @@ templ Plugin(page string, initialJob string, lane string) {
<div class="card shadow-sm mt-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-hourglass-split me-2"></i>Next Run</h5>
<h5 class="mb-0"><i class="bi bi-hourglass-split me-2"></i>Next scheduled check</h5>
<small class="text-muted">Scheduler</small>
</div>
<div class="card-body">
@@ -616,6 +617,7 @@ templ Plugin(page string, initialJob string, lane string) {
activities: [],
schedulerStates: [],
schedulerStatus: null,
schedulerLock: null,
allJobs: [],
allActivities: [],
loadedJobType: '',
@@ -1606,6 +1608,7 @@ templ Plugin(page string, initialJob string, lane string) {
}
function renderSchedulerStatus() {
window.renderPluginSchedulerStatus({scheduler: state.schedulerStatus, lock: state.schedulerLock});
var valueNodes = document.querySelectorAll('.plugin-scheduler-next-run');
if (!valueNodes.length) {
return;
@@ -1617,7 +1620,7 @@ templ Plugin(page string, initialJob string, lane string) {
valueNodes.forEach(function(node) {
node.textContent = display;
});
var metaText = nextRun ? 'Local time' : 'Not scheduled';
var metaText = nextRun ? 'Next scheduled check (local time); locks and other jobs may delay detection.' : 'Not scheduled';
if (metaNodes.length) {
metaNodes.forEach(function(node) {
node.textContent = metaText;
@@ -3076,6 +3079,7 @@ templ Plugin(page string, initialJob string, lane string) {
state.allActivities = state.activities;
state.schedulerStates = Array.isArray(schedulerStates) ? schedulerStates : [];
state.schedulerStatus = schedulerStatus && schedulerStatus.scheduler ? schedulerStatus.scheduler : null;
state.schedulerLock = schedulerStatus && schedulerStatus.lock ? schedulerStatus.lock : null;
renderQueueJobs();
renderDetectionJobs();
renderExecutionJobs();
+6 -2
View File
@@ -31,6 +31,7 @@ templ PluginLane(page string, lane string) {
</div>
</div>
@PluginSchedulerStatus()
<div class="row mb-3">
<div class="col-12">
<div class="card">
@@ -102,16 +103,17 @@ templ PluginLane(page string, lane string) {
}
function renderSchedulerStatus(data) {
window.renderPluginSchedulerStatus(data || {});
if (!data || !data.scheduler) return '<div class="text-muted">No scheduler data</div>';
const s = data.scheduler;
let html = '<div class="row">';
html += '<div class="col-md-3"><strong>Phase:</strong> ' + (s.current_phase || 'idle') + '</div>';
html += '<div class="col-md-3"><strong>Idle Sleep:</strong> ' + (s.idle_sleep_seconds || 0) + 's</div>';
html += '<div class="col-md-3"><strong>Current Job Type:</strong> ' + (s.current_job_type || '-') + '</div>';
html += '<div class="col-md-3"><strong>Next Detection:</strong> ' + (s.next_detection_at ? new Date(s.next_detection_at).toLocaleTimeString() : '-') + '</div>';
html += '<div class="col-md-3"><strong>Next scheduled check:</strong> ' + (s.next_detection_at ? new Date(s.next_detection_at).toLocaleString() : '-') + '</div>';
html += '</div>';
if (s.job_types && s.job_types.length > 0) {
html += '<hr><table class="table table-sm table-hover mb-0"><thead><tr><th>Job Type</th><th>Enabled</th><th>Interval</th><th>In Flight</th><th>Next Detection</th></tr></thead><tbody>';
html += '<hr><table class="table table-sm table-hover mb-0"><thead><tr><th>Job Type</th><th>Enabled</th><th>Interval</th><th>In Flight</th><th>Next scheduled check</th></tr></thead><tbody>';
s.job_types.forEach(jt => {
html += '<tr>';
html += '<td>' + jt.job_type + '</td>';
@@ -168,6 +170,8 @@ templ PluginLane(page string, lane string) {
}
refresh();
const timer = setInterval(refresh, 10000);
window.addEventListener('beforeunload', function() { clearInterval(timer); });
const btn = document.getElementById('plugin-lane-refresh-btn');
if (btn) btn.addEventListener('click', refresh);
})();
+14 -6
View File
@@ -41,7 +41,7 @@ func PluginLane(page string, lane string) templ.Component {
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templruntime.NewAttributeExpression(currentPage).Resolve(ctx)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/plugin_lane.templ`, Line: 14, Col: 85}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_lane.templ`, Line: 14, Col: 85}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil {
@@ -54,7 +54,7 @@ func PluginLane(page string, lane string) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templruntime.NewAttributeExpression(lane).Resolve(ctx)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/plugin_lane.templ`, Line: 14, Col: 111}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_lane.templ`, Line: 14, Col: 111}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
@@ -67,7 +67,7 @@ func PluginLane(page string, lane string) templ.Component {
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(lane)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/plugin_lane.templ`, Line: 19, Col: 80}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_lane.templ`, Line: 19, Col: 80}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -80,7 +80,7 @@ func PluginLane(page string, lane string) templ.Component {
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lane)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/plugin_lane.templ`, Line: 20, Col: 94}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_lane.templ`, Line: 20, Col: 94}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -93,13 +93,21 @@ func PluginLane(page string, lane string) templ.Component {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templruntime.NewAttributeExpression(dash.PUrl(ctx, "/plugin")).ResolveURL(ctx)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/plugin_lane.templ`, Line: 23, Col: 59}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin_lane.templ`, Line: 23, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" class=\"btn btn-outline-secondary\"><i class=\"bi bi-arrow-left me-1\"></i>All Workers</a> <button type=\"button\" class=\"btn btn-outline-secondary\" id=\"plugin-lane-refresh-btn\"><i class=\"bi bi-arrow-repeat me-1\"></i>Refresh</button></div></div></div></div><div class=\"row mb-3\"><div class=\"col-12\"><div class=\"card\"><div class=\"card-header\"><h5 class=\"card-title mb-0\"><i class=\"bi bi-server me-2\"></i>Lane Scheduler Status</h5></div><div class=\"card-body\" id=\"plugin-lane-scheduler-status\"><div class=\"text-center text-muted py-3\"><i class=\"bi bi-arrow-repeat icon-spin me-1\"></i>Loading scheduler status...</div></div></div></div></div><div class=\"row mb-3\"><div class=\"col-12\"><div class=\"card\"><div class=\"card-header\"><h5 class=\"card-title mb-0\"><i class=\"bi bi-list-check me-2\"></i>Job Types</h5></div><div class=\"card-body\" id=\"plugin-lane-job-types\"><div class=\"text-center text-muted py-3\"><i class=\"bi bi-arrow-repeat icon-spin me-1\"></i>Loading job types...</div></div></div></div></div><div class=\"row\"><div class=\"col-12\"><div class=\"card\"><div class=\"card-header\"><h5 class=\"card-title mb-0\"><i class=\"bi bi-plug me-2\"></i>Connected Workers</h5></div><div class=\"card-body\" id=\"plugin-lane-workers\"><div class=\"text-center text-muted py-3\"><i class=\"bi bi-arrow-repeat icon-spin me-1\"></i>Loading workers...</div></div></div></div></div></div><script>\n (function() {\n const page = document.getElementById('plugin-lane-page');\n if (!page) return;\n const lane = page.dataset.pluginLane || '';\n if (!lane) return;\n\n function basePath(path) {\n return (window.__BASE_PATH__ || '') + path;\n }\n\n function fetchAndRender(url, containerId, renderFn) {\n fetch(basePath(url))\n .then(r => r.json())\n .then(data => {\n const el = document.getElementById(containerId);\n if (el) el.innerHTML = renderFn(data);\n })\n .catch(err => {\n const el = document.getElementById(containerId);\n if (el) el.innerHTML = '<div class=\"alert alert-danger\">Failed to load: ' + err.message + '</div>';\n });\n }\n\n function renderSchedulerStatus(data) {\n if (!data || !data.scheduler) return '<div class=\"text-muted\">No scheduler data</div>';\n const s = data.scheduler;\n let html = '<div class=\"row\">';\n html += '<div class=\"col-md-3\"><strong>Phase:</strong> ' + (s.current_phase || 'idle') + '</div>';\n html += '<div class=\"col-md-3\"><strong>Idle Sleep:</strong> ' + (s.idle_sleep_seconds || 0) + 's</div>';\n html += '<div class=\"col-md-3\"><strong>Current Job Type:</strong> ' + (s.current_job_type || '-') + '</div>';\n html += '<div class=\"col-md-3\"><strong>Next Detection:</strong> ' + (s.next_detection_at ? new Date(s.next_detection_at).toLocaleTimeString() : '-') + '</div>';\n html += '</div>';\n if (s.job_types && s.job_types.length > 0) {\n html += '<hr><table class=\"table table-sm table-hover mb-0\"><thead><tr><th>Job Type</th><th>Enabled</th><th>Interval</th><th>In Flight</th><th>Next Detection</th></tr></thead><tbody>';\n s.job_types.forEach(jt => {\n html += '<tr>';\n html += '<td>' + jt.job_type + '</td>';\n html += '<td>' + (jt.enabled ? '<span class=\"badge bg-success\">Yes</span>' : '<span class=\"badge bg-secondary\">No</span>') + '</td>';\n html += '<td>' + (jt.detection_interval_minutes || '-') + 'm</td>';\n html += '<td>' + (jt.detection_in_flight ? '<span class=\"badge bg-warning\">Yes</span>' : 'No') + '</td>';\n html += '<td>' + (jt.next_detection_at ? new Date(jt.next_detection_at).toLocaleTimeString() : '-') + '</td>';\n html += '</tr>';\n });\n html += '</tbody></table>';\n }\n return html;\n }\n\n function renderJobTypes(data) {\n if (!data || data.length === 0) return '<div class=\"text-muted\">No job types in this lane</div>';\n const filtered = data.filter(s => s.lane === lane);\n if (filtered.length === 0) return '<div class=\"text-muted\">No job types in this lane</div>';\n let html = '<table class=\"table table-sm table-hover mb-0\"><thead><tr><th>Job Type</th><th>Enabled</th><th>Concurrency</th><th>Detection Interval</th><th>Status</th></tr></thead><tbody>';\n filtered.forEach(s => {\n html += '<tr>';\n html += '<td><a href=\"' + basePath('/plugin/configuration?job=' + s.job_type) + '\">' + s.job_type + '</a></td>';\nLine truncated
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" class=\"btn btn-outline-secondary\"><i class=\"bi bi-arrow-left me-1\"></i>All Workers</a> <button type=\"button\" class=\"btn btn-outline-secondary\" id=\"plugin-lane-refresh-btn\"><i class=\"bi bi-arrow-repeat me-1\"></i>Refresh</button></div></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = PluginSchedulerStatus().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"row mb-3\"><div class=\"col-12\"><div class=\"card\"><div class=\"card-header\"><h5 class=\"card-title mb-0\"><i class=\"bi bi-server me-2\"></i>Lane Scheduler Status</h5></div><div class=\"card-body\" id=\"plugin-lane-scheduler-status\"><div class=\"text-center text-muted py-3\"><i class=\"bi bi-arrow-repeat icon-spin me-1\"></i>Loading scheduler status...</div></div></div></div></div><div class=\"row mb-3\"><div class=\"col-12\"><div class=\"card\"><div class=\"card-header\"><h5 class=\"card-title mb-0\"><i class=\"bi bi-list-check me-2\"></i>Job Types</h5></div><div class=\"card-body\" id=\"plugin-lane-job-types\"><div class=\"text-center text-muted py-3\"><i class=\"bi bi-arrow-repeat icon-spin me-1\"></i>Loading job types...</div></div></div></div></div><div class=\"row\"><div class=\"col-12\"><div class=\"card\"><div class=\"card-header\"><h5 class=\"card-title mb-0\"><i class=\"bi bi-plug me-2\"></i>Connected Workers</h5></div><div class=\"card-body\" id=\"plugin-lane-workers\"><div class=\"text-center text-muted py-3\"><i class=\"bi bi-arrow-repeat icon-spin me-1\"></i>Loading workers...</div></div></div></div></div></div><script>\n (function() {\n const page = document.getElementById('plugin-lane-page');\n if (!page) return;\n const lane = page.dataset.pluginLane || '';\n if (!lane) return;\n\n function basePath(path) {\n return (window.__BASE_PATH__ || '') + path;\n }\n\n function fetchAndRender(url, containerId, renderFn) {\n fetch(basePath(url))\n .then(r => r.json())\n .then(data => {\n const el = document.getElementById(containerId);\n if (el) el.innerHTML = renderFn(data);\n })\n .catch(err => {\n const el = document.getElementById(containerId);\n if (el) el.innerHTML = '<div class=\"alert alert-danger\">Failed to load: ' + err.message + '</div>';\n });\n }\n\n function renderSchedulerStatus(data) {\n window.renderPluginSchedulerStatus(data || {});\n if (!data || !data.scheduler) return '<div class=\"text-muted\">No scheduler data</div>';\n const s = data.scheduler;\n let html = '<div class=\"row\">';\n html += '<div class=\"col-md-3\"><strong>Phase:</strong> ' + (s.current_phase || 'idle') + '</div>';\n html += '<div class=\"col-md-3\"><strong>Idle Sleep:</strong> ' + (s.idle_sleep_seconds || 0) + 's</div>';\n html += '<div class=\"col-md-3\"><strong>Current Job Type:</strong> ' + (s.current_job_type || '-') + '</div>';\n html += '<div class=\"col-md-3\"><strong>Next scheduled check:</strong> ' + (s.next_detection_at ? new Date(s.next_detection_at).toLocaleString() : '-') + '</div>';\n html += '</div>';\n if (s.job_types && s.job_types.length > 0) {\n html += '<hr><table class=\"table table-sm table-hover mb-0\"><thead><tr><th>Job Type</th><th>Enabled</th><th>Interval</th><th>In Flight</th><th>Next scheduled check</th></tr></thead><tbody>';\n s.job_types.forEach(jt => {\n html += '<tr>';\n html += '<td>' + jt.job_type + '</td>';\n html += '<td>' + (jt.enabled ? '<span class=\"badge bg-success\">Yes</span>' : '<span class=\"badge bg-secondary\">No</span>') + '</td>';\n html += '<td>' + (jt.detection_interval_minutes || '-') + 'm</td>';\n html += '<td>' + (jt.detection_in_flight ? '<span class=\"badge bg-warning\">Yes</span>' : 'No') + '</td>';\n html += '<td>' + (jt.next_detection_at ? new Date(jt.next_detection_at).toLocaleTimeString() : '-') + '</td>';\n html += '</tr>';\n });\n html += '</tbody></table>';\n }\n return html;\n }\n\n function renderJobTypes(data) {\n if (!data || data.length === 0) return '<div class=\"text-muted\">No job types in this lane</div>';\n const filtered = data.filter(s => s.lane === lane);\n if (filtered.length === 0) return '<div class=\"text-muted\">No job types in this lane</div>';\n let html = '<table class=\"table table-sm table-hover mb-0\"><thead><tr><th>Job Type</th><th>Enabled</th><th>Concurrency</th><th>Detection Interval</th><th>Status</th></tr></thead><tbody>';\n filtered.forEach(s => {\n html += '<tr>';\n html += '<td><a href=\"' + basePath('/plugin/configuration?job=' + s.job_type) + '\">' + s.job_type + '</a></td>';\n html += '<td>' + (s.enabled ? '<span class=\"badge bg-success\">Yes</span>' : '<span class=\"badge bg-secondary\">No</span>') + '</td>';\n html += '<td>' + (s.global_Line truncated
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -0,0 +1,38 @@
package app
templ PluginSchedulerStatus() {
<div id="plugin-scheduler-blockers" aria-live="polite"></div>
<script>
window.renderPluginSchedulerStatus = function(data) {
const root = document.getElementById('plugin-scheduler-blockers');
if (!root) return;
root.replaceChildren();
const lock = data.lock || {};
const waiters = lock.waiters || [];
if (!waiters.length) return;
const alert = document.createElement('div');
alert.className = 'alert alert-warning';
const title = document.createElement('strong');
title.textContent = 'Waiting for the maintenance lock';
alert.appendChild(title);
const owner = document.createElement('div');
owner.textContent = lock.holder ? 'Lock owner: ' + lock.holder : (lock.holder_error || 'Lock owner not reported');
if (lock.holder_message) owner.textContent += ' — ' + lock.holder_message;
alert.appendChild(owner);
waiters.forEach(function(waiter) {
const line = document.createElement('div');
const since = new Date(waiter.since);
const now = new Date((data.scheduler || {}).now || Date.now());
const seconds = Math.max(0, Math.floor((now - since) / 1000));
line.textContent = waiter.reason + ' · since ' + since.toLocaleString() +
' (local time) · waiting ' + Math.floor(seconds / 60) + 'm ' + (seconds % 60) + 's';
alert.appendChild(line);
});
const note = document.createElement('div');
note.className = 'small mt-1';
note.textContent = 'This lane cannot advance until the lock becomes available. A scheduled check time is not a guaranteed start time.';
alert.appendChild(note);
root.appendChild(alert);
};
</script>
}
@@ -0,0 +1,37 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1100
package app
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func PluginSchedulerStatus() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"plugin-scheduler-blockers\" aria-live=\"polite\"></div><script>\n window.renderPluginSchedulerStatus = function(data) {\n const root = document.getElementById('plugin-scheduler-blockers');\n if (!root) return;\n root.replaceChildren();\n const lock = data.lock || {};\n const waiters = lock.waiters || [];\n if (!waiters.length) return;\n const alert = document.createElement('div');\n alert.className = 'alert alert-warning';\n const title = document.createElement('strong');\n title.textContent = 'Waiting for the maintenance lock';\n alert.appendChild(title);\n const owner = document.createElement('div');\n owner.textContent = lock.holder ? 'Lock owner: ' + lock.holder : (lock.holder_error || 'Lock owner not reported');\n if (lock.holder_message) owner.textContent += ' — ' + lock.holder_message;\n alert.appendChild(owner);\n waiters.forEach(function(waiter) {\n const line = document.createElement('div');\n const since = new Date(waiter.since);\n const now = new Date((data.scheduler || {}).now || Date.now());\n const seconds = Math.max(0, Math.floor((now - since) / 1000));\n line.textContent = waiter.reason + ' · since ' + since.toLocaleString() +\n ' (local time) · waiting ' + Math.floor(seconds / 60) + 'm ' + (seconds % 60) + 's';\n alert.appendChild(line);\n });\n const note = document.createElement('div');\n note.className = 'small mt-1';\n note.textContent = 'This lane cannot advance until the lock becomes available. A scheduled check time is not a guaranteed start time.';\n alert.appendChild(note);\n root.appendChild(alert);\n };\n </script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+14 -6
View File
@@ -43,7 +43,7 @@ func Plugin(page string, initialJob string, lane string) templ.Component {
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templruntime.NewAttributeExpression(currentPage).Resolve(ctx)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/plugin.templ`, Line: 16, Col: 80}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin.templ`, Line: 16, Col: 80}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil {
@@ -56,7 +56,7 @@ func Plugin(page string, initialJob string, lane string) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templruntime.NewAttributeExpression(initialJob).Resolve(ctx)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/plugin.templ`, Line: 16, Col: 111}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin.templ`, Line: 16, Col: 111}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
@@ -69,7 +69,7 @@ func Plugin(page string, initialJob string, lane string) templ.Component {
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templruntime.NewAttributeExpression(currentLane).Resolve(ctx)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/plugin.templ`, Line: 16, Col: 144}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin.templ`, Line: 16, Col: 144}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
@@ -82,7 +82,7 @@ func Plugin(page string, initialJob string, lane string) templ.Component {
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(laneTitle)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/plugin.templ`, Line: 21, Col: 83}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin.templ`, Line: 21, Col: 83}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -95,13 +95,21 @@ func Plugin(page string, initialJob string, lane string) templ.Component {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(laneDescription)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/plugin.templ`, Line: 22, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/plugin.templ`, Line: 22, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</p></div><div class=\"btn-group\"><button type=\"button\" class=\"btn btn-outline-secondary\" id=\"plugin-refresh-all-btn\"><i class=\"bi bi-arrow-repeat me-1\"></i>Refresh</button></div></div></div></div><div class=\"row mb-3\"><div class=\"col-12\"><ul class=\"nav nav-tabs\" id=\"plugin-top-tabs\"><li class=\"nav-item\"><button type=\"button\" class=\"nav-link active\" data-plugin-top-tab=\"overview\"><i class=\"bi bi-graph-up-arrow me-1\"></i>Overview</button></li></ul></div></div><div class=\"row mb-3 d-none\" id=\"plugin-subtab-row\"><div class=\"col-12\"><ul class=\"nav nav-pills gap-2\" id=\"plugin-subtabs\"><li class=\"nav-item\"><button type=\"button\" class=\"nav-link active\" data-plugin-subtab=\"configuration\"><i class=\"bi bi-sliders me-1\"></i>Configuration</button></li><li class=\"nav-item\"><button type=\"button\" class=\"nav-link\" data-plugin-subtab=\"detection\"><i class=\"bi bi-search me-1\"></i>Job Detection</button></li><li class=\"nav-item\"><button type=\"button\" class=\"nav-link\" data-plugin-subtab=\"queue\"><i class=\"bi bi-list me-1\"></i>Job Queue</button></li><li class=\"nav-item\"><button type=\"button\" class=\"nav-link\" data-plugin-subtab=\"execution\"><i class=\"bi bi-list-check me-1\"></i>Job Execution</button></li></ul></div></div><div class=\"plugin-section plugin-section-overview\"><div class=\"row mb-4\"><div class=\"col-md-3 mb-3\"><div class=\"card border-info h-100\"><div class=\"card-body text-center\"><div class=\"text-uppercase text-muted small mb-1\">Workers</div><h4 class=\"mb-0\" id=\"plugin-status-workers\">0</h4></div></div></div><div class=\"col-md-3 mb-3\"><div class=\"card border-warning h-100\"><div class=\"card-body text-center\"><div class=\"text-uppercase text-muted small mb-1\">Active Jobs</div><h4 class=\"mb-0\" id=\"plugin-status-active-jobs\">0</h4></div></div></div><div class=\"col-md-3 mb-3\"><div class=\"card border-success h-100\"><div class=\"card-body text-center\"><div class=\"text-uppercase text-muted small mb-1\">Activities (recent)</div><h4 class=\"mb-0\" id=\"plugin-status-activities\">0</h4></div></div></div><div class=\"col-md-3 mb-3\"><div class=\"card border-secondary h-100\"><div class=\"card-body text-center\"><div class=\"text-uppercase text-muted small mb-1\">Next Run</div><h4 class=\"mb-0 plugin-scheduler-next-run\">-</h4><div class=\"text-muted small plugin-scheduler-next-run-meta\"></div></div></div></div></div><div class=\"row mb-4\"><div class=\"col-12\"><div class=\"card shadow-sm\"><div class=\"card-header\"><h5 class=\"mb-0\"><i class=\"bi bi-bar-chart me-2\"></i>Per Job Type Summary</h5></div><div class=\"card-body p-0\"><div class=\"table-responsive\"><table class=\"table table-sm table-hover mb-0\"><thead class=\"table-light\"><tr><th>Job Type</th><th>Active Jobs</th><th>Recent Activities</th></tr></thead> <tbody id=\"plugin-jobtype-summary-body\"><tr><td colspan=\"3\" class=\"text-muted text-center py-3\">Loading...</td></tr></tbody></table></div></div></div></div></div><div class=\"row mb-4\"><div class=\"col-12\"><div class=\"card shadow-sm\"><div class=\"card-header d-flex justify-content-between align-items-center flex-wrap gap-2\"><h5 class=\"mb-0\"><i class=\"bi bi-clock me-2\"></i>Scheduler State</h5><small class=\"text-muted\">Sequential scheduler with per-job runtime limits</small></div><div class=\"card-body p-0\"><div class=\"table-responsive\"><table class=\"table table-sm table-hover mb-0\"><thead class=\"table-light\"><tr><th>Job Type</th><th>Enabled</th><th>Detector</th><th>In Flight</th><th>Max Runtime</th><th>Exec Global</th><th>Exec/Worker</th><th>Executor Workers</th><th>Effective Exec</th><th>Last Run</th></tr></thead> <tbody id=\"plugin-scheduler-table-body\"><tr><td colspan=\"10\" class=\"text-muted text-center py-3\">Loading...</td></tr></tbody></table></div></div></div></div></div><div class=\"row mb-4\"><div class=\"col-12\"><div class=\"card shadow-sm\"><div class=\"card-header\"><h5 class=\"mb-0\"><i class=\"bi bi-people me-2\"></i>Workers</h5></div><div class=\"card-body p-0\"><div class=\"table-responsive\" style=\"max-height: 560px; overflow-y: auto;\"><table class=\"table table-sm table-hover mb-0\"><thead class=\"table-light sticky-top\"><tr><th>Worker</th><th>Address</th><th>Capabilities</th><th>Load</th></tr></thead> <tbody id=\"plugin-workers-table-body\"><tr><td colspan=\"4\" class=\"text-muted text-center py-3\">Loading...</td></tr></tbody></table></div></div></div></div></div></div><div class=\"plugin-section plugin-section-configuration\"><div class=\"row mb-4\"><div class=\"col-lg-8 mb-3\"><div class=\"card shadow-sm h-100\"><div class=\"card-header d-flex justify-content-between align-items-center\"><h5 class=\"mb-0\"><i class=\"bi bi-sliders me-2\"></i>Job Type Configuration</h5><small class=\"text-muted\" id=\"plugin-config-updated-at\">Not loaded</small></div><div class=\"card-body\"><div class=Line truncated
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</p></div><div class=\"btn-group\"><button type=\"button\" class=\"btn btn-outline-secondary\" id=\"plugin-refresh-all-btn\"><i class=\"bi bi-arrow-repeat me-1\"></i>Refresh</button></div></div></div></div><div class=\"row mb-3\"><div class=\"col-12\"><ul class=\"nav nav-tabs\" id=\"plugin-top-tabs\"><li class=\"nav-item\"><button type=\"button\" class=\"nav-link active\" data-plugin-top-tab=\"overview\"><i class=\"bi bi-graph-up-arrow me-1\"></i>Overview</button></li></ul></div></div><div class=\"row mb-3 d-none\" id=\"plugin-subtab-row\"><div class=\"col-12\"><ul class=\"nav nav-pills gap-2\" id=\"plugin-subtabs\"><li class=\"nav-item\"><button type=\"button\" class=\"nav-link active\" data-plugin-subtab=\"configuration\"><i class=\"bi bi-sliders me-1\"></i>Configuration</button></li><li class=\"nav-item\"><button type=\"button\" class=\"nav-link\" data-plugin-subtab=\"detection\"><i class=\"bi bi-search me-1\"></i>Job Detection</button></li><li class=\"nav-item\"><button type=\"button\" class=\"nav-link\" data-plugin-subtab=\"queue\"><i class=\"bi bi-list me-1\"></i>Job Queue</button></li><li class=\"nav-item\"><button type=\"button\" class=\"nav-link\" data-plugin-subtab=\"execution\"><i class=\"bi bi-list-check me-1\"></i>Job Execution</button></li></ul></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = PluginSchedulerStatus().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"plugin-section plugin-section-overview\"><div class=\"row mb-4\"><div class=\"col-md-3 mb-3\"><div class=\"card border-info h-100\"><div class=\"card-body text-center\"><div class=\"text-uppercase text-muted small mb-1\">Workers</div><h4 class=\"mb-0\" id=\"plugin-status-workers\">0</h4></div></div></div><div class=\"col-md-3 mb-3\"><div class=\"card border-warning h-100\"><div class=\"card-body text-center\"><div class=\"text-uppercase text-muted small mb-1\">Active Jobs</div><h4 class=\"mb-0\" id=\"plugin-status-active-jobs\">0</h4></div></div></div><div class=\"col-md-3 mb-3\"><div class=\"card border-success h-100\"><div class=\"card-body text-center\"><div class=\"text-uppercase text-muted small mb-1\">Activities (recent)</div><h4 class=\"mb-0\" id=\"plugin-status-activities\">0</h4></div></div></div><div class=\"col-md-3 mb-3\"><div class=\"card border-secondary h-100\"><div class=\"card-body text-center\"><div class=\"text-uppercase text-muted small mb-1\">Next scheduled check</div><h4 class=\"mb-0 plugin-scheduler-next-run\">-</h4><div class=\"text-muted small plugin-scheduler-next-run-meta\"></div></div></div></div></div><div class=\"row mb-4\"><div class=\"col-12\"><div class=\"card shadow-sm\"><div class=\"card-header\"><h5 class=\"mb-0\"><i class=\"bi bi-bar-chart me-2\"></i>Per Job Type Summary</h5></div><div class=\"card-body p-0\"><div class=\"table-responsive\"><table class=\"table table-sm table-hover mb-0\"><thead class=\"table-light\"><tr><th>Job Type</th><th>Active Jobs</th><th>Recent Activities</th></tr></thead> <tbody id=\"plugin-jobtype-summary-body\"><tr><td colspan=\"3\" class=\"text-muted text-center py-3\">Loading...</td></tr></tbody></table></div></div></div></div></div><div class=\"row mb-4\"><div class=\"col-12\"><div class=\"card shadow-sm\"><div class=\"card-header d-flex justify-content-between align-items-center flex-wrap gap-2\"><h5 class=\"mb-0\"><i class=\"bi bi-clock me-2\"></i>Scheduler State</h5><small class=\"text-muted\">Sequential scheduler with per-job runtime limits</small></div><div class=\"card-body p-0\"><div class=\"table-responsive\"><table class=\"table table-sm table-hover mb-0\"><thead class=\"table-light\"><tr><th>Job Type</th><th>Enabled</th><th>Detector</th><th>In Flight</th><th>Max Runtime</th><th>Exec Global</th><th>Exec/Worker</th><th>Executor Workers</th><th>Effective Exec</th><th>Last Run</th></tr></thead> <tbody id=\"plugin-scheduler-table-body\"><tr><td colspan=\"10\" class=\"text-muted text-center py-3\">Loading...</td></tr></tbody></table></div></div></div></div></div><div class=\"row mb-4\"><div class=\"col-12\"><div class=\"card shadow-sm\"><div class=\"card-header\"><h5 class=\"mb-0\"><i class=\"bi bi-people me-2\"></i>Workers</h5></div><div class=\"card-body p-0\"><div class=\"table-responsive\" style=\"max-height: 560px; overflow-y: auto;\"><table class=\"table table-sm table-hover mb-0\"><thead class=\"table-light sticky-top\"><tr><th>Worker</th><th>Address</th><th>Capabilities</th><th>Load</th></tr></thead> <tbody id=\"plugin-workers-table-body\"><tr><td colspan=\"4\" class=\"text-muted text-center py-3\">Loading...</td></tr></tbody></table></div></div></div></div></div></div><div class=\"plugin-section plugin-section-configuration\"><div class=\"row mb-4\"><div class=\"col-lg-8 mb-3\"><div class=\"card shadow-sm h-100\"><div class=\"card-header d-flex justify-content-between align-items-center\"><h5 class=\"mb-0\"><i class=\"bi bi-sliders me-2\"></i>Job Type Configuration</h5><small class=\"text-muted\" id=\"plugin-config-updated-at\">Not loaded</small></div><div class=\"card-body\"><div class=\"mb-3\"><div class=\"small text-muted\">Selected Job Type</div><div class=\"fw-semibold\" id=\"plugin-selected-job-type\">-</div></div><div class=\"mb-3\"><div class=\"d-flex justify-content-between align-items-center mb-2\"><h6 class=\"mb-0\">Descriptor</h6><button type=\"button\" class=\"btn btn-sm btn-outline-secondary\" id=\"plugin-refresh-schema-btn\"><i class=\"bi bi-cloud-download me-1\"></i>Refresh Schema</button></div><div class=\"alert alert-light border\" id=\"plugin-descriptor-summary\">Select a job type to load schema and config.</div></div><div class=\"mb-3\"><h6>Admin Config Form</h6><div id=\"plugin-admin-config-form\" class=\"plugin-form-root\"><div class=\"text-muted\">No admin form loaded.</div></div></div><div class=\"mb-3\"><h6>Worker Config Form</h6><div id=\"plugin-worker-config-form\" class=\"plugin-form-root\"><div class=\"text-muted\">No worker form loaded.</div></div></div><div class=\"d-flex flex-wrap gap-2\"><button type=\"button\" class=\"btn btn-primary\" id=\"plugin-save-config-btn\"><i class=\"bi bi-floppy me-1\"></i>Save Config</button> <button type=\"button\" class=\"btn btn-outline-primary\" id=\"plugin-trigger-detection-btn\"><i class=\"bi bi-search me-1\"></i>Run Detection</button> <button type=\"button\" class=\"btn btnLine truncated
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}