Files
seaweedFS/weed/admin/view/app/plugin_templ.go
Chris Lu d95df76bca feat: separate scheduler lanes for iceberg, lifecycle, and volume management (#8787)
* feat: introduce scheduler lanes for independent per-workload scheduling

Split the single plugin scheduler loop into independent per-lane
goroutines so that volume management, iceberg compaction, and lifecycle
operations never block each other.

Each lane has its own:
- Goroutine (laneSchedulerLoop)
- Wake channel for immediate scheduling
- Admin lock scope (e.g. "plugin scheduler:default")
- Configurable idle sleep duration
- Loop state tracking

Three lanes are defined:
- default: vacuum, volume_balance, ec_balance, erasure_coding, admin_script
- iceberg: iceberg_maintenance
- lifecycle: s3_lifecycle (new, handler coming in a later commit)

Job types are mapped to lanes via a hardcoded map with LaneDefault as
the fallback. The SchedulerJobTypeState and SchedulerStatus types now
include a Lane field for API consumers.

* feat: per-lane execution reservation pools for resource isolation

Each scheduler lane now maintains its own execution reservation map
so that a busy volume lane cannot consume execution slots needed by
iceberg or lifecycle lanes. The per-lane pool is used by default when
dispatching jobs through the lane scheduler; the global pool remains
as a fallback for the public DispatchProposals API.

* feat: add per-lane scheduler status API and lane worker UI pages

- GET /api/plugin/lanes returns all lanes with status and job types
- GET /api/plugin/workers?lane=X filters workers by lane
- GET /api/plugin/scheduler-states?lane=X filters job types by lane
- GET /api/plugin/scheduler-status?lane=X returns lane-scoped status
- GET /plugin/lanes/{lane}/workers renders per-lane worker page
- SchedulerJobTypeState now includes a "lane" field

The lane worker pages show scheduler status, job type configuration,
and connected workers scoped to a single lane, with links back to
the main plugin overview.

* feat: add s3_lifecycle worker handler for object store lifecycle management

Implements a full plugin worker handler for S3 lifecycle management,
assigned to the new "lifecycle" scheduler lane.

Detection phase:
- Reads filer.conf to find buckets with TTL lifecycle rules
- Creates one job proposal per bucket with active lifecycle rules
- Supports bucket_filter wildcard pattern from admin config

Execution phase:
- Walks the bucket directory tree breadth-first
- Identifies expired objects by checking TtlSec + Crtime < now
- Deletes expired objects in configurable batches
- Reports progress with scanned/expired/error counts
- Supports dry_run mode for safe testing

Configurable via admin UI:
- batch_size: entries per filer listing page (default 1000)
- max_deletes_per_bucket: safety cap per run (default 10000)
- dry_run: detect without deleting
- delete_marker_cleanup: clean expired delete markers
- abort_mpu_days: abort stale multipart uploads

The handler integrates with the existing PutBucketLifecycle flow which
sets TtlSec on entries via filer.conf path rules.

* feat: add per-lane submenu items under Workers sidebar menu

Replace the single "Workers" sidebar link with a collapsible submenu
containing three lane entries:
- Default (volume management + admin scripts) -> /plugin
- Iceberg (table compaction) -> /plugin/lanes/iceberg/workers
- Lifecycle (S3 object expiration) -> /plugin/lanes/lifecycle/workers

The submenu auto-expands when on any /plugin page and highlights the
active lane. Icons match each lane's job type descriptor (server,
snowflake, hourglass).

* feat: scope plugin pages to their scheduler lane

The plugin overview, configuration, detection, queue, and execution
pages now filter workers, job types, scheduler states, and scheduler
status to only show data for their lane.

- Plugin() templ function accepts a lane parameter (default: "default")
- JavaScript appends ?lane= to /api/plugin/workers, /job-types,
  /scheduler-states, and /scheduler-status API calls
- GET /api/plugin/job-types now supports ?lane= filtering
- When ?job= is provided (e.g. ?job=iceberg_maintenance), the lane is
  auto-derived from the job type so the page scopes correctly

This ensures /plugin shows only default-lane workers and
/plugin/configuration?job=iceberg_maintenance scopes to the iceberg lane.

* fix: remove "Lane" from lane worker page titles and capitalize properly

"lifecycle Lane Workers" -> "Lifecycle Workers"
"iceberg Lane Workers" -> "Iceberg Workers"

* refactor: promote lane items to top-level sidebar menu entries

Move Default, Iceberg, and Lifecycle from a collapsible submenu to
direct top-level items under the WORKERS heading. Removes the
intermediate "Workers" parent link and collapse toggle.

* admin: unify plugin lane routes and handlers

* admin: filter plugin jobs and activities by lane

* admin: reuse plugin UI for worker lane pages

* fix: use ServerAddress.ToGrpcAddress() for filer connections in lifecycle handler

ClusterContext addresses use ServerAddress format (host:port.grpcPort).
Convert to the actual gRPC address via ToGrpcAddress() before dialing,
and add a Ping verification after connecting.

Fixes: "dial tcp: lookup tcp/8888.18888: unknown port"

* fix: resolve ServerAddress gRPC port in iceberg and lifecycle filer connections

ClusterContext addresses use ServerAddress format (host:httpPort.grpcPort).
Both the iceberg and lifecycle handlers now detect the compound format
and extract the gRPC port via ToGrpcAddress() before dialing. Plain
host:port addresses (e.g. from tests) are passed through unchanged.

Fixes: "dial tcp: lookup tcp/8888.18888: unknown port"

* align url

* Potential fix for code scanning alert no. 335: Incorrect conversion between integer types

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* fix: address PR review findings across scheduler lanes and lifecycle handler

- Fix variable shadowing: rename loop var `w` to `worker` in
  GetPluginWorkersAPI to avoid shadowing the http.ResponseWriter param
- Fix stale GetSchedulerStatus: aggregate loop states across all lanes
  instead of reading never-updated legacy schedulerLoopState
- Scope InProcessJobs to lane in GetLaneSchedulerStatus
- Fix AbortMPUDays=0 treated as unset: change <= 0 to < 0 so 0 disables
- Propagate listing errors in lifecycle bucket walk instead of swallowing
- Implement DeleteMarkerCleanup: scan for S3 delete marker entries and
  remove them
- Implement AbortMPUDays: scan .uploads directory and remove stale
  multipart uploads older than the configured threshold
- Fix success determination: mark job failed when result.errors > 0
  even if no fatal error occurred
- Add regression test for jobTypeLaneMap to catch drift from handler
  registrations

* fix: guard against nil result in lifecycle completion and trim filer addresses

- Guard result dereference in completion summary: use local vars
  defaulting to 0 when result is nil to prevent panic
- Append trimmed filer addresses instead of originals so whitespace
  is not passed to the gRPC dialer

* fix: propagate ctx cancellation from deleteExpiredObjects and add config logging

- deleteExpiredObjects now returns a third error value when the context
  is canceled mid-batch; the caller stops processing further batches
  and returns the cancellation error to the job completion handler
- readBoolConfig and readInt64Config now log unexpected ConfigValue
  types at V(1) for debugging, consistent with readStringConfig

* fix: propagate errors in lifecycle cleanup helpers and use correct delete marker key

- cleanupDeleteMarkers: return error on ctx cancellation and SeaweedList
  failures instead of silently continuing
- abortIncompleteMPUs: log SeaweedList errors instead of discarding
- isDeleteMarker: use ExtDeleteMarkerKey ("Seaweed-X-Amz-Delete-Marker")
  instead of ExtLatestVersionIsDeleteMarker which is for the parent entry
- batchSize cap: use math.MaxInt instead of math.MaxInt32

* fix: propagate ctx cancellation from abortIncompleteMPUs and log unrecognized bool strings

- abortIncompleteMPUs now returns (aborted, errors, ctxErr) matching
  cleanupDeleteMarkers; caller stops on cancellation or listing failure
- readBoolConfig logs unrecognized string values before falling back

* fix: shared per-bucket budget across lifecycle phases and allow cleanup without expired objects

- Thread a shared remaining counter through TTL deletion, delete marker
  cleanup, and MPU abort so the total operations per bucket never exceed
  MaxDeletesPerBucket
- Remove early return when no TTL-expired objects found so delete marker
  cleanup and MPU abort still run
- Add NOTE on cleanupDeleteMarkers about version-safety limitation

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-03-26 19:28:13 -07:00

116 lines
146 KiB
Go

// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.977
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 Plugin(page string, initialJob string, lane string) 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)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
currentPage := page
if currentPage == "" {
currentPage = "overview"
}
currentLane := lane
if currentLane == "" {
currentLane = "default"
}
laneTitle := pluginLaneTitle(currentLane)
laneDescription := pluginLaneDescription(currentLane)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"container-fluid\" id=\"plugin-page\" data-plugin-page=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(currentPage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/plugin.templ`, Line: 16, Col: 80}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" data-plugin-job=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(initialJob)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/plugin.templ`, Line: 16, Col: 111}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" data-plugin-lane=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(currentLane)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/plugin.templ`, Line: 16, Col: 144}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\"><div class=\"row mb-4\"><div class=\"col-12\"><div class=\"d-flex justify-content-between align-items-center flex-wrap gap-2\"><div><h2 class=\"mb-0\"><i class=\"fas fa-plug me-2\"></i>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
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: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</h2><p class=\"text-muted mb-0\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
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}
}
_, 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=\"fas fa-sync-alt 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=\"fas fa-chart-line 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=\"fas fa-sliders-h me-1\"></i>Configuration</button></li><li class=\"nav-item\"><button type=\"button\" class=\"nav-link\" data-plugin-subtab=\"detection\"><i class=\"fas fa-search me-1\"></i>Job Detection</button></li><li class=\"nav-item\"><button type=\"button\" class=\"nav-link\" data-plugin-subtab=\"queue\"><i class=\"fas fa-list me-1\"></i>Job Queue</button></li><li class=\"nav-item\"><button type=\"button\" class=\"nav-link\" data-plugin-subtab=\"execution\"><i class=\"fas fa-tasks 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=\"fas fa-chart-bar 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=\"fas fa-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=\"fas fa-users 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=\"fas fa-sliders-h 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=\"fas fa-cloud-download-alt 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=\"fas fa-save me-1\"></i>Save Config</button> <button type=\"button\" class=\"btn btn-outline-primary\" id=\"plugin-trigger-detection-btn\"><i class=\"fas fa-search me-1\"></i>Run Detection</button> <button type=\"button\" class=\"btn btn-outline-success\" id=\"plugin-run-workflow-btn\"><i class=\"fas fa-play me-1\"></i>Run Detect + Execute</button></div></div></div></div><div class=\"col-lg-4 mb-3\"><div class=\"card shadow-sm\"><div class=\"card-header\"><h5 class=\"mb-0\"><i class=\"fas fa-cogs me-2\"></i>Job Scheduling Settings</h5></div><div class=\"card-body\"><div class=\"row g-2\" id=\"plugin-admin-settings-form\"><div class=\"col-12\"><label class=\"form-label\" for=\"plugin-admin-enabled\">Enabled</label><div class=\"form-check form-switch mt-1\"><input class=\"form-check-input\" type=\"checkbox\" id=\"plugin-admin-enabled\"></div></div><div class=\"col-12\"><label class=\"form-label\" for=\"plugin-admin-detection-interval\">Detection Interval (s)</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><div class=\"col-12\"><label class=\"form-label\" for=\"plugin-admin-detection-timeout\">Detection Timeout (s)</label> <input type=\"number\" class=\"form-control\" id=\"plugin-admin-detection-timeout\" min=\"0\"></div><div class=\"col-12\"><label class=\"form-label\" for=\"plugin-admin-max-runtime\">Job Type Max Runtime (s)</label> <input type=\"number\" class=\"form-control\" id=\"plugin-admin-max-runtime\" min=\"0\"></div><div class=\"col-12\"><label class=\"form-label\" for=\"plugin-admin-max-results\">Max Jobs / Detection</label> <input type=\"number\" class=\"form-control\" id=\"plugin-admin-max-results\" min=\"0\"></div><div class=\"col-12\"><label class=\"form-label\" for=\"plugin-admin-global-exec\">Global Execution Concurrency</label> <input type=\"number\" class=\"form-control\" id=\"plugin-admin-global-exec\" min=\"0\"></div><div class=\"col-12\"><label class=\"form-label\" for=\"plugin-admin-per-worker-exec\">Per Worker Concurrency</label> <input type=\"number\" class=\"form-control\" id=\"plugin-admin-per-worker-exec\" min=\"0\"></div><div class=\"col-12\"><label class=\"form-label\" for=\"plugin-admin-retry-limit\">Retry Limit</label> <input type=\"number\" class=\"form-control\" id=\"plugin-admin-retry-limit\" min=\"0\"></div><div class=\"col-12\"><label class=\"form-label\" for=\"plugin-admin-retry-backoff\">Retry Backoff (s)</label> <input type=\"number\" class=\"form-control\" id=\"plugin-admin-retry-backoff\" min=\"0\"></div></div></div></div><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=\"fas fa-hourglass-half me-2\"></i>Next Run</h5><small class=\"text-muted\">Scheduler</small></div><div class=\"card-body\"><div class=\"h5 mb-1 plugin-scheduler-next-run\">-</div><div class=\"text-muted small plugin-scheduler-next-run-meta\">Not scheduled</div></div></div></div></div><div class=\"row mb-4\"><div class=\"col-lg-6 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=\"fas fa-clipboard-check me-2\"></i>Run History</h5><small class=\"text-muted\">Keep last 10 success + last 10 errors</small></div><div class=\"card-body\"><div class=\"row\"><div class=\"col-12 mb-3\"><h6 class=\"text-success\">Successful Runs</h6><div class=\"table-responsive\"><table class=\"table table-sm table-striped\"><thead><tr><th>Time</th><th>Job ID</th><th>Worker</th><th>Duration</th></tr></thead> <tbody id=\"plugin-runs-success-body\"><tr><td colspan=\"4\" class=\"text-muted\">No data</td></tr></tbody></table></div></div><div class=\"col-12\"><h6 class=\"text-danger\">Error Runs</h6><div class=\"table-responsive\"><table class=\"table table-sm table-striped\"><thead><tr><th>Time</th><th>Job ID</th><th>Worker</th><th>Error</th></tr></thead> <tbody id=\"plugin-runs-error-body\"><tr><td colspan=\"4\" class=\"text-muted\">No data</td></tr></tbody></table></div></div></div></div></div></div><div class=\"col-lg-6 mb-3\"><div class=\"card shadow-sm h-100\"><div class=\"card-header\"><h5 class=\"mb-0\"><i class=\"fas fa-lightbulb me-2\"></i>Detection Results</h5></div><div class=\"card-body\" id=\"plugin-detection-results\"><div class=\"text-muted\">Run detection to see proposals.</div></div></div></div></div></div><div class=\"plugin-section plugin-section-queue\"><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=\"fas fa-list me-2\"></i>Job Queue</h5><div class=\"d-flex gap-2 align-items-center flex-wrap\"><small class=\"text-muted\">States: pending/assigned/running</small></div></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>Job ID</th><th>Type</th><th>State</th><th>Progress</th><th>Worker</th><th>Updated</th><th>Message</th></tr></thead> <tbody id=\"plugin-queue-jobs-table-body\"><tr><td colspan=\"7\" class=\"text-muted text-center py-3\">Loading...</td></tr></tbody></table></div></div></div></div></div></div><div class=\"plugin-section plugin-section-detection\"><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=\"fas fa-search me-2\"></i>Detection Jobs</h5><div class=\"small text-muted\">Detection activities for selected job type</div></div><div class=\"card-body p-0\"><div class=\"table-responsive\" style=\"max-height: 420px; overflow-y: auto;\"><table class=\"table table-sm table-hover mb-0\"><thead class=\"table-light sticky-top\"><tr><th>Time</th><th>Job Type</th><th>Request ID</th><th>Worker</th><th>Stage</th><th>Source</th><th>Message</th></tr></thead> <tbody id=\"plugin-detection-table-body\"><tr><td colspan=\"7\" class=\"text-muted text-center py-3\">Loading...</td></tr></tbody></table></div></div></div></div></div></div><div class=\"plugin-section plugin-section-execution\"><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=\"fas fa-tasks me-2\"></i>Execution Jobs</h5><div class=\"d-flex gap-2 align-items-center flex-wrap\"><select class=\"form-select form-select-sm\" id=\"plugin-monitor-job-state-filter\" style=\"min-width: 180px;\"><option value=\"\">All States</option> <option value=\"assigned\">Assigned</option> <option value=\"running\">Running</option> <option value=\"succeeded\">Succeeded</option> <option value=\"failed\">Failed</option> <option value=\"canceled\">Canceled</option></select></div></div><div class=\"card-body p-0\"><div class=\"table-responsive\"><table class=\"table table-hover mb-0\"><thead class=\"table-light\"><tr><th>Job ID</th><th>Type</th><th>State</th><th>Progress</th><th>Worker</th><th>Updated</th><th>Message</th></tr></thead> <tbody id=\"plugin-execution-jobs-table-body\"><tr><td colspan=\"7\" 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=\"fas fa-stream me-2\"></i>Execution Activities</h5><small class=\"text-muted\">Non-detection events only</small></div><div class=\"card-body p-0\"><div class=\"table-responsive\" style=\"max-height: 420px; overflow-y: auto;\"><table class=\"table table-sm table-hover mb-0\"><thead class=\"table-light sticky-top\"><tr><th>Time</th><th>Job Type</th><th>Job ID</th><th>Source</th><th>Stage</th><th>Message</th></tr></thead> <tbody id=\"plugin-execution-activities-table-body\"><tr><td colspan=\"6\" class=\"text-muted text-center py-3\">Loading...</td></tr></tbody></table></div></div></div></div></div></div></div><div class=\"modal fade\" id=\"plugin-job-detail-modal\" tabindex=\"-1\" aria-labelledby=\"plugin-job-detail-modal-label\" aria-hidden=\"true\"><div class=\"modal-dialog modal-xl modal-dialog-scrollable\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\" id=\"plugin-job-detail-modal-label\"><i class=\"fas fa-file-alt me-2\"></i>Job Detail</h5><div class=\"ms-auto me-2\"><button type=\"button\" class=\"btn btn-outline-danger btn-sm\" id=\"plugin-expire-job-btn\" disabled><i class=\"fas fa-stop-circle me-1\"></i>Expire Job</button></div><button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button></div><div class=\"modal-body\" id=\"plugin-job-detail-content\"><div class=\"text-muted\">Select a job to view details.</div></div></div></div></div><style>\n .plugin-section {\n display: none;\n }\n\n .plugin-section.plugin-section-active {\n display: block;\n }\n\n .plugin-form-root .card {\n border: 1px solid #dee2e6;\n }\n .plugin-form-root textarea {\n min-height: 12rem;\n }\n\n .plugin-field-hidden {\n display: none;\n }\n\n .plugin-capability-badge {\n font-size: 0.72rem;\n margin-right: 0.25rem;\n }\n\n .plugin-job-progress {\n min-width: 120px;\n }\n\n .plugin-detection-item {\n border: 1px solid #dee2e6;\n border-radius: 6px;\n padding: 0.5rem 0.75rem;\n margin-bottom: 0.5rem;\n background: #fbfcfd;\n }\n\n .plugin-job-link {\n text-decoration: none;\n }\n\n .plugin-job-detail-json {\n max-height: 220px;\n overflow: auto;\n background: #f8f9fa;\n border: 1px solid #dee2e6;\n border-radius: 6px;\n padding: 0.5rem;\n margin-bottom: 0;\n font-size: 0.82rem;\n }\n </style><script>\n (function() {\n var page = document.getElementById('plugin-page');\n if (!page) {\n return;\n }\n\n var pluginLane = String(page.getAttribute('data-plugin-lane') || '').trim();\n\n var state = {\n selectedJobType: '',\n jobTypes: [],\n lastDetectionByJobType: {},\n initialPage: String(page.getAttribute('data-plugin-page') || 'overview').trim().toLowerCase(),\n initialJob: String(page.getAttribute('data-plugin-job') || '').trim(),\n activeTopTab: 'overview',\n activeSubTab: 'configuration',\n initialNavigationApplied: false,\n monitorExecutionJobState: '',\n workers: [],\n jobs: [],\n activities: [],\n schedulerStates: [],\n schedulerStatus: null,\n allJobs: [],\n allActivities: [],\n loadedJobType: '',\n descriptor: null,\n config: null,\n refreshTimer: null,\n };\n\n // appendLaneParam appends ?lane= or &lane= to a URL if a lane filter is active.\n function appendLaneParam(url) {\n if (!pluginLane) return url;\n var sep = url.indexOf('?') === -1 ? '?' : '&';\n return url + sep + 'lane=' + encodeURIComponent(pluginLane);\n }\n\n function getCSRFToken() {\n var meta = document.querySelector('meta[name=\"csrf-token\"]');\n if (!meta) {\n return '';\n }\n return meta.getAttribute('content') || '';\n }\n\n function notify(message, level) {\n if (typeof window.showAlert === 'function') {\n window.showAlert(message, level || 'info');\n return;\n }\n if (level === 'error') {\n console.error(message);\n }\n alert(message);\n }\n\n async function readResponseJSON(response) {\n var bodyText = await response.text();\n if (!bodyText) {\n return {};\n }\n try {\n return JSON.parse(bodyText);\n } catch (e) {\n return { error: bodyText };\n }\n }\n\n async function pluginRequest(method, url, payload) {\n var headers = {};\n var requestOptions = {\n method: method,\n headers: headers,\n };\n\n if (payload !== undefined && payload !== null) {\n headers['Content-Type'] = 'application/json';\n requestOptions.body = JSON.stringify(payload);\n }\n\n if (method !== 'GET') {\n var csrf = getCSRFToken();\n if (csrf) {\n headers['X-CSRF-Token'] = csrf;\n }\n }\n\n var response = await fetch(url, requestOptions);\n var data = await readResponseJSON(response);\n if (!response.ok) {\n var message = (data && (data.error || data.message)) ? (data.error || data.message) : ('Request failed: ' + response.status);\n var requestError = new Error(message);\n requestError.responseData = data;\n requestError.responseStatus = response.status;\n throw requestError;\n }\n return data;\n }\n\n function textOrDash(value) {\n if (value === null || value === undefined || value === '') {\n return '-';\n }\n return String(value);\n }\n\n function escapeHtml(value) {\n if (value === null || value === undefined) {\n return '';\n }\n return String(value)\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&#039;');\n }\n\n function encodePath(value) {\n return encodeURIComponent(value || '');\n }\n\n function normalizeEnum(value) {\n if (value === undefined || value === null) {\n return '';\n }\n return String(value).toUpperCase();\n }\n\n function parseTime(value) {\n if (!value) {\n return '';\n }\n var date = new Date(value);\n if (Number.isNaN(date.getTime())) {\n return '';\n }\n return date.toLocaleString();\n }\n\n function normalizeSubTab(value) {\n var normalized = String(value || '').toLowerCase();\n if (normalized === 'configuration' || normalized === 'detection' || normalized === 'queue' || normalized === 'execution') {\n return normalized;\n }\n return 'configuration';\n }\n\n function buildPluginURL(topTab, subTab, jobType) {\n var laneName = pluginLane || 'default';\n var laneBase = '/plugin/lanes/' + encodeURIComponent(laneName);\n if (topTab === 'overview') {\n return laneBase;\n }\n var effectiveSubTab = normalizeSubTab(subTab);\n var path = laneBase + '/' + encodeURIComponent(effectiveSubTab);\n var jt = String(jobType || '').trim();\n if (jt) {\n path += '?job=' + encodeURIComponent(jt);\n }\n return path;\n }\n\n function updateURL(replace) {\n var url = buildPluginURL(state.activeTopTab, state.activeSubTab, state.selectedJobType);\n if (replace) {\n history.replaceState({ pluginTopTab: state.activeTopTab, pluginSubTab: state.activeSubTab, pluginJob: state.selectedJobType }, '', url);\n } else {\n history.pushState({ pluginTopTab: state.activeTopTab, pluginSubTab: state.activeSubTab, pluginJob: state.selectedJobType }, '', url);\n }\n }\n\n // Parse a job type item (string or object) into a safe object shape\n function parseJobTypeItem(item) {\n var jobType = '';\n var displayName = '';\n \n if (typeof item === 'string') {\n jobType = String(item || '').trim();\n displayName = jobType;\n } else if (item && typeof item === 'object') {\n jobType = String(item.job_type || item.jobType || '').trim();\n displayName = String(item.display_name || item.displayName || jobType || '').trim();\n if (!displayName) {\n displayName = jobType;\n }\n }\n \n return { jobType: jobType, displayName: displayName };\n }\n\n function topTabKeyForJobType(jobType) {\n var normalized = String(jobType || '').trim();\n if (!normalized) {\n return '';\n }\n return 'job:' + normalized;\n }\n\n function parseJobTypeFromTopTab(tabKey) {\n var normalized = String(tabKey || '').trim();\n if (normalized.indexOf('job:') !== 0) {\n return '';\n }\n return normalized.slice(4);\n }\n\n function hasJobType(jobType) {\n var normalized = String(jobType || '').trim();\n if (!normalized) {\n return false;\n }\n var jobTypes = Array.isArray(state.jobTypes) ? state.jobTypes : [];\n for (var i = 0; i < jobTypes.length; i++) {\n var item = parseJobTypeItem(jobTypes[i]);\n if (item.jobType === normalized) {\n return true;\n }\n }\n return false;\n }\n\n function applyInitialNavigation() {\n if (state.initialNavigationApplied) {\n return;\n }\n state.initialNavigationApplied = true;\n state.activeSubTab = normalizeSubTab(state.initialPage);\n\n if (state.initialPage === 'overview') {\n state.activeTopTab = 'overview';\n return;\n }\n if (!Array.isArray(state.jobTypes) || state.jobTypes.length === 0) {\n state.activeTopTab = 'overview';\n return;\n }\n // If a specific job type was provided in the URL, use it\n if (state.initialJob && hasJobType(state.initialJob)) {\n state.selectedJobType = state.initialJob;\n } else {\n var firstItem = parseJobTypeItem(state.jobTypes[0]);\n state.selectedJobType = firstItem.jobType;\n }\n state.activeTopTab = topTabKeyForJobType(state.selectedJobType);\n }\n\n function ensureActiveNavigation() {\n state.activeSubTab = normalizeSubTab(state.activeSubTab);\n\n if (state.selectedJobType && !hasJobType(state.selectedJobType)) {\n state.selectedJobType = '';\n state.descriptor = null;\n state.config = null;\n }\n\n var topTabJobType = parseJobTypeFromTopTab(state.activeTopTab);\n if (topTabJobType && !hasJobType(topTabJobType)) {\n state.activeTopTab = 'overview';\n topTabJobType = '';\n }\n\n if (state.activeTopTab === 'overview') {\n if (!state.selectedJobType && Array.isArray(state.jobTypes) && state.jobTypes.length > 0) {\n var item = parseJobTypeItem(state.jobTypes[0]);\n state.selectedJobType = item.jobType;\n }\n return;\n }\n\n if (!topTabJobType && state.selectedJobType) {\n topTabJobType = state.selectedJobType;\n }\n if (!topTabJobType && Array.isArray(state.jobTypes) && state.jobTypes.length > 0) {\n var item = parseJobTypeItem(state.jobTypes[0]);\n topTabJobType = item.jobType;\n }\n if (!topTabJobType) {\n state.activeTopTab = 'overview';\n return;\n }\n\n state.selectedJobType = topTabJobType;\n state.activeTopTab = topTabKeyForJobType(topTabJobType);\n }\n\n function renderTopTabs() {\n var root = document.getElementById('plugin-top-tabs');\n if (!root) {\n return;\n }\n\n var html = '' +\n '<li class=\"nav-item\">' +\n '<button type=\"button\" class=\"nav-link\" data-plugin-top-tab=\"overview\">' +\n '<i class=\"fas fa-chart-line me-1\"></i>Overview' +\n '</button>' +\n '</li>';\n\n var jobTypes = Array.isArray(state.jobTypes) ? state.jobTypes : [];\n for (var i = 0; i < jobTypes.length; i++) {\n var item = parseJobTypeItem(jobTypes[i]);\n var jobType = item.jobType;\n var displayName = item.displayName;\n \n if (!jobType) {\n continue;\n }\n html += '' +\n '<li class=\"nav-item\">' +\n '<button type=\"button\" class=\"nav-link\" data-plugin-top-tab=\"' + escapeHtml(topTabKeyForJobType(jobType)) + '\">' +\n escapeHtml(displayName) +\n '</button>' +\n '</li>';\n }\n root.innerHTML = html;\n }\n\n function renderNavigationState() {\n var selectedJobTypeLabel = document.getElementById('plugin-selected-job-type');\n if (selectedJobTypeLabel) {\n selectedJobTypeLabel.textContent = state.selectedJobType || '-';\n }\n\n var isOverview = state.activeTopTab === 'overview';\n var subtabRow = document.getElementById('plugin-subtab-row');\n if (subtabRow) {\n subtabRow.classList.toggle('d-none', isOverview);\n }\n\n var topTabs = document.querySelectorAll('#plugin-top-tabs [data-plugin-top-tab]');\n for (var i = 0; i < topTabs.length; i++) {\n var topTab = topTabs[i];\n var topKey = String(topTab.getAttribute('data-plugin-top-tab') || '');\n topTab.classList.toggle('active', topKey === state.activeTopTab);\n }\n\n var subTabs = document.querySelectorAll('#plugin-subtabs [data-plugin-subtab]');\n for (var j = 0; j < subTabs.length; j++) {\n var subTab = subTabs[j];\n var subKey = String(subTab.getAttribute('data-plugin-subtab') || '');\n subTab.classList.toggle('active', !isOverview && subKey === state.activeSubTab);\n }\n\n function toggleSection(sectionName, visible) {\n var section = document.querySelector('.plugin-section-' + sectionName);\n if (!section) {\n return;\n }\n section.classList.toggle('plugin-section-active', !!visible);\n }\n\n toggleSection('overview', isOverview);\n toggleSection('configuration', !isOverview && state.activeSubTab === 'configuration');\n toggleSection('detection', !isOverview && state.activeSubTab === 'detection');\n toggleSection('queue', !isOverview && state.activeSubTab === 'queue');\n toggleSection('execution', !isOverview && state.activeSubTab === 'execution');\n }\n\n function renderJobLink(jobID) {\n var normalized = String(jobID || '').trim();\n if (!normalized) {\n return '<small>-</small>';\n }\n return '<button type=\"button\" class=\"btn btn-link btn-sm p-0 align-baseline plugin-job-link\" data-job-id=\"' + escapeHtml(normalized) + '\">' + escapeHtml(normalized) + '</button>';\n }\n\n function toPrettyJson(value) {\n if (value === null || value === undefined || value === '') {\n return '';\n }\n if (typeof value === 'string') {\n return value;\n }\n try {\n return JSON.stringify(value, null, 2);\n } catch (e) {\n return String(value);\n }\n }\n\n function clonePlainObject(value) {\n if (!value || typeof value !== 'object') {\n return null;\n }\n try {\n return JSON.parse(JSON.stringify(value));\n } catch (e) {\n return null;\n }\n }\n\n function extractExecutionPlan(job) {\n if (!job || !job.parameters) {\n return null;\n }\n var plan = job.parameters.execution_plan;\n if (!plan) {\n return null;\n }\n if (typeof plan === 'string') {\n try {\n plan = JSON.parse(plan);\n } catch (e) {\n return null;\n }\n }\n if (typeof plan !== 'object' || Array.isArray(plan)) {\n return null;\n }\n return plan;\n }\n\n function removeExecutionPlanForDisplay(parameters) {\n var copy = clonePlainObject(parameters);\n if (!copy) {\n return parameters;\n }\n if (copy.execution_plan !== undefined) {\n delete copy.execution_plan;\n }\n if (Object.keys(copy).length === 0) {\n return null;\n }\n return copy;\n }\n\n function parseIntOrDefault(value, fallback) {\n var parsed = parseInt(value, 10);\n if (Number.isNaN(parsed)) {\n return fallback;\n }\n return parsed;\n }\n\n function normalizeShardIDs(value) {\n if (!Array.isArray(value)) {\n return [];\n }\n var out = [];\n for (var i = 0; i < value.length; i++) {\n var parsed = parseIntOrDefault(value[i], -1);\n if (parsed >= 0) {\n out.push(parsed);\n }\n }\n out.sort(function(a, b) { return a - b; });\n return out;\n }\n\n function classifyShardLabel(shardID, dataShardCount) {\n if (shardID < dataShardCount) {\n return {\n kind: 'data',\n label: String(shardID),\n className: 'bg-primary',\n title: 'Data shard ' + shardID,\n };\n }\n var parityIndex = shardID - dataShardCount;\n return {\n kind: 'parity',\n label: String(shardID),\n className: 'bg-warning text-dark',\n title: 'Parity shard ' + shardID + ' (index ' + parityIndex + ')',\n };\n }\n\n function renderShardBadges(shardIDs, dataShardCount) {\n if (!Array.isArray(shardIDs) || shardIDs.length === 0) {\n return '<span class=\"text-muted\">-</span>';\n }\n var html = '';\n for (var i = 0; i < shardIDs.length; i++) {\n var shard = classifyShardLabel(shardIDs[i], dataShardCount);\n html += '<span class=\"badge me-1 ' + shard.className + '\" title=\"' + escapeHtml(shard.title) + '\">' + escapeHtml(shard.label) + '</span>';\n }\n return html;\n }\n\n function renderExecutionPlan(plan) {\n if (!plan || typeof plan !== 'object') {\n return '';\n }\n\n var jobType = String(plan.job_type || '').trim().toLowerCase();\n if (jobType === 'volume_balance') {\n return renderBalanceExecutionPlan(plan);\n }\n if (jobType !== 'erasure_coding') {\n var fallbackText = toPrettyJson(plan);\n if (!fallbackText) {\n return '';\n }\n return '<div class=\"mb-3\"><h6>Execution Plan</h6><pre class=\"plugin-job-detail-json\">' + escapeHtml(fallbackText) + '</pre></div>';\n }\n\n var dataShards = parseIntOrDefault(plan.data_shards, 10);\n var parityShards = parseIntOrDefault(plan.parity_shards, 4);\n var totalShards = parseIntOrDefault(plan.total_shards, dataShards + parityShards);\n var sources = Array.isArray(plan.sources) ? plan.sources : [];\n var targets = Array.isArray(plan.targets) ? plan.targets : [];\n var assignments = Array.isArray(plan.shard_assignments) ? plan.shard_assignments : [];\n\n var html = '';\n html += '<div class=\"mb-3\"><h6>Execution Plan</h6>';\n html += '<div class=\"row g-2 mb-2\">' +\n '<div class=\"col-md-3\"><small><strong>Volume:</strong> ' + escapeHtml(textOrDash(plan.volume_id)) + '</small></div>' +\n '<div class=\"col-md-3\"><small><strong>Collection:</strong> ' + escapeHtml(textOrDash(plan.collection)) + '</small></div>' +\n '<div class=\"col-md-3\"><small><strong>Shards:</strong> ' + escapeHtml(String(dataShards)) + '+' + escapeHtml(String(parityShards)) + '</small></div>' +\n '<div class=\"col-md-3\"><small><strong>Total:</strong> ' + escapeHtml(String(totalShards)) + '</small></div>' +\n '</div>';\n\n html += '<div class=\"small text-muted mb-2\">' +\n '<span class=\"badge bg-primary me-1\">Data</span>' +\n '<span class=\"badge bg-warning text-dark me-2\">Parity</span>' +\n 'Source and target placement per shard.' +\n '</div>';\n\n function renderEndpointsTable(title, endpoints) {\n if (!endpoints.length) {\n return '<div class=\"mb-2\"><strong>' + escapeHtml(title) + ':</strong> <span class=\"text-muted\">none</span></div>';\n }\n var table = '<div class=\"table-responsive mb-2\"><table class=\"table table-sm table-striped mb-0\">' +\n '<thead><tr><th>#</th><th>Node</th><th>DC</th><th>Rack</th><th>Volume</th><th>Shards</th></tr></thead><tbody>';\n for (var i = 0; i < endpoints.length; i++) {\n var endpoint = endpoints[i] || {};\n var endpointShards = normalizeShardIDs(endpoint.shard_ids);\n table += '<tr>' +\n '<td>' + escapeHtml(String(i + 1)) + '</td>' +\n '<td><code>' + escapeHtml(textOrDash(endpoint.node)) + '</code></td>' +\n '<td>' + escapeHtml(textOrDash(endpoint.data_center)) + '</td>' +\n '<td>' + escapeHtml(textOrDash(endpoint.rack)) + '</td>' +\n '<td>' + escapeHtml(textOrDash(endpoint.volume_id)) + '</td>' +\n '<td>' + renderShardBadges(endpointShards, dataShards) + '</td>' +\n '</tr>';\n }\n table += '</tbody></table></div>';\n return '<div class=\"mb-2\"><strong>' + escapeHtml(title) + '</strong></div>' + table;\n }\n\n html += renderEndpointsTable('Sources', sources);\n html += renderEndpointsTable('Targets', targets);\n\n if (assignments.length) {\n html += '<div class=\"mb-2\"><strong>Shard Assignments</strong></div>';\n html += '<div class=\"table-responsive\"><table class=\"table table-sm table-striped mb-0\">' +\n '<thead><tr><th>Shard</th><th>Kind</th><th>Target</th><th>DC</th><th>Rack</th></tr></thead><tbody>';\n for (var j = 0; j < assignments.length; j++) {\n var assignment = assignments[j] || {};\n var shardID = parseIntOrDefault(assignment.shard_id, -1);\n if (shardID < 0) {\n continue;\n }\n var shard = classifyShardLabel(shardID, dataShards);\n html += '<tr>' +\n '<td><span class=\"badge ' + shard.className + '\">' + escapeHtml(shard.label) + '</span></td>' +\n '<td>' + escapeHtml(textOrDash(assignment.kind || shard.kind)) + '</td>' +\n '<td><code>' + escapeHtml(textOrDash(assignment.target_node)) + '</code></td>' +\n '<td>' + escapeHtml(textOrDash(assignment.target_data_center)) + '</td>' +\n '<td>' + escapeHtml(textOrDash(assignment.target_rack)) + '</td>' +\n '</tr>';\n }\n html += '</tbody></table></div>';\n }\n\n html += '</div>';\n return html;\n }\n\n function renderBalanceExecutionPlan(plan) {\n var html = '<div class=\"mb-3\"><h6>Execution Plan</h6>';\n var moves = Array.isArray(plan.moves) ? plan.moves : [];\n\n if (moves.length === 0) {\n // Single-move balance job\n var src = textOrDash(plan.source_node || plan.source_server);\n var dst = textOrDash(plan.target_node || plan.target_server);\n var vid = textOrDash(plan.volume_id);\n var col = textOrDash(plan.collection);\n html += `<div class=\"row g-2 mb-2\">\n <div class=\"col-md-3\"><small><strong>Volume:</strong> ${escapeHtml(vid)}</small></div>\n <div class=\"col-md-3\"><small><strong>Collection:</strong> ${escapeHtml(col)}</small></div>\n <div class=\"col-md-3\"><small><strong>Source:</strong> <code>${escapeHtml(src)}</code></small></div>\n <div class=\"col-md-3\"><small><strong>Target:</strong> <code>${escapeHtml(dst)}</code></small></div>\n </div>`;\n } else {\n // Batch balance job\n html += '<div class=\"mb-2\"><span class=\"badge bg-info\">' + escapeHtml(String(moves.length)) + ' moves</span></div>';\n html += '<div class=\"table-responsive\"><table class=\"table table-sm table-striped mb-0\">' +\n '<thead><tr><th>#</th><th>Volume</th><th>Source</th><th>Target</th><th>Collection</th></tr></thead><tbody>';\n for (var i = 0; i < moves.length; i++) {\n var move = moves[i] || {};\n html += `<tr>\n <td>${escapeHtml(String(i + 1))}</td>\n <td>${escapeHtml(textOrDash(move.volume_id))}</td>\n <td><code>${escapeHtml(textOrDash(move.source_node))}</code></td>\n <td><code>${escapeHtml(textOrDash(move.target_node))}</code></td>\n <td>${escapeHtml(textOrDash(move.collection))}</td>\n </tr>`;\n }\n html += '</tbody></table></div>';\n }\n\n html += '</div>';\n return html;\n }\n\n function isActiveJobState(candidateState) {\n var jobState = candidateState;\n if (candidateState && typeof candidateState === 'object' && candidateState.state !== undefined) {\n jobState = candidateState.state;\n }\n var st = String(jobState || '').toLowerCase();\n return st === 'job_state_pending' || st === 'job_state_assigned' || st === 'job_state_running' ||\n st === 'pending' || st === 'assigned' || st === 'running' || st === 'in_progress';\n }\n\n function setExpireButtonState(job) {\n var expireBtn = document.getElementById('plugin-expire-job-btn');\n if (!expireBtn) {\n return;\n }\n var jobID = job && job.job_id ? String(job.job_id) : '';\n var active = isActiveJobState(job);\n expireBtn.setAttribute('data-job-id', jobID);\n expireBtn.disabled = !jobID || !active;\n if (!jobID) {\n expireBtn.title = 'Select a job to expire.';\n } else if (!active) {\n expireBtn.title = 'Job is not active.';\n } else {\n expireBtn.title = 'Expire job to unblock scheduling.';\n }\n }\n\n async function expireJob(jobID) {\n var normalizedJobID = String(jobID || '').trim();\n if (!normalizedJobID) {\n return;\n }\n\n var reason = window.prompt('Expire job ' + normalizedJobID + '? Optional reason:', 'job expired by admin request');\n if (reason === null) {\n return;\n }\n\n var expireBtn = document.getElementById('plugin-expire-job-btn');\n if (expireBtn) {\n expireBtn.disabled = true;\n }\n\n try {\n var response = await pluginRequest('POST', '/api/plugin/jobs/' + encodePath(normalizedJobID) + '/expire', {\n reason: reason,\n });\n if (response && response.expired === false) {\n notify(response.message || 'Job is not active.', 'info');\n } else {\n notify('Job expired: ' + normalizedJobID, 'success');\n }\n await refreshJobsAndActivities();\n await openJobDetail(normalizedJobID);\n } catch (e) {\n notify('Failed to expire job: ' + e.message, 'error');\n }\n }\n\n async function openJobDetail(jobID) {\n var normalizedJobID = String(jobID || '').trim();\n if (!normalizedJobID) {\n return;\n }\n\n var modalElement = document.getElementById('plugin-job-detail-modal');\n var contentRoot = document.getElementById('plugin-job-detail-content');\n if (!modalElement || !contentRoot) {\n return;\n }\n\n var modal;\n if (window.bootstrap && window.bootstrap.Modal) {\n modal = window.bootstrap.Modal.getOrCreateInstance(modalElement);\n }\n if (modal) {\n modal.show();\n }\n\n setExpireButtonState(null);\n contentRoot.innerHTML = '<div class=\"text-muted\">Loading job detail...</div>';\n try {\n var detail = await pluginRequest('GET', '/api/plugin/jobs/' + encodePath(normalizedJobID) + '/detail?activity_limit=500&related_limit=20');\n var job = (detail && detail.job) ? detail.job : {};\n setExpireButtonState(job);\n var runRecord = detail && detail.run_record ? detail.run_record : null;\n var activities = (detail && Array.isArray(detail.activities)) ? detail.activities : [];\n var relatedJobs = (detail && Array.isArray(detail.related_jobs)) ? detail.related_jobs : [];\n var html = '';\n\n html += '<div class=\"row g-3 mb-3\">' +\n '<div class=\"col-lg-8\">' +\n '<div><strong>Job ID:</strong> ' + escapeHtml(textOrDash(job.job_id || normalizedJobID)) + '</div>' +\n '<div><strong>Type:</strong> ' + escapeHtml(textOrDash(job.job_type)) + '</div>' +\n '<div><strong>State:</strong> ' + escapeHtml(textOrDash(job.state)) + '</div>' +\n '<div><strong>Worker:</strong> ' + escapeHtml(textOrDash(job.worker_id)) + '</div>' +\n '<div><strong>Updated:</strong> ' + escapeHtml(parseTime(job.updated_at) || '-') + '</div>' +\n '</div>' +\n '<div class=\"col-lg-4\">' +\n '<div><strong>Attempt:</strong> ' + escapeHtml(textOrDash(job.attempt)) + '</div>' +\n '<div><strong>Progress:</strong> ' + escapeHtml(String(Math.round(Number(job.progress || 0)))) + '%</div>' +\n '<div><strong>Created:</strong> ' + escapeHtml(parseTime(job.created_at) || '-') + '</div>' +\n '<div><strong>Completed:</strong> ' + escapeHtml(parseTime(job.completed_at) || '-') + '</div>' +\n '</div>' +\n '</div>';\n\n if (job.summary) {\n html += '<div class=\"mb-2\"><strong>Summary:</strong> ' + escapeHtml(String(job.summary)) + '</div>';\n }\n if (job.detail) {\n html += '<div class=\"mb-2\"><strong>Detail:</strong> ' + escapeHtml(String(job.detail)) + '</div>';\n }\n if (job.message) {\n html += '<div class=\"mb-2\"><strong>Message:</strong> ' + escapeHtml(String(job.message)) + '</div>';\n }\n if (job.error_message) {\n html += '<div class=\"mb-2 text-danger\"><strong>Error:</strong> ' + escapeHtml(String(job.error_message)) + '</div>';\n }\n if (job.result_summary) {\n html += '<div class=\"mb-2\"><strong>Result:</strong> ' + escapeHtml(String(job.result_summary)) + '</div>';\n }\n\n var executionPlan = extractExecutionPlan(job);\n if (executionPlan) {\n html += renderExecutionPlan(executionPlan);\n }\n\n var parametersText = toPrettyJson(removeExecutionPlanForDisplay(job.parameters));\n if (parametersText) {\n html += '<div class=\"mb-3\"><h6>Parameters</h6><pre class=\"plugin-job-detail-json\">' + escapeHtml(parametersText) + '</pre></div>';\n }\n var labelsText = toPrettyJson(job.labels);\n if (labelsText) {\n html += '<div class=\"mb-3\"><h6>Labels</h6><pre class=\"plugin-job-detail-json\">' + escapeHtml(labelsText) + '</pre></div>';\n }\n var outputsText = toPrettyJson(job.result_output_values);\n if (outputsText) {\n html += '<div class=\"mb-3\"><h6>Result Output Values</h6><pre class=\"plugin-job-detail-json\">' + escapeHtml(outputsText) + '</pre></div>';\n }\n\n if (runRecord) {\n html += '<div class=\"mb-3\"><h6>Run Record</h6>' +\n '<div><strong>Outcome:</strong> ' + escapeHtml(textOrDash(runRecord.outcome)) + '</div>' +\n '<div><strong>Duration:</strong> ' + escapeHtml(formatDurationMs(runRecord.duration_ms)) + '</div>' +\n '<div><strong>Completed:</strong> ' + escapeHtml(parseTime(runRecord.completed_at) || '-') + '</div>' +\n '<div><strong>Message:</strong> ' + escapeHtml(textOrDash(runRecord.message)) + '</div>' +\n '</div>';\n }\n\n html += '<h6 class=\"mt-3\">Activity Timeline</h6>';\n if (!activities.length) {\n html += '<div class=\"text-muted mb-3\">No activity records.</div>';\n } else {\n html += '<div class=\"table-responsive mb-3\"><table class=\"table table-sm table-striped\">' +\n '<thead><tr><th>Time</th><th>Stage</th><th>Source</th><th>Worker</th><th>Message</th></tr></thead><tbody>';\n for (var i = 0; i < activities.length; i++) {\n var activity = activities[i] || {};\n html += '<tr>' +\n '<td><small>' + escapeHtml(parseTime(activity.occurred_at) || '-') + '</small></td>' +\n '<td>' + escapeHtml(textOrDash(activity.stage)) + '</td>' +\n '<td>' + escapeHtml(textOrDash(activity.source)) + '</td>' +\n '<td>' + escapeHtml(textOrDash(activity.worker_id)) + '</td>' +\n '<td><small>' + escapeHtml(textOrDash(activity.message)) + '</small></td>' +\n '</tr>';\n }\n html += '</tbody></table></div>';\n }\n\n if (relatedJobs.length) {\n html += '<h6>Related Jobs</h6><div class=\"table-responsive\"><table class=\"table table-sm table-hover\">' +\n '<thead><tr><th>Job ID</th><th>State</th><th>Worker</th><th>Updated</th></tr></thead><tbody>';\n for (var j = 0; j < relatedJobs.length; j++) {\n var related = relatedJobs[j] || {};\n html += '<tr>' +\n '<td><small>' + escapeHtml(textOrDash(related.job_id)) + '</small></td>' +\n '<td>' + escapeHtml(textOrDash(related.state)) + '</td>' +\n '<td>' + escapeHtml(textOrDash(related.worker_id)) + '</td>' +\n '<td><small>' + escapeHtml(parseTime(related.updated_at) || '-') + '</small></td>' +\n '</tr>';\n }\n html += '</tbody></table></div>';\n }\n\n contentRoot.innerHTML = html;\n } catch (e) {\n setExpireButtonState(null);\n contentRoot.innerHTML = '<div class=\"alert alert-danger mb-0\">Failed to load job detail: ' + escapeHtml(e.message) + '</div>';\n }\n }\n\n function formatDurationMs(value) {\n var ms = Number(value || 0);\n if (!Number.isFinite(ms) || ms <= 0) {\n return '-';\n }\n if (ms < 1000) {\n return ms + 'ms';\n }\n var sec = Math.floor(ms / 1000);\n var remainMs = ms % 1000;\n if (sec < 60) {\n return sec + '.' + String(Math.floor(remainMs / 100)) + 's';\n }\n var min = Math.floor(sec / 60);\n var remainSec = sec % 60;\n return min + 'm ' + remainSec + 's';\n }\n\n function getWorkerField(worker, name) {\n if (!worker) {\n return null;\n }\n if (worker[name] !== undefined) {\n return worker[name];\n }\n var camel = name.charAt(0).toUpperCase() + name.slice(1);\n if (worker[camel] !== undefined) {\n return worker[camel];\n }\n return null;\n }\n\n function renderStatus() {\n var allJobs = Array.isArray(state.allJobs) ? state.allJobs : [];\n var allActivities = Array.isArray(state.allActivities) ? state.allActivities : [];\n\n var activeCount = allJobs.filter(function(job) {\n return isActiveJobState(job);\n }).length;\n\n document.getElementById('plugin-status-workers').textContent = String(state.workers.length);\n document.getElementById('plugin-status-active-jobs').textContent = String(activeCount);\n document.getElementById('plugin-status-activities').textContent = String(allActivities.length);\n }\n\n function renderJobTypeSummary() {\n var tbody = document.getElementById('plugin-jobtype-summary-body');\n if (!tbody) {\n return;\n }\n\n var activeByType = {};\n var activityByType = {};\n\n var allJobs = Array.isArray(state.allJobs) ? state.allJobs : [];\n var allActivities = Array.isArray(state.allActivities) ? state.allActivities : [];\n\n for (var i = 0; i < allJobs.length; i++) {\n var job = allJobs[i] || {};\n var jobType = String(job.job_type || '').trim();\n if (!jobType) {\n continue;\n }\n var isActive = isActiveJobState(job);\n if (!isActive) {\n continue;\n }\n if (!activeByType[jobType]) {\n activeByType[jobType] = 0;\n }\n activeByType[jobType]++;\n }\n\n for (var j = 0; j < allActivities.length; j++) {\n var activity = allActivities[j] || {};\n var activityJobType = String(activity.job_type || '').trim();\n if (!activityJobType) {\n continue;\n }\n if (!activityByType[activityJobType]) {\n activityByType[activityJobType] = 0;\n }\n activityByType[activityJobType]++;\n }\n\n var known = Array.isArray(state.jobTypes) ? state.jobTypes.slice() : [];\n var seen = {};\n var jobTypes = [];\n\n for (var k = 0; k < known.length; k++) {\n var item = parseJobTypeItem(known[k]);\n var knownType = item && item.jobType ? String(item.jobType).trim() : '';\n if (!knownType || seen[knownType]) {\n continue;\n }\n seen[knownType] = true;\n jobTypes.push(knownType);\n }\n\n Object.keys(activeByType).forEach(function(jobType) {\n if (!seen[jobType]) {\n seen[jobType] = true;\n jobTypes.push(jobType);\n }\n });\n Object.keys(activityByType).forEach(function(jobType) {\n if (!seen[jobType]) {\n seen[jobType] = true;\n jobTypes.push(jobType);\n }\n });\n\n jobTypes.sort();\n\n if (jobTypes.length === 0) {\n tbody.innerHTML = '<tr><td colspan=\\\"3\\\" class=\\\"text-muted text-center py-3\\\">No job types discovered</td></tr>';\n return;\n }\n\n var rows = '';\n for (var m = 0; m < jobTypes.length; m++) {\n var rowType = jobTypes[m];\n rows += '<tr>' +\n '<td>' + escapeHtml(rowType) + '</td>' +\n '<td><span class=\\\"badge bg-warning text-dark\\\">' + escapeHtml(String(activeByType[rowType] || 0)) + '</span></td>' +\n '<td><span class=\\\"badge bg-info\\\">' + escapeHtml(String(activityByType[rowType] || 0)) + '</span></td>' +\n '</tr>';\n }\n tbody.innerHTML = rows;\n }\n\n function computeEffectiveExecutionCapacity(globalExec, perWorkerExec, executorWorkers) {\n var capacities = [];\n if (globalExec > 0) {\n capacities.push(globalExec);\n }\n var workerCapacity = perWorkerExec > 0 && executorWorkers > 0 ? (perWorkerExec * executorWorkers) : 0;\n if (workerCapacity > 0) {\n capacities.push(workerCapacity);\n }\n if (!capacities.length) {\n return 0;\n }\n var effective = capacities[0];\n for (var i = 1; i < capacities.length; i++) {\n if (capacities[i] < effective) {\n effective = capacities[i];\n }\n }\n return effective;\n }\n\n function renderSchedulerStates() {\n var tbody = document.getElementById('plugin-scheduler-table-body');\n if (!tbody) {\n return;\n }\n\n var states = Array.isArray(state.schedulerStates) ? state.schedulerStates : [];\n if (!states.length) {\n tbody.innerHTML = '<tr><td colspan=\"10\" class=\"text-muted text-center py-3\">No scheduler state available</td></tr>';\n return;\n }\n\n var rows = '';\n for (var i = 0; i < states.length; i++) {\n var item = states[i] || {};\n var enabled = !!item.enabled;\n var inFlight = !!item.detection_in_flight;\n var detector = item.detector_available ? textOrDash(item.detector_worker_id) : 'No detector';\n var maxRuntimeSeconds = Number(item.job_type_max_runtime_seconds || 0);\n var maxRuntimeText = maxRuntimeSeconds > 0 ? (String(maxRuntimeSeconds) + 's') : '-';\n var globalExec = Number(item.global_execution_concurrency || 0);\n var perWorkerExec = Number(item.per_worker_execution_concurrency || 0);\n var executorWorkers = Number(item.executor_worker_count || 0);\n var effectiveExec = computeEffectiveExecutionCapacity(globalExec, perWorkerExec, executorWorkers);\n var globalExecText = enabled ? String(globalExec) : '-';\n var perWorkerExecText = enabled ? String(perWorkerExec) : '-';\n var executorWorkersText = enabled ? String(executorWorkers) : '-';\n var effectiveExecText = enabled ? String(effectiveExec) : '-';\n var lastRunStatus = textOrDash(item.last_run_status);\n var lastRunTime = parseTime(item.last_run_completed_at);\n var lastRunText = '-';\n if (lastRunStatus !== '-' || lastRunTime) {\n var statusLabel = lastRunStatus !== '-' ? lastRunStatus : 'run';\n lastRunText = lastRunTime ? (statusLabel + ' @ ' + lastRunTime) : statusLabel;\n }\n\n var enabledBadge = enabled ? '<span class=\"badge bg-success\">Enabled</span>' : '<span class=\"badge bg-secondary\">Disabled</span>';\n var inFlightBadge = inFlight ? '<span class=\"badge bg-warning text-dark\">Yes</span>' : '<span class=\"badge bg-light text-dark\">No</span>';\n var policyErrorHtml = '';\n if (item.policy_error) {\n policyErrorHtml = '<div><small class=\"text-danger\">' + escapeHtml(String(item.policy_error)) + '</small></div>';\n }\n\n rows += '<tr>' +\n '<td>' + escapeHtml(textOrDash(item.job_type)) + policyErrorHtml + '</td>' +\n '<td>' + enabledBadge + '</td>' +\n '<td><small>' + escapeHtml(detector) + '</small></td>' +\n '<td>' + inFlightBadge + '</td>' +\n '<td><small>' + escapeHtml(maxRuntimeText) + '</small></td>' +\n '<td><small>' + escapeHtml(globalExecText) + '</small></td>' +\n '<td><small>' + escapeHtml(perWorkerExecText) + '</small></td>' +\n '<td><small>' + escapeHtml(executorWorkersText) + '</small></td>' +\n '<td><small>' + escapeHtml(effectiveExecText) + '</small></td>' +\n '<td><small>' + escapeHtml(lastRunText) + '</small></td>' +\n '</tr>';\n }\n\n tbody.innerHTML = rows;\n }\n\n function renderSchedulerStatus() {\n var valueNodes = document.querySelectorAll('.plugin-scheduler-next-run');\n if (!valueNodes.length) {\n return;\n }\n var metaNodes = document.querySelectorAll('.plugin-scheduler-next-run-meta');\n var status = state.schedulerStatus || {};\n var nextRun = parseTime(status.next_detection_at);\n var display = nextRun || '-';\n valueNodes.forEach(function(node) {\n node.textContent = display;\n });\n var metaText = nextRun ? 'Local time' : 'Not scheduled';\n if (metaNodes.length) {\n metaNodes.forEach(function(node) {\n node.textContent = metaText;\n });\n }\n }\n\n function renderWorkers() {\n var tbody = document.getElementById('plugin-workers-table-body');\n if (!state.workers.length) {\n tbody.innerHTML = '<tr><td colspan=\"4\" class=\"text-muted text-center py-3\">No connected workers</td></tr>';\n return;\n }\n\n var rows = '';\n for (var i = 0; i < state.workers.length; i++) {\n var worker = state.workers[i];\n var workerID = textOrDash(getWorkerField(worker, 'workerID'));\n var address = textOrDash(getWorkerField(worker, 'address'));\n var caps = getWorkerField(worker, 'capabilities') || {};\n var capBadges = [];\n\n Object.keys(caps).sort().forEach(function(jobType) {\n var cap = caps[jobType] || {};\n var labels = [];\n if (cap.can_detect) {\n labels.push('D');\n }\n if (cap.can_execute) {\n labels.push('E');\n }\n var text = jobType;\n if (labels.length) {\n text += ' (' + labels.join('/') + ')';\n }\n capBadges.push('<span class=\"badge bg-secondary plugin-capability-badge\">' + escapeHtml(text) + '</span>');\n });\n\n if (!capBadges.length) {\n capBadges.push('<span class=\"text-muted\">-</span>');\n }\n\n var heartbeat = getWorkerField(worker, 'heartbeat') || {};\n var detUsed = Number(heartbeat.detection_slots_used || heartbeat.DetectionSlotsUsed || 0);\n var detTotal = Number(heartbeat.detection_slots_total || heartbeat.DetectionSlotsTotal || 0);\n var execUsed = Number(heartbeat.execution_slots_used || heartbeat.ExecutionSlotsUsed || 0);\n var execTotal = Number(heartbeat.execution_slots_total || heartbeat.ExecutionSlotsTotal || 0);\n var runningWork = heartbeat.running_work || heartbeat.RunningWork || [];\n var queuedByType = heartbeat.queued_jobs_by_type || heartbeat.QueuedJobsByType || {};\n var queuedTotal = 0;\n Object.keys(queuedByType || {}).forEach(function(jobType) {\n var queuedCount = Number(queuedByType[jobType] || 0);\n if (Number.isFinite(queuedCount) && queuedCount > 0) {\n queuedTotal += queuedCount;\n }\n });\n var loadLines = [];\n loadLines.push('Detect: ' + String(detUsed) + '/' + String(detTotal));\n loadLines.push('Execute: ' + String(execUsed) + '/' + String(execTotal));\n loadLines.push('Running: ' + String(Array.isArray(runningWork) ? runningWork.length : 0));\n loadLines.push('Queued: ' + String(queuedTotal));\n\n rows += '<tr>' +\n '<td><div class=\"fw-semibold\">' + escapeHtml(workerID) + '</div></td>' +\n '<td><small>' + escapeHtml(address) + '</small></td>' +\n '<td>' + capBadges.join('') + '</td>' +\n '<td><small>' + escapeHtml(loadLines.join(' | ')) + '</small></td>' +\n '</tr>';\n }\n\n tbody.innerHTML = rows;\n }\n\n function normalizeJobStateForFilter(value) {\n var normalized = String(value || '').toLowerCase();\n if (normalized.indexOf('job_state_') === 0) {\n normalized = normalized.slice('job_state_'.length);\n }\n return normalized;\n }\n\n function matchesExecutionStateFilter(jobState, selectedFilter) {\n var target = normalizeJobStateForFilter(selectedFilter);\n if (!target) {\n return true;\n }\n var actual = normalizeJobStateForFilter(jobState);\n if (actual === target) {\n return true;\n }\n if (target === 'running' && actual === 'in_progress') {\n return true;\n }\n return false;\n }\n\n function isDetectionActivitySource(source) {\n var normalized = String(source || '').toLowerCase();\n if (!normalized) {\n return false;\n }\n if (normalized === 'worker_detection') {\n return true;\n }\n return normalized.indexOf('detector') >= 0;\n }\n\n function renderExecutionJobs() {\n var tbody = document.getElementById('plugin-execution-jobs-table-body');\n if (!tbody) {\n return;\n }\n\n var filteredJobs = [];\n var allJobs = Array.isArray(state.allJobs) ? state.allJobs : [];\n for (var i = 0; i < allJobs.length; i++) {\n var job = allJobs[i] || {};\n var jobType = String(job.job_type || '').trim();\n if (state.selectedJobType && jobType !== state.selectedJobType) {\n continue;\n }\n if (!matchesExecutionStateFilter(job.state, state.monitorExecutionJobState)) {\n continue;\n }\n filteredJobs.push(job);\n }\n\n if (!filteredJobs.length) {\n tbody.innerHTML = '<tr><td colspan=\"7\" class=\"text-muted text-center py-3\">No execution jobs tracked yet</td></tr>';\n return;\n }\n\n var rows = '';\n for (var j = 0; j < filteredJobs.length; j++) {\n var executionJob = filteredJobs[j] || {};\n var progress = Number(executionJob.progress || 0);\n if (!Number.isFinite(progress) || progress < 0) {\n progress = 0;\n }\n if (progress > 100) {\n progress = 100;\n }\n\n var barClass = 'bg-info';\n var stateText = String(executionJob.state || 'unknown').toLowerCase();\n if (stateText.indexOf('failed') >= 0) {\n barClass = 'bg-danger';\n } else if (stateText.indexOf('succeed') >= 0 || stateText.indexOf('complete') >= 0) {\n barClass = 'bg-success';\n } else if (stateText.indexOf('run') >= 0 || stateText.indexOf('progress') >= 0) {\n barClass = 'bg-warning';\n }\n\n var jobTypeCell = escapeHtml(textOrDash(executionJob.job_type));\n var execLabels = executionJob.labels || {};\n if (execLabels.batch === 'true' && execLabels.batch_size) {\n jobTypeCell += ' <span class=\"badge bg-info\">' + escapeHtml(execLabels.batch_size) + ' moves</span>';\n }\n\n rows += '<tr>' +\n '<td>' + renderJobLink(executionJob.job_id) + '</td>' +\n '<td>' + jobTypeCell + '</td>' +\n '<td><span class=\"badge bg-light text-dark\">' + escapeHtml(textOrDash(executionJob.state)) + '</span></td>' +\n '<td class=\"plugin-job-progress\"><div class=\"progress\" style=\"height: 14px;\"><div class=\"progress-bar ' + barClass + '\" role=\"progressbar\" style=\"width:' + progress + '%\">' + Math.round(progress) + '%</div></div></td>' +\n '<td><small>' + escapeHtml(textOrDash(executionJob.worker_id)) + '</small></td>' +\n '<td><small>' + escapeHtml(parseTime(executionJob.updated_at) || '-') + '</small></td>' +\n '<td><small>' + escapeHtml(textOrDash(executionJob.message)) + '</small></td>' +\n '</tr>';\n }\n\n tbody.innerHTML = rows;\n }\n\n function renderQueueJobs() {\n var tbody = document.getElementById('plugin-queue-jobs-table-body');\n if (!tbody) {\n return;\n }\n\n var filteredJobs = [];\n var allJobs = Array.isArray(state.allJobs) ? state.allJobs : [];\n for (var i = 0; i < allJobs.length; i++) {\n var queueJob = allJobs[i] || {};\n var queueJobType = String(queueJob.job_type || '').trim();\n if (state.selectedJobType && queueJobType !== state.selectedJobType) {\n continue;\n }\n var normalizedState = normalizeJobStateForFilter(queueJob.state);\n if (normalizedState !== 'pending' && normalizedState !== 'assigned' && normalizedState !== 'running' && normalizedState !== 'in_progress') {\n continue;\n }\n filteredJobs.push(queueJob);\n }\n\n if (!filteredJobs.length) {\n tbody.innerHTML = '<tr><td colspan=\"7\" class=\"text-muted text-center py-3\">No queued/active jobs</td></tr>';\n return;\n }\n\n var rows = '';\n for (var j = 0; j < filteredJobs.length; j++) {\n var queueItem = filteredJobs[j] || {};\n var progress = Number(queueItem.progress || 0);\n if (!Number.isFinite(progress) || progress < 0) {\n progress = 0;\n }\n if (progress > 100) {\n progress = 100;\n }\n\n var barClass = 'bg-warning';\n var queueStateText = String(queueItem.state || 'unknown').toLowerCase();\n if (queueStateText.indexOf('run') >= 0 || queueStateText.indexOf('progress') >= 0) {\n barClass = 'bg-info';\n }\n\n rows += '<tr>' +\n '<td>' + renderJobLink(queueItem.job_id) + '</td>' +\n '<td>' + escapeHtml(textOrDash(queueItem.job_type)) + '</td>' +\n '<td><span class=\"badge bg-light text-dark\">' + escapeHtml(textOrDash(queueItem.state)) + '</span></td>' +\n '<td class=\"plugin-job-progress\"><div class=\"progress\" style=\"height: 14px;\"><div class=\"progress-bar ' + barClass + '\" role=\"progressbar\" style=\"width:' + progress + '%\">' + Math.round(progress) + '%</div></div></td>' +\n '<td><small>' + escapeHtml(textOrDash(queueItem.worker_id)) + '</small></td>' +\n '<td><small>' + escapeHtml(parseTime(queueItem.updated_at) || '-') + '</small></td>' +\n '<td><small>' + escapeHtml(textOrDash(queueItem.message)) + '</small></td>' +\n '</tr>';\n }\n\n tbody.innerHTML = rows;\n }\n\n function renderDetectionJobs() {\n var tbody = document.getElementById('plugin-detection-table-body');\n if (!tbody) {\n return;\n }\n\n var filteredActivities = [];\n var allActivities = Array.isArray(state.allActivities) ? state.allActivities : [];\n for (var i = 0; i < allActivities.length; i++) {\n var activity = allActivities[i] || {};\n var jobType = String(activity.job_type || '').trim();\n if (state.selectedJobType && jobType !== state.selectedJobType) {\n continue;\n }\n if (!isDetectionActivitySource(activity.source)) {\n continue;\n }\n filteredActivities.push(activity);\n }\n\n if (!filteredActivities.length) {\n tbody.innerHTML = '<tr><td colspan=\"7\" class=\"text-muted text-center py-3\">No detection jobs tracked yet</td></tr>';\n return;\n }\n\n var rows = '';\n for (var j = 0; j < filteredActivities.length; j++) {\n var detectionActivity = filteredActivities[j] || {};\n rows += '<tr>' +\n '<td><small>' + escapeHtml(parseTime(detectionActivity.occurred_at) || '-') + '</small></td>' +\n '<td>' + escapeHtml(textOrDash(detectionActivity.job_type)) + '</td>' +\n '<td><small>' + escapeHtml(textOrDash(detectionActivity.request_id)) + '</small></td>' +\n '<td><small>' + escapeHtml(textOrDash(detectionActivity.worker_id)) + '</small></td>' +\n '<td>' + escapeHtml(textOrDash(detectionActivity.stage)) + '</td>' +\n '<td><span class=\"badge bg-light text-dark\">' + escapeHtml(textOrDash(detectionActivity.source)) + '</span></td>' +\n '<td><small>' + escapeHtml(textOrDash(detectionActivity.message)) + '</small></td>' +\n '</tr>';\n }\n\n tbody.innerHTML = rows;\n }\n\n function renderExecutionActivities() {\n var tbody = document.getElementById('plugin-execution-activities-table-body');\n if (!tbody) {\n return;\n }\n\n var filteredActivities = [];\n var allActivities = Array.isArray(state.allActivities) ? state.allActivities : [];\n for (var i = 0; i < allActivities.length; i++) {\n var activity = allActivities[i] || {};\n if (isDetectionActivitySource(activity.source)) {\n continue;\n }\n var jobType = String(activity.job_type || '').trim();\n if (state.selectedJobType && jobType !== state.selectedJobType) {\n continue;\n }\n filteredActivities.push(activity);\n }\n\n if (!filteredActivities.length) {\n tbody.innerHTML = '<tr><td colspan=\"6\" class=\"text-muted text-center py-3\">No execution activities yet</td></tr>';\n return;\n }\n\n var rows = '';\n for (var j = 0; j < filteredActivities.length; j++) {\n var executionActivity = filteredActivities[j] || {};\n rows += '<tr>' +\n '<td><small>' + escapeHtml(parseTime(executionActivity.occurred_at) || '-') + '</small></td>' +\n '<td>' + escapeHtml(textOrDash(executionActivity.job_type)) + '</td>' +\n '<td>' + renderJobLink(executionActivity.job_id) + '</td>' +\n '<td><span class=\"badge bg-light text-dark\">' + escapeHtml(textOrDash(executionActivity.source)) + '</span></td>' +\n '<td>' + escapeHtml(textOrDash(executionActivity.stage)) + '</td>' +\n '<td><small>' + escapeHtml(textOrDash(executionActivity.message)) + '</small></td>' +\n '</tr>';\n }\n\n tbody.innerHTML = rows;\n }\n\n function getSelectValueArray(selectEl) {\n if (!selectEl) {\n return [];\n }\n var values = [];\n for (var i = 0; i < selectEl.options.length; i++) {\n if (selectEl.options[i].selected) {\n values.push(selectEl.options[i].value);\n }\n }\n return values;\n }\n\n function configValueToNative(configValue) {\n if (!configValue || typeof configValue !== 'object') {\n return null;\n }\n if (Object.prototype.hasOwnProperty.call(configValue, 'bool_value')) {\n return !!configValue.bool_value;\n }\n if (Object.prototype.hasOwnProperty.call(configValue, 'int64_value')) {\n return configValue.int64_value;\n }\n if (Object.prototype.hasOwnProperty.call(configValue, 'double_value')) {\n return configValue.double_value;\n }\n if (Object.prototype.hasOwnProperty.call(configValue, 'string_value')) {\n return configValue.string_value;\n }\n if (Object.prototype.hasOwnProperty.call(configValue, 'bytes_value')) {\n return configValue.bytes_value;\n }\n if (Object.prototype.hasOwnProperty.call(configValue, 'duration_value')) {\n return configValue.duration_value;\n }\n if (configValue.string_list && Array.isArray(configValue.string_list.values)) {\n return configValue.string_list.values.slice();\n }\n if (configValue.int64_list && Array.isArray(configValue.int64_list.values)) {\n return configValue.int64_list.values.slice();\n }\n if (configValue.double_list && Array.isArray(configValue.double_list.values)) {\n return configValue.double_list.values.slice();\n }\n if (configValue.bool_list && Array.isArray(configValue.bool_list.values)) {\n return configValue.bool_list.values.slice();\n }\n if (configValue.list_value && Array.isArray(configValue.list_value.values)) {\n return configValue.list_value.values.map(function(v) {\n return configValueToNative(v);\n });\n }\n if (configValue.map_value && configValue.map_value.fields) {\n var out = {};\n Object.keys(configValue.map_value.fields).forEach(function(k) {\n out[k] = configValueToNative(configValue.map_value.fields[k]);\n });\n return out;\n }\n return null;\n }\n\n function inferConfigValue(value) {\n if (value === null || value === undefined) {\n return { string_value: '' };\n }\n if (typeof value === 'boolean') {\n return { bool_value: value };\n }\n if (typeof value === 'number') {\n if (Number.isInteger(value)) {\n return { int64_value: String(value) };\n }\n return { double_value: value };\n }\n if (typeof value === 'string') {\n return { string_value: value };\n }\n if (Array.isArray(value)) {\n var listValues = value.map(function(item) {\n return inferConfigValue(item);\n });\n return { list_value: { values: listValues } };\n }\n if (typeof value === 'object') {\n var fields = {};\n Object.keys(value).forEach(function(key) {\n fields[key] = inferConfigValue(value[key]);\n });\n return { map_value: { fields: fields } };\n }\n return { string_value: String(value) };\n }\n\n function nativeToConfigValue(value, fieldType, widget, field) {\n var normalizedFieldType = normalizeEnum(fieldType);\n var normalizedWidget = normalizeEnum(widget);\n\n if (normalizedFieldType === 'CONFIG_FIELD_TYPE_BOOL') {\n return { bool_value: !!value };\n }\n\n if (normalizedFieldType === 'CONFIG_FIELD_TYPE_INT64') {\n if (value === '' || value === null || value === undefined) {\n return null;\n }\n var intValue = parseInt(value, 10);\n if (Number.isNaN(intValue)) {\n throw new Error('Invalid int64 value for field ' + field.name);\n }\n return { int64_value: String(intValue) };\n }\n\n if (normalizedFieldType === 'CONFIG_FIELD_TYPE_DOUBLE') {\n if (value === '' || value === null || value === undefined) {\n return null;\n }\n var doubleValue = parseFloat(value);\n if (Number.isNaN(doubleValue)) {\n throw new Error('Invalid number for field ' + field.name);\n }\n return { double_value: doubleValue };\n }\n\n if (normalizedFieldType === 'CONFIG_FIELD_TYPE_DURATION') {\n if (value === '' || value === null || value === undefined) {\n return null;\n }\n var text = String(value).trim();\n if (!text) {\n return null;\n }\n if (/^\\d+$/.test(text)) {\n text += 's';\n }\n return { duration_value: text };\n }\n\n if (normalizedFieldType === 'CONFIG_FIELD_TYPE_LIST') {\n if (value === '' || value === null || value === undefined) {\n return null;\n }\n\n if (normalizedWidget === 'CONFIG_WIDGET_MULTI_SELECT' && Array.isArray(value)) {\n return { string_list: { values: value } };\n }\n\n if (typeof value === 'string') {\n try {\n var parsed = JSON.parse(value);\n if (!Array.isArray(parsed)) {\n throw new Error('Expected JSON array');\n }\n return inferConfigValue(parsed);\n } catch (e) {\n throw new Error('Invalid JSON list for field ' + field.name + ': ' + e.message);\n }\n }\n return inferConfigValue(value);\n }\n\n if (normalizedFieldType === 'CONFIG_FIELD_TYPE_OBJECT') {\n if (value === '' || value === null || value === undefined) {\n return null;\n }\n if (typeof value === 'string') {\n try {\n var parsedObject = JSON.parse(value);\n if (!parsedObject || typeof parsedObject !== 'object' || Array.isArray(parsedObject)) {\n throw new Error('Expected JSON object');\n }\n return inferConfigValue(parsedObject);\n } catch (e) {\n throw new Error('Invalid JSON object for field ' + field.name + ': ' + e.message);\n }\n }\n return inferConfigValue(value);\n }\n\n if (normalizedFieldType === 'CONFIG_FIELD_TYPE_ENUM') {\n if (normalizedWidget === 'CONFIG_WIDGET_MULTI_SELECT') {\n if (!value || !value.length) {\n return null;\n }\n return { string_list: { values: value } };\n }\n if (value === '' || value === null || value === undefined) {\n return null;\n }\n return { string_value: String(value) };\n }\n\n if (value === '' || value === null || value === undefined) {\n return null;\n }\n\n return { string_value: String(value) };\n }\n\n function toInputID(scope, fieldName) {\n return 'plugin-' + scope + '-field-' + String(fieldName || '').replace(/[^a-zA-Z0-9_-]/g, '_');\n }\n\n function resolveFieldConfigValue(scope, field) {\n if (!field || !field.name) {\n return null;\n }\n var fromConfig = null;\n if (state.config) {\n if (scope === 'admin' && state.config.admin_config_values) {\n fromConfig = state.config.admin_config_values[field.name];\n }\n if (scope === 'worker' && state.config.worker_config_values) {\n fromConfig = state.config.worker_config_values[field.name];\n }\n }\n if (fromConfig) {\n return fromConfig;\n }\n\n var formDefaults = null;\n if (scope === 'admin' && state.descriptor && state.descriptor.admin_config_form && state.descriptor.admin_config_form.default_values) {\n formDefaults = state.descriptor.admin_config_form.default_values[field.name];\n }\n if (scope === 'worker' && state.descriptor && state.descriptor.worker_config_form && state.descriptor.worker_config_form.default_values) {\n formDefaults = state.descriptor.worker_config_form.default_values[field.name];\n }\n if (formDefaults) {\n return formDefaults;\n }\n\n if (scope === 'worker' && state.descriptor && state.descriptor.worker_default_values) {\n return state.descriptor.worker_default_values[field.name] || null;\n }\n\n return null;\n }\n\n function renderFieldInput(scope, field, value) {\n var inputID = toInputID(scope, field.name);\n var normalizedType = normalizeEnum(field.field_type);\n var normalizedWidget = normalizeEnum(field.widget);\n var input;\n\n if (normalizedType === 'CONFIG_FIELD_TYPE_BOOL' || normalizedWidget === 'CONFIG_WIDGET_TOGGLE') {\n input = document.createElement('input');\n input.type = 'checkbox';\n input.className = 'form-check-input';\n input.id = inputID;\n input.checked = !!value;\n } else if ((normalizedType === 'CONFIG_FIELD_TYPE_ENUM' && normalizedWidget === 'CONFIG_WIDGET_MULTI_SELECT') || normalizedWidget === 'CONFIG_WIDGET_MULTI_SELECT') {\n input = document.createElement('select');\n input.className = 'form-select';\n input.id = inputID;\n input.multiple = true;\n var selected = Array.isArray(value) ? value : [];\n var options = Array.isArray(field.options) ? field.options : [];\n for (var i = 0; i < options.length; i++) {\n var option = document.createElement('option');\n option.value = options[i].value || '';\n option.textContent = options[i].label || options[i].value || '';\n if (selected.indexOf(option.value) >= 0) {\n option.selected = true;\n }\n input.appendChild(option);\n }\n } else if (normalizedType === 'CONFIG_FIELD_TYPE_ENUM' || normalizedWidget === 'CONFIG_WIDGET_SELECT') {\n input = document.createElement('select');\n input.className = 'form-select';\n input.id = inputID;\n var blankOption = document.createElement('option');\n blankOption.value = '';\n blankOption.textContent = '-- select --';\n input.appendChild(blankOption);\n var enumOptions = Array.isArray(field.options) ? field.options : [];\n for (var j = 0; j < enumOptions.length; j++) {\n var enumOption = document.createElement('option');\n enumOption.value = enumOptions[j].value || '';\n enumOption.textContent = enumOptions[j].label || enumOptions[j].value || '';\n if (String(value) === enumOption.value) {\n enumOption.selected = true;\n }\n input.appendChild(enumOption);\n }\n } else if (normalizedType === 'CONFIG_FIELD_TYPE_INT64' || normalizedType === 'CONFIG_FIELD_TYPE_DOUBLE' || normalizedWidget === 'CONFIG_WIDGET_NUMBER') {\n input = document.createElement('input');\n input.type = 'number';\n if (normalizedType === 'CONFIG_FIELD_TYPE_DOUBLE') {\n input.step = 'any';\n }\n input.className = 'form-control';\n input.id = inputID;\n if (value !== null && value !== undefined && value !== '') {\n input.value = String(value);\n }\n } else if (normalizedType === 'CONFIG_FIELD_TYPE_LIST' || normalizedType === 'CONFIG_FIELD_TYPE_OBJECT' || normalizedWidget === 'CONFIG_WIDGET_TEXTAREA') {\n input = document.createElement('textarea');\n input.className = 'form-control';\n input.id = inputID;\n input.rows = 4;\n if (normalizedType === 'CONFIG_FIELD_TYPE_LIST' || normalizedType === 'CONFIG_FIELD_TYPE_OBJECT') {\n input.placeholder = normalizedType === 'CONFIG_FIELD_TYPE_LIST' ? '[...]' : '{...}';\n }\n if (value !== null && value !== undefined && value !== '') {\n if (typeof value === 'object') {\n input.value = JSON.stringify(value, null, 2);\n } else {\n input.value = String(value);\n }\n }\n } else {\n input = document.createElement('input');\n input.className = 'form-control';\n input.id = inputID;\n if (normalizedWidget === 'CONFIG_WIDGET_PASSWORD' || field.sensitive) {\n input.type = 'password';\n } else {\n input.type = 'text';\n }\n if (value !== null && value !== undefined && value !== '') {\n input.value = String(value);\n }\n }\n\n if (field.placeholder && input.tagName === 'INPUT') {\n input.placeholder = field.placeholder;\n }\n if (field.placeholder && input.tagName === 'TEXTAREA') {\n input.placeholder = field.placeholder;\n }\n\n if (field.required) {\n input.required = true;\n }\n if (field.read_only) {\n input.disabled = true;\n }\n\n return input;\n }\n\n function renderConfigForm(scope, rootID, form) {\n var root = document.getElementById(rootID);\n if (!root) {\n return;\n }\n\n root.innerHTML = '';\n if (!form || !Array.isArray(form.sections) || form.sections.length === 0) {\n root.innerHTML = '<div class=\"text-muted\">No fields provided by descriptor.</div>';\n return;\n }\n\n for (var s = 0; s < form.sections.length; s++) {\n var section = form.sections[s] || {};\n var sectionCard = document.createElement('div');\n sectionCard.className = 'card mb-3';\n\n var sectionHeader = document.createElement('div');\n sectionHeader.className = 'card-header py-2';\n sectionHeader.innerHTML = '<strong>' + escapeHtml(textOrDash(section.title || section.section_id)) + '</strong>';\n sectionCard.appendChild(sectionHeader);\n\n var sectionBody = document.createElement('div');\n sectionBody.className = 'card-body';\n\n if (section.description) {\n var sectionDescription = document.createElement('p');\n sectionDescription.className = 'text-muted small';\n sectionDescription.textContent = section.description;\n sectionBody.appendChild(sectionDescription);\n }\n\n var fields = Array.isArray(section.fields) ? section.fields : [];\n if (!fields.length) {\n var noFields = document.createElement('div');\n noFields.className = 'text-muted';\n noFields.textContent = 'No fields in this section.';\n sectionBody.appendChild(noFields);\n }\n\n for (var f = 0; f < fields.length; f++) {\n var field = fields[f] || {};\n if (!field.name) {\n continue;\n }\n\n var configValue = resolveFieldConfigValue(scope, field);\n var nativeValue = configValueToNative(configValue);\n\n var wrapper = document.createElement('div');\n wrapper.className = 'mb-3 plugin-field';\n wrapper.dataset.scope = scope;\n wrapper.dataset.fieldName = field.name;\n wrapper.dataset.fieldType = normalizeEnum(field.field_type);\n wrapper.dataset.widget = normalizeEnum(field.widget);\n wrapper.dataset.visibleWhenField = field.visible_when_field || '';\n wrapper.dataset.visibleWhenEquals = JSON.stringify(field.visible_when_equals || null);\n\n var label = document.createElement('label');\n label.className = 'form-label';\n label.setAttribute('for', toInputID(scope, field.name));\n label.innerHTML = escapeHtml(field.label || field.name) + (field.required ? ' <span class=\"text-danger\">*</span>' : '');\n wrapper.appendChild(label);\n\n var input = renderFieldInput(scope, field, nativeValue);\n wrapper.appendChild(input);\n\n if (field.description || field.help_text) {\n var help = document.createElement('div');\n help.className = 'form-text';\n help.textContent = field.help_text || field.description || '';\n wrapper.appendChild(help);\n }\n\n sectionBody.appendChild(wrapper);\n }\n\n sectionCard.appendChild(sectionBody);\n root.appendChild(sectionCard);\n }\n\n root.addEventListener('change', function() {\n applyVisibility(scope);\n });\n root.addEventListener('input', function() {\n applyVisibility(scope);\n });\n\n applyVisibility(scope);\n }\n\n function readRawFieldValue(scope, fieldName) {\n var input = document.getElementById(toInputID(scope, fieldName));\n if (!input) {\n return null;\n }\n if (input.tagName === 'SELECT' && input.multiple) {\n return getSelectValueArray(input);\n }\n if (input.type === 'checkbox') {\n return !!input.checked;\n }\n return input.value;\n }\n\n function compareVisibleValue(currentValue, expectedValueObject) {\n if (!expectedValueObject) {\n return true;\n }\n var expectedNative = configValueToNative(expectedValueObject);\n if (Array.isArray(expectedNative)) {\n if (!Array.isArray(currentValue)) {\n return false;\n }\n if (expectedNative.length !== currentValue.length) {\n return false;\n }\n for (var i = 0; i < expectedNative.length; i++) {\n if (String(expectedNative[i]) !== String(currentValue[i])) {\n return false;\n }\n }\n return true;\n }\n return String(currentValue) === String(expectedNative);\n }\n\n function applyVisibility(scope) {\n var fields = document.querySelectorAll('.plugin-field[data-scope=\"' + scope + '\"]');\n for (var i = 0; i < fields.length; i++) {\n var wrapper = fields[i];\n var dependsOn = wrapper.dataset.visibleWhenField;\n if (!dependsOn) {\n wrapper.classList.remove('plugin-field-hidden');\n continue;\n }\n\n var expectedRaw = null;\n try {\n expectedRaw = JSON.parse(wrapper.dataset.visibleWhenEquals || 'null');\n } catch (e) {\n expectedRaw = null;\n }\n var current = readRawFieldValue(scope, dependsOn);\n var visible = compareVisibleValue(current, expectedRaw);\n if (visible) {\n wrapper.classList.remove('plugin-field-hidden');\n } else {\n wrapper.classList.add('plugin-field-hidden');\n }\n }\n }\n\n function collectConfigValues(scope, form) {\n var out = {};\n if (!form || !Array.isArray(form.sections)) {\n return out;\n }\n\n for (var s = 0; s < form.sections.length; s++) {\n var section = form.sections[s] || {};\n var fields = Array.isArray(section.fields) ? section.fields : [];\n for (var f = 0; f < fields.length; f++) {\n var field = fields[f] || {};\n if (!field.name) {\n continue;\n }\n\n var wrapper = document.querySelector('.plugin-field[data-scope=\"' + scope + '\"][data-field-name=\"' + field.name + '\"]');\n if (wrapper && wrapper.classList.contains('plugin-field-hidden')) {\n continue;\n }\n\n var input = document.getElementById(toInputID(scope, field.name));\n if (!input) {\n continue;\n }\n\n var rawValue;\n if (input.tagName === 'SELECT' && input.multiple) {\n rawValue = getSelectValueArray(input);\n } else if (input.type === 'checkbox') {\n rawValue = !!input.checked;\n } else {\n rawValue = input.value;\n }\n\n if (field.required) {\n var missing = false;\n if (input.type === 'checkbox') {\n missing = false;\n } else if (Array.isArray(rawValue)) {\n missing = rawValue.length === 0;\n } else {\n missing = String(rawValue || '').trim() === '';\n }\n if (missing) {\n throw new Error('Missing required field: ' + (field.label || field.name));\n }\n }\n\n var converted = nativeToConfigValue(rawValue, field.field_type, field.widget, field);\n if (converted !== null) {\n out[field.name] = converted;\n }\n }\n }\n\n return out;\n }\n\n function renderDescriptorSummary() {\n var summary = document.getElementById('plugin-descriptor-summary');\n if (!state.descriptor) {\n summary.innerHTML = 'No descriptor loaded.';\n return;\n }\n\n var descriptor = state.descriptor;\n var html = '';\n html += '<div><strong>' + escapeHtml(textOrDash(descriptor.display_name || descriptor.job_type)) + '</strong></div>';\n html += '<div><small>Job Type: ' + escapeHtml(textOrDash(descriptor.job_type)) + '</small></div>';\n html += '<div><small>Descriptor Version: ' + escapeHtml(textOrDash(descriptor.descriptor_version)) + '</small></div>';\n if (descriptor.description) {\n html += '<div class=\"mt-1\">' + escapeHtml(descriptor.description) + '</div>';\n }\n summary.innerHTML = html;\n }\n\n function fillAdminSettings() {\n var runtimeConfig = (state.config && state.config.admin_runtime) ? state.config.admin_runtime : {};\n var defaults = (state.descriptor && state.descriptor.admin_runtime_defaults) ? state.descriptor.admin_runtime_defaults : {};\n\n function pickNumber(key) {\n if (runtimeConfig && runtimeConfig[key] !== undefined && runtimeConfig[key] !== null) {\n return runtimeConfig[key];\n }\n if (defaults && defaults[key] !== undefined && defaults[key] !== null) {\n return defaults[key];\n }\n return 0;\n }\n\n function pickBool(key) {\n if (runtimeConfig && runtimeConfig[key] !== undefined && runtimeConfig[key] !== null) {\n return !!runtimeConfig[key];\n }\n if (defaults && defaults[key] !== undefined && defaults[key] !== null) {\n return !!defaults[key];\n }\n return true;\n }\n\n document.getElementById('plugin-admin-enabled').checked = pickBool('enabled');\n document.getElementById('plugin-admin-detection-interval').value = String(pickNumber('detection_interval_seconds'));\n document.getElementById('plugin-admin-detection-timeout').value = String(pickNumber('detection_timeout_seconds'));\n document.getElementById('plugin-admin-max-runtime').value = String(pickNumber('job_type_max_runtime_seconds'));\n document.getElementById('plugin-admin-max-results').value = String(pickNumber('max_jobs_per_detection'));\n document.getElementById('plugin-admin-global-exec').value = String(pickNumber('global_execution_concurrency'));\n document.getElementById('plugin-admin-per-worker-exec').value = String(pickNumber('per_worker_execution_concurrency'));\n document.getElementById('plugin-admin-retry-limit').value = String(pickNumber('retry_limit'));\n document.getElementById('plugin-admin-retry-backoff').value = String(pickNumber('retry_backoff_seconds'));\n }\n\n function collectAdminSettings() {\n function getInt(id) {\n var raw = String(document.getElementById(id).value || '').trim();\n if (!raw) {\n return 0;\n }\n var parsed = parseInt(raw, 10);\n if (Number.isNaN(parsed) || parsed < 0) {\n throw new Error('Invalid number for ' + id);\n }\n return parsed;\n }\n\n return {\n enabled: !!document.getElementById('plugin-admin-enabled').checked,\n detection_interval_seconds: getInt('plugin-admin-detection-interval'),\n detection_timeout_seconds: getInt('plugin-admin-detection-timeout'),\n job_type_max_runtime_seconds: getInt('plugin-admin-max-runtime'),\n max_jobs_per_detection: getInt('plugin-admin-max-results'),\n global_execution_concurrency: getInt('plugin-admin-global-exec'),\n per_worker_execution_concurrency: getInt('plugin-admin-per-worker-exec'),\n retry_limit: getInt('plugin-admin-retry-limit'),\n retry_backoff_seconds: getInt('plugin-admin-retry-backoff'),\n };\n }\n\n function renderRunHistory(history) {\n var successBody = document.getElementById('plugin-runs-success-body');\n var errorBody = document.getElementById('plugin-runs-error-body');\n\n var successRuns = (history && Array.isArray(history.successful_runs)) ? history.successful_runs : [];\n var errorRuns = (history && Array.isArray(history.error_runs)) ? history.error_runs : [];\n\n if (!successRuns.length) {\n successBody.innerHTML = '<tr><td colspan=\"4\" class=\"text-muted\">No successful runs</td></tr>';\n } else {\n var successRows = '';\n for (var i = 0; i < successRuns.length; i++) {\n var run = successRuns[i] || {};\n successRows += '<tr>' +\n '<td><small>' + escapeHtml(parseTime(run.completed_at) || '-') + '</small></td>' +\n '<td>' + renderJobLink(run.job_id) + '</td>' +\n '<td><small>' + escapeHtml(textOrDash(run.worker_id)) + '</small></td>' +\n '<td><small>' + escapeHtml(formatDurationMs(run.duration_ms)) + '</small></td>' +\n '</tr>';\n }\n successBody.innerHTML = successRows;\n }\n\n if (!errorRuns.length) {\n errorBody.innerHTML = '<tr><td colspan=\"4\" class=\"text-muted\">No error runs</td></tr>';\n } else {\n var errorRows = '';\n for (var j = 0; j < errorRuns.length; j++) {\n var errorRun = errorRuns[j] || {};\n errorRows += '<tr>' +\n '<td><small>' + escapeHtml(parseTime(errorRun.completed_at) || '-') + '</small></td>' +\n '<td>' + renderJobLink(errorRun.job_id) + '</td>' +\n '<td><small>' + escapeHtml(textOrDash(errorRun.worker_id)) + '</small></td>' +\n '<td><small class=\"text-danger\">' + escapeHtml(textOrDash(errorRun.message)) + '</small></td>' +\n '</tr>';\n }\n errorBody.innerHTML = errorRows;\n }\n }\n\n function renderDetectionResult(data) {\n var root = document.getElementById('plugin-detection-results');\n if (!root) {\n return;\n }\n\n function normalizeProposals(payload) {\n if (!payload || typeof payload !== 'object') {\n return [];\n }\n if (Array.isArray(payload.proposals)) {\n return payload.proposals;\n }\n if (payload.proposals && typeof payload.proposals === 'object') {\n return Object.keys(payload.proposals).map(function(key) {\n return payload.proposals[key];\n }).filter(function(item) {\n return !!item;\n });\n }\n return [];\n }\n\n function proposalID(proposal) {\n return proposal.proposal_id || proposal.proposalId || proposal.id || '';\n }\n\n function proposalSummary(proposal) {\n return proposal.summary || proposal.title || proposalID(proposal) || 'Proposal';\n }\n\n function proposalDetail(proposal) {\n return proposal.detail || proposal.reason || proposal.message || '-';\n }\n\n function normalizeActivities(payload) {\n if (!payload || typeof payload !== 'object') {\n return [];\n }\n if (Array.isArray(payload.activities)) {\n return payload.activities;\n }\n return [];\n }\n\n function encodeDetails(details) {\n if (!details || typeof details !== 'object') {\n return '';\n }\n var text = '';\n try {\n text = JSON.stringify(details);\n } catch (e) {\n text = '';\n }\n if (!text) {\n return '';\n }\n if (text.length > 360) {\n text = text.slice(0, 360) + '...';\n }\n return text;\n }\n\n if (!data) {\n root.innerHTML = '<div class=\"text-muted\">No results.</div>';\n return;\n }\n\n var proposals = normalizeProposals(data);\n var activities = normalizeActivities(data);\n var responseCount = Number(data.count || 0);\n if (!Number.isFinite(responseCount) || responseCount < 0) {\n responseCount = proposals.length;\n }\n var requestID = data.request_id || data.requestId || '';\n var detectorWorkerID = data.detector_worker_id || data.detectorWorkerId || '';\n var totalProposals = Number(data.total_proposals || 0);\n if (!Number.isFinite(totalProposals) || totalProposals < 0) {\n totalProposals = responseCount;\n }\n\n var html = '';\n html += '<div class=\"mb-2\"><strong>Detection Run</strong></div>';\n if (requestID) {\n html += '<div><small>request_id: ' + escapeHtml(String(requestID)) + '</small></div>';\n }\n if (detectorWorkerID) {\n html += '<div><small>detector: ' + escapeHtml(String(detectorWorkerID)) + '</small></div>';\n }\n html += '<div><small>proposals: ' + escapeHtml(String(responseCount)) + ' / total: ' + escapeHtml(String(totalProposals)) + '</small></div>';\n if (data.error) {\n html += '<div class=\"mt-1 text-danger\"><small>' + escapeHtml(String(data.error)) + '</small></div>';\n }\n\n if (!proposals.length) {\n html += '<div class=\"mt-2 text-muted\">No proposals returned.</div>';\n } else {\n html += '<div class=\"mt-2 mb-1\"><strong>' + escapeHtml(String(proposals.length)) + '</strong> proposal(s)</div>';\n for (var i = 0; i < proposals.length; i++) {\n var proposal = proposals[i] || {};\n var pID = proposalID(proposal);\n var pSummary = proposalSummary(proposal);\n var pDetail = proposalDetail(proposal);\n var pPriority = proposal.priority;\n if (pPriority === null || pPriority === undefined || pPriority === '') {\n pPriority = '-';\n }\n html += '<div class=\"plugin-detection-item\">' +\n '<div class=\"fw-semibold\">' + escapeHtml(textOrDash(pSummary)) + '</div>' +\n '<div><small>proposal_id: ' + escapeHtml(textOrDash(pID)) + '</small></div>' +\n '<div><small>priority: ' + escapeHtml(textOrDash(pPriority)) + '</small></div>' +\n '<div><small>detail: ' + escapeHtml(textOrDash(pDetail)) + '</small></div>' +\n '</div>';\n }\n }\n\n if (activities.length) {\n html += '<hr class=\"my-2\"/>';\n html += '<div class=\"mb-2\"><strong>Detection Process</strong></div>';\n for (var j = 0; j < activities.length; j++) {\n var activity = activities[j] || {};\n var activityStage = activity.stage || '-';\n var activitySource = activity.source || '-';\n var activityMessage = activity.message || '-';\n var activityTime = parseTime(activity.occurred_at) || '-';\n var detailsText = encodeDetails(activity.details);\n html += '<div class=\"plugin-detection-item\">' +\n '<div class=\"fw-semibold\">' + escapeHtml(textOrDash(activityStage)) + '</div>' +\n '<div><small>source: ' + escapeHtml(textOrDash(activitySource)) + ' | at: ' + escapeHtml(activityTime) + '</small></div>' +\n '<div><small>' + escapeHtml(textOrDash(activityMessage)) + '</small></div>';\n if (detailsText) {\n html += '<div><small class=\"text-muted\">details: ' + escapeHtml(detailsText) + '</small></div>';\n }\n html += '</div>';\n }\n }\n\n root.innerHTML = html;\n }\n\n function getSelectedJobType() {\n return String(state.selectedJobType || '').trim();\n }\n\n function resetJobTypePanels() {\n state.loadedJobType = '';\n state.descriptor = null;\n state.config = null;\n document.getElementById('plugin-descriptor-summary').textContent = 'No descriptor loaded.';\n document.getElementById('plugin-admin-config-form').innerHTML = '<div class=\"text-muted\">No admin form loaded.</div>';\n document.getElementById('plugin-worker-config-form').innerHTML = '<div class=\"text-muted\">No worker form loaded.</div>';\n document.getElementById('plugin-config-updated-at').textContent = 'Not loaded';\n renderRunHistory(null);\n renderDetectionResult(null);\n }\n\n async function loadDescriptorAndConfig(jobType, forceRefresh) {\n if (!jobType) {\n return;\n }\n\n state.selectedJobType = jobType;\n renderNavigationState();\n\n var summary = document.getElementById('plugin-descriptor-summary');\n summary.textContent = 'Loading descriptor and config...';\n\n try {\n if (forceRefresh) {\n var refreshURL = '/api/plugin/job-types/' + encodePath(jobType) + '/schema?force_refresh=true';\n state.descriptor = await pluginRequest('POST', refreshURL, null);\n } else {\n try {\n state.descriptor = await pluginRequest('GET', '/api/plugin/job-types/' + encodePath(jobType) + '/descriptor');\n } catch (descriptorLoadError) {\n state.descriptor = await pluginRequest('POST', '/api/plugin/job-types/' + encodePath(jobType) + '/schema', null);\n }\n }\n } catch (schemaError) {\n state.loadedJobType = '';\n state.descriptor = null;\n summary.innerHTML = '<span class=\"text-danger\">Failed to load descriptor: ' + escapeHtml(schemaError.message) + '</span>';\n document.getElementById('plugin-admin-config-form').innerHTML = '<div class=\"text-muted\">No admin form loaded.</div>';\n document.getElementById('plugin-worker-config-form').innerHTML = '<div class=\"text-muted\">No worker form loaded.</div>';\n throw schemaError;\n }\n\n try {\n state.config = await pluginRequest('GET', '/api/plugin/job-types/' + encodePath(jobType) + '/config');\n } catch (configError) {\n state.config = null;\n notify('Failed to load config: ' + configError.message, 'error');\n }\n\n renderDescriptorSummary();\n fillAdminSettings();\n\n if (state.descriptor) {\n renderConfigForm('admin', 'plugin-admin-config-form', state.descriptor.admin_config_form || {});\n renderConfigForm('worker', 'plugin-worker-config-form', state.descriptor.worker_config_form || {});\n }\n\n if (state.config && state.config.updated_at) {\n document.getElementById('plugin-config-updated-at').textContent = 'Updated: ' + (parseTime(state.config.updated_at) || state.config.updated_at);\n } else {\n document.getElementById('plugin-config-updated-at').textContent = 'Not saved yet';\n }\n state.loadedJobType = jobType;\n\n await loadRunHistory(jobType);\n\n if (state.lastDetectionByJobType && state.lastDetectionByJobType[jobType]) {\n renderDetectionResult(state.lastDetectionByJobType[jobType]);\n } else {\n renderDetectionResult(null);\n }\n }\n\n async function loadRunHistory(jobType) {\n if (!jobType) {\n renderRunHistory(null);\n return;\n }\n try {\n var history = await pluginRequest('GET', '/api/plugin/job-types/' + encodePath(jobType) + '/runs');\n renderRunHistory(history);\n } catch (e) {\n renderRunHistory(null);\n notify('Failed to load run history: ' + e.message, 'error');\n }\n }\n\n async function saveConfig() {\n var jobType = state.selectedJobType || getSelectedJobType();\n if (!jobType) {\n notify('Select a job type first.', 'error');\n return;\n }\n if (!state.descriptor) {\n notify('Load descriptor before saving config.', 'error');\n return;\n }\n\n var payload;\n try {\n payload = {\n job_type: jobType,\n descriptor_version: state.descriptor.descriptor_version || 0,\n admin_runtime: collectAdminSettings(),\n admin_config_values: collectConfigValues('admin', state.descriptor.admin_config_form || {}),\n worker_config_values: collectConfigValues('worker', state.descriptor.worker_config_form || {}),\n };\n } catch (err) {\n notify(err.message, 'error');\n return;\n }\n\n try {\n state.config = await pluginRequest('PUT', '/api/plugin/job-types/' + encodePath(jobType) + '/config', payload);\n document.getElementById('plugin-config-updated-at').textContent = 'Updated: ' + (parseTime(state.config.updated_at) || state.config.updated_at || 'just now');\n notify('Config saved for ' + jobType, 'success');\n } catch (e) {\n notify('Failed to save config: ' + e.message, 'error');\n }\n }\n\n function getMaxResults() {\n var raw = String(document.getElementById('plugin-admin-max-results').value || '').trim();\n if (!raw) {\n return 0;\n }\n var parsed = parseInt(raw, 10);\n if (Number.isNaN(parsed) || parsed < 0) {\n return 0;\n }\n return parsed;\n }\n\n async function runDetection() {\n var jobType = state.selectedJobType || getSelectedJobType();\n if (!jobType) {\n notify('Select a job type first.', 'error');\n return;\n }\n\n try {\n var data = await pluginRequest('POST', '/api/plugin/job-types/' + encodePath(jobType) + '/detect', {\n max_results: getMaxResults(),\n });\n state.lastDetectionByJobType[jobType] = data;\n renderDetectionResult(data);\n } catch (e) {\n if (e && e.responseData) {\n state.lastDetectionByJobType[jobType] = e.responseData;\n renderDetectionResult(e.responseData);\n }\n notify('Detection failed: ' + e.message, 'error');\n return;\n }\n\n try {\n await refreshJobsAndActivities();\n } catch (refreshErr) {\n notify('Detection succeeded, but monitoring refresh failed: ' + refreshErr.message, 'error');\n }\n }\n\n async function runWorkflow() {\n var jobType = state.selectedJobType || getSelectedJobType();\n if (!jobType) {\n notify('Select a job type first.', 'error');\n return;\n }\n\n try {\n var data = await pluginRequest('POST', '/api/plugin/job-types/' + encodePath(jobType) + '/run', {\n max_results: getMaxResults(),\n });\n var resultRoot = document.getElementById('plugin-detection-results');\n resultRoot.innerHTML = '<div class=\"mb-2\"><strong>Workflow completed</strong></div>' +\n '<div>Detected: <strong>' + escapeHtml(String(data.detected_count || 0)) + '</strong></div>' +\n '<div>Ready to Execute: <strong>' + escapeHtml(String(data.ready_to_execute_count || 0)) + '</strong></div>' +\n '<div>Skipped (Already Active): <strong>' + escapeHtml(String(data.skipped_active_count || 0)) + '</strong></div>' +\n '<div>Executed: <strong>' + escapeHtml(String(data.executed_count || 0)) + '</strong></div>' +\n '<div class=\"text-success\">Success: ' + escapeHtml(String(data.success_count || 0)) + '</div>' +\n '<div class=\"text-danger\">Errors: ' + escapeHtml(String(data.error_count || 0)) + '</div>';\n await loadRunHistory(jobType);\n await refreshJobsAndActivities();\n } catch (e) {\n notify('Workflow run failed: ' + e.message, 'error');\n }\n }\n\n async function refreshJobsAndActivities() {\n var executionStateFilter = document.getElementById('plugin-monitor-job-state-filter');\n\n state.monitorExecutionJobState = executionStateFilter ? String(executionStateFilter.value || '').trim() : '';\n\n var allJobsPromise = pluginRequest('GET', appendLaneParam('/api/plugin/jobs?limit=500'));\n var allActivitiesPromise = pluginRequest('GET', appendLaneParam('/api/plugin/activities?limit=500'));\n var schedulerPromise = pluginRequest('GET', appendLaneParam('/api/plugin/scheduler-states'));\n var schedulerStatusPromise = pluginRequest('GET', appendLaneParam('/api/plugin/scheduler-status'));\n\n var allJobs = await allJobsPromise;\n var allActivities = await allActivitiesPromise;\n var schedulerStates = await schedulerPromise;\n var schedulerStatus = null;\n try {\n schedulerStatus = await schedulerStatusPromise;\n } catch (e) {\n schedulerStatus = null;\n }\n\n state.jobs = Array.isArray(allJobs) ? allJobs : [];\n state.activities = Array.isArray(allActivities) ? allActivities : [];\n state.allJobs = state.jobs;\n state.allActivities = state.activities;\n state.schedulerStates = Array.isArray(schedulerStates) ? schedulerStates : [];\n state.schedulerStatus = schedulerStatus && schedulerStatus.scheduler ? schedulerStatus.scheduler : null;\n renderQueueJobs();\n renderDetectionJobs();\n renderExecutionJobs();\n renderExecutionActivities();\n renderSchedulerStates();\n renderSchedulerStatus();\n renderStatus();\n renderJobTypeSummary();\n }\n\n async function refreshAll() {\n try {\n var previousSelectedJobType = state.selectedJobType;\n\n var workers = await pluginRequest('GET', appendLaneParam('/api/plugin/workers'));\n state.workers = Array.isArray(workers) ? workers : [];\n renderWorkers();\n\n var jobTypes = await pluginRequest('GET', appendLaneParam('/api/plugin/job-types'));\n state.jobTypes = Array.isArray(jobTypes) ? jobTypes : [];\n applyInitialNavigation();\n ensureActiveNavigation();\n renderTopTabs();\n renderNavigationState();\n\n if (!state.selectedJobType) {\n resetJobTypePanels();\n } else if (state.loadedJobType !== state.selectedJobType || previousSelectedJobType !== state.selectedJobType) {\n try {\n await loadDescriptorAndConfig(state.selectedJobType, false);\n } catch (loadError) {\n // Already rendered in UI.\n }\n }\n\n await refreshJobsAndActivities();\n } catch (e) {\n notify('Refresh failed: ' + e.message, 'error');\n }\n }\n\n function bindActions() {\n var monitorStateFilter = document.getElementById('plugin-monitor-job-state-filter');\n if (monitorStateFilter) {\n monitorStateFilter.addEventListener('change', function() {\n state.monitorExecutionJobState = String(monitorStateFilter.value || '').trim();\n renderExecutionJobs();\n });\n }\n\n var expireBtn = document.getElementById('plugin-expire-job-btn');\n if (expireBtn) {\n expireBtn.addEventListener('click', function() {\n var jobID = String(expireBtn.getAttribute('data-job-id') || '').trim();\n if (!jobID) {\n return;\n }\n expireJob(jobID);\n });\n }\n\n document.getElementById('plugin-refresh-all-btn').addEventListener('click', function() {\n refreshAll();\n });\n\n document.getElementById('plugin-refresh-schema-btn').addEventListener('click', async function() {\n var jobType = getSelectedJobType();\n if (!jobType) {\n notify('Select a job type first.', 'error');\n return;\n }\n try {\n await loadDescriptorAndConfig(jobType, true);\n notify('Schema refreshed for ' + jobType, 'success');\n } catch (e) {\n notify('Schema refresh failed: ' + e.message, 'error');\n }\n });\n\n document.getElementById('plugin-save-config-btn').addEventListener('click', function() {\n saveConfig();\n });\n\n document.getElementById('plugin-trigger-detection-btn').addEventListener('click', function() {\n runDetection();\n });\n\n document.getElementById('plugin-run-workflow-btn').addEventListener('click', function() {\n runWorkflow();\n });\n\n page.addEventListener('click', async function(event) {\n var target = event.target;\n if (!target) {\n return;\n }\n\n var topTab = target.closest('[data-plugin-top-tab]');\n if (topTab) {\n event.preventDefault();\n var topTabKey = String(topTab.getAttribute('data-plugin-top-tab') || '').trim();\n if (!topTabKey) {\n return;\n }\n if (topTabKey === 'overview') {\n state.activeTopTab = 'overview';\n renderNavigationState();\n updateURL(false);\n return;\n }\n\n var jobType = parseJobTypeFromTopTab(topTabKey);\n if (!jobType) {\n return;\n }\n state.activeTopTab = topTabKey;\n state.selectedJobType = jobType;\n ensureActiveNavigation();\n renderNavigationState();\n updateURL(false);\n if (state.loadedJobType !== jobType) {\n try {\n await loadDescriptorAndConfig(jobType, false);\n } catch (e) {\n // Already rendered in UI.\n }\n } else {\n if (state.lastDetectionByJobType && state.lastDetectionByJobType[jobType]) {\n renderDetectionResult(state.lastDetectionByJobType[jobType]);\n } else {\n renderDetectionResult(null);\n }\n await loadRunHistory(jobType);\n }\n renderQueueJobs();\n renderDetectionJobs();\n renderExecutionJobs();\n renderExecutionActivities();\n return;\n }\n\n var subTab = target.closest('[data-plugin-subtab]');\n if (subTab) {\n event.preventDefault();\n var subTabKey = normalizeSubTab(subTab.getAttribute('data-plugin-subtab'));\n state.activeSubTab = subTabKey;\n renderNavigationState();\n updateURL(false);\n return;\n }\n\n var trigger = target.closest('.plugin-job-link');\n if (!trigger) {\n return;\n }\n event.preventDefault();\n var jobID = trigger.getAttribute('data-job-id');\n if (!jobID) {\n return;\n }\n openJobDetail(jobID);\n });\n }\n\n async function init() {\n bindActions();\n renderTopTabs();\n ensureActiveNavigation();\n renderNavigationState();\n await refreshAll();\n\n // Set the initial browser history state to match the current view\n updateURL(true);\n\n state.refreshTimer = setInterval(function() {\n refreshAll();\n }, 3000);\n }\n\n window.addEventListener('popstate', async function(event) {\n var s = event.state;\n if (!s || !s.pluginTopTab) {\n // No plugin state — treat as overview\n state.activeTopTab = 'overview';\n renderNavigationState();\n return;\n }\n state.activeTopTab = String(s.pluginTopTab || 'overview');\n state.activeSubTab = normalizeSubTab(s.pluginSubTab);\n var jobType = String(s.pluginJob || '').trim();\n if (jobType) {\n state.selectedJobType = jobType;\n }\n ensureActiveNavigation();\n renderNavigationState();\n // If a job type is selected and not loaded yet, load it\n if (state.selectedJobType && state.loadedJobType !== state.selectedJobType) {\n try {\n await loadDescriptorAndConfig(state.selectedJobType, false);\n } catch (e) {\n console.error('Failed to load data on popstate navigation:', e);\n }\n }\n renderQueueJobs();\n renderDetectionJobs();\n renderExecutionJobs();\n renderExecutionActivities();\n });\n\n window.addEventListener('beforeunload', function() {\n if (state.refreshTimer) {\n clearInterval(state.refreshTimer);\n state.refreshTimer = null;\n }\n });\n\n init();\n })();\n </script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate