You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
2876 lines
138 KiB
2876 lines
138 KiB
package app
|
|
|
|
templ Plugin(page string) {
|
|
{{
|
|
currentPage := page
|
|
if currentPage == "" {
|
|
currentPage = "overview"
|
|
}
|
|
}}
|
|
<div class="container-fluid" id="plugin-page" data-plugin-page={ currentPage }>
|
|
<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>Workers</h2>
|
|
<p class="text-muted mb-0">Cluster-wide worker status, per-job configuration, detection, queue, and execution workflows.</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-4 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-4 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-4 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>
|
|
<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">Per job type detection schedule and execution 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>Next Detection</th>
|
|
<th>Interval</th>
|
|
<th>Exec Global</th>
|
|
<th>Exec/Worker</th>
|
|
<th>Executor Workers</th>
|
|
<th>Effective Exec</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 h-100">
|
|
<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>
|
|
<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-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>
|
|
</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>
|
|
<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>
|
|
.plugin-section {
|
|
display: none;
|
|
}
|
|
|
|
.plugin-section.plugin-section-active {
|
|
display: block;
|
|
}
|
|
|
|
.plugin-form-root .card {
|
|
border: 1px solid #dee2e6;
|
|
}
|
|
|
|
.plugin-field-hidden {
|
|
display: none;
|
|
}
|
|
|
|
.plugin-capability-badge {
|
|
font-size: 0.72rem;
|
|
margin-right: 0.25rem;
|
|
}
|
|
|
|
.plugin-job-progress {
|
|
min-width: 120px;
|
|
}
|
|
|
|
.plugin-detection-item {
|
|
border: 1px solid #dee2e6;
|
|
border-radius: 6px;
|
|
padding: 0.5rem 0.75rem;
|
|
margin-bottom: 0.5rem;
|
|
background: #fbfcfd;
|
|
}
|
|
|
|
.plugin-job-link {
|
|
text-decoration: none;
|
|
}
|
|
|
|
.plugin-job-detail-json {
|
|
max-height: 220px;
|
|
overflow: auto;
|
|
background: #f8f9fa;
|
|
border: 1px solid #dee2e6;
|
|
border-radius: 6px;
|
|
padding: 0.5rem;
|
|
margin-bottom: 0;
|
|
font-size: 0.82rem;
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
(function() {
|
|
var page = document.getElementById('plugin-page');
|
|
if (!page) {
|
|
return;
|
|
}
|
|
|
|
var state = {
|
|
selectedJobType: '',
|
|
jobTypes: [],
|
|
lastDetectionByJobType: {},
|
|
initialPage: String(page.getAttribute('data-plugin-page') || 'overview').trim().toLowerCase(),
|
|
activeTopTab: 'overview',
|
|
activeSubTab: 'configuration',
|
|
initialNavigationApplied: false,
|
|
monitorExecutionJobState: '',
|
|
workers: [],
|
|
jobs: [],
|
|
activities: [],
|
|
schedulerStates: [],
|
|
allJobs: [],
|
|
allActivities: [],
|
|
loadedJobType: '',
|
|
descriptor: null,
|
|
config: null,
|
|
refreshTimer: null,
|
|
};
|
|
|
|
function getCSRFToken() {
|
|
var meta = document.querySelector('meta[name="csrf-token"]');
|
|
if (!meta) {
|
|
return '';
|
|
}
|
|
return meta.getAttribute('content') || '';
|
|
}
|
|
|
|
function notify(message, level) {
|
|
if (typeof window.showAlert === 'function') {
|
|
window.showAlert(message, level || 'info');
|
|
return;
|
|
}
|
|
if (level === 'error') {
|
|
console.error(message);
|
|
}
|
|
alert(message);
|
|
}
|
|
|
|
async function readResponseJSON(response) {
|
|
var bodyText = await response.text();
|
|
if (!bodyText) {
|
|
return {};
|
|
}
|
|
try {
|
|
return JSON.parse(bodyText);
|
|
} catch (e) {
|
|
return { error: bodyText };
|
|
}
|
|
}
|
|
|
|
async function pluginRequest(method, url, payload) {
|
|
var headers = {};
|
|
var requestOptions = {
|
|
method: method,
|
|
headers: headers,
|
|
};
|
|
|
|
if (payload !== undefined && payload !== null) {
|
|
headers['Content-Type'] = 'application/json';
|
|
requestOptions.body = JSON.stringify(payload);
|
|
}
|
|
|
|
if (method !== 'GET') {
|
|
var csrf = getCSRFToken();
|
|
if (csrf) {
|
|
headers['X-CSRF-Token'] = csrf;
|
|
}
|
|
}
|
|
|
|
var response = await fetch(url, requestOptions);
|
|
var data = await readResponseJSON(response);
|
|
if (!response.ok) {
|
|
var message = (data && (data.error || data.message)) ? (data.error || data.message) : ('Request failed: ' + response.status);
|
|
var requestError = new Error(message);
|
|
requestError.responseData = data;
|
|
requestError.responseStatus = response.status;
|
|
throw requestError;
|
|
}
|
|
return data;
|
|
}
|
|
|
|
function textOrDash(value) {
|
|
if (value === null || value === undefined || value === '') {
|
|
return '-';
|
|
}
|
|
return String(value);
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
if (value === null || value === undefined) {
|
|
return '';
|
|
}
|
|
return String(value)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function encodePath(value) {
|
|
return encodeURIComponent(value || '');
|
|
}
|
|
|
|
function normalizeEnum(value) {
|
|
if (value === undefined || value === null) {
|
|
return '';
|
|
}
|
|
return String(value).toUpperCase();
|
|
}
|
|
|
|
function parseTime(value) {
|
|
if (!value) {
|
|
return '';
|
|
}
|
|
var date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) {
|
|
return '';
|
|
}
|
|
return date.toLocaleString();
|
|
}
|
|
|
|
function normalizeSubTab(value) {
|
|
var normalized = String(value || '').toLowerCase();
|
|
if (normalized === 'configuration' || normalized === 'detection' || normalized === 'queue' || normalized === 'execution') {
|
|
return normalized;
|
|
}
|
|
return 'configuration';
|
|
}
|
|
|
|
function topTabKeyForJobType(jobType) {
|
|
var normalized = String(jobType || '').trim();
|
|
if (!normalized) {
|
|
return '';
|
|
}
|
|
return 'job:' + normalized;
|
|
}
|
|
|
|
function parseJobTypeFromTopTab(tabKey) {
|
|
var normalized = String(tabKey || '').trim();
|
|
if (normalized.indexOf('job:') !== 0) {
|
|
return '';
|
|
}
|
|
return normalized.slice(4);
|
|
}
|
|
|
|
function hasJobType(jobType) {
|
|
var normalized = String(jobType || '').trim();
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
var jobTypes = Array.isArray(state.jobTypes) ? state.jobTypes : [];
|
|
for (var i = 0; i < jobTypes.length; i++) {
|
|
if (String(jobTypes[i] || '').trim() === normalized) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function applyInitialNavigation() {
|
|
if (state.initialNavigationApplied) {
|
|
return;
|
|
}
|
|
state.initialNavigationApplied = true;
|
|
state.activeSubTab = normalizeSubTab(state.initialPage);
|
|
|
|
if (state.initialPage === 'overview') {
|
|
state.activeTopTab = 'overview';
|
|
return;
|
|
}
|
|
if (!Array.isArray(state.jobTypes) || state.jobTypes.length === 0) {
|
|
state.activeTopTab = 'overview';
|
|
return;
|
|
}
|
|
state.selectedJobType = String(state.jobTypes[0] || '').trim();
|
|
state.activeTopTab = topTabKeyForJobType(state.selectedJobType);
|
|
}
|
|
|
|
function ensureActiveNavigation() {
|
|
state.activeSubTab = normalizeSubTab(state.activeSubTab);
|
|
|
|
if (state.selectedJobType && !hasJobType(state.selectedJobType)) {
|
|
state.selectedJobType = '';
|
|
state.descriptor = null;
|
|
state.config = null;
|
|
}
|
|
|
|
var topTabJobType = parseJobTypeFromTopTab(state.activeTopTab);
|
|
if (topTabJobType && !hasJobType(topTabJobType)) {
|
|
state.activeTopTab = 'overview';
|
|
topTabJobType = '';
|
|
}
|
|
|
|
if (state.activeTopTab === 'overview') {
|
|
if (!state.selectedJobType && Array.isArray(state.jobTypes) && state.jobTypes.length > 0) {
|
|
state.selectedJobType = String(state.jobTypes[0] || '').trim();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!topTabJobType && state.selectedJobType) {
|
|
topTabJobType = state.selectedJobType;
|
|
}
|
|
if (!topTabJobType && Array.isArray(state.jobTypes) && state.jobTypes.length > 0) {
|
|
topTabJobType = String(state.jobTypes[0] || '').trim();
|
|
}
|
|
if (!topTabJobType) {
|
|
state.activeTopTab = 'overview';
|
|
return;
|
|
}
|
|
|
|
state.selectedJobType = topTabJobType;
|
|
state.activeTopTab = topTabKeyForJobType(topTabJobType);
|
|
}
|
|
|
|
function renderTopTabs() {
|
|
var root = document.getElementById('plugin-top-tabs');
|
|
if (!root) {
|
|
return;
|
|
}
|
|
|
|
var html = '' +
|
|
'<li class="nav-item">' +
|
|
'<button type="button" class="nav-link" data-plugin-top-tab="overview">' +
|
|
'<i class="fas fa-chart-line me-1"></i>Overview' +
|
|
'</button>' +
|
|
'</li>';
|
|
|
|
var jobTypes = Array.isArray(state.jobTypes) ? state.jobTypes : [];
|
|
for (var i = 0; i < jobTypes.length; i++) {
|
|
var jobType = String(jobTypes[i] || '').trim();
|
|
if (!jobType) {
|
|
continue;
|
|
}
|
|
html += '' +
|
|
'<li class="nav-item">' +
|
|
'<button type="button" class="nav-link" data-plugin-top-tab="' + escapeHtml(topTabKeyForJobType(jobType)) + '">' +
|
|
escapeHtml(jobType) +
|
|
'</button>' +
|
|
'</li>';
|
|
}
|
|
root.innerHTML = html;
|
|
}
|
|
|
|
function renderNavigationState() {
|
|
var selectedJobTypeLabel = document.getElementById('plugin-selected-job-type');
|
|
if (selectedJobTypeLabel) {
|
|
selectedJobTypeLabel.textContent = state.selectedJobType || '-';
|
|
}
|
|
|
|
var isOverview = state.activeTopTab === 'overview';
|
|
var subtabRow = document.getElementById('plugin-subtab-row');
|
|
if (subtabRow) {
|
|
subtabRow.classList.toggle('d-none', isOverview);
|
|
}
|
|
|
|
var topTabs = document.querySelectorAll('#plugin-top-tabs [data-plugin-top-tab]');
|
|
for (var i = 0; i < topTabs.length; i++) {
|
|
var topTab = topTabs[i];
|
|
var topKey = String(topTab.getAttribute('data-plugin-top-tab') || '');
|
|
topTab.classList.toggle('active', topKey === state.activeTopTab);
|
|
}
|
|
|
|
var subTabs = document.querySelectorAll('#plugin-subtabs [data-plugin-subtab]');
|
|
for (var j = 0; j < subTabs.length; j++) {
|
|
var subTab = subTabs[j];
|
|
var subKey = String(subTab.getAttribute('data-plugin-subtab') || '');
|
|
subTab.classList.toggle('active', !isOverview && subKey === state.activeSubTab);
|
|
}
|
|
|
|
function toggleSection(sectionName, visible) {
|
|
var section = document.querySelector('.plugin-section-' + sectionName);
|
|
if (!section) {
|
|
return;
|
|
}
|
|
section.classList.toggle('plugin-section-active', !!visible);
|
|
}
|
|
|
|
toggleSection('overview', isOverview);
|
|
toggleSection('configuration', !isOverview && state.activeSubTab === 'configuration');
|
|
toggleSection('detection', !isOverview && state.activeSubTab === 'detection');
|
|
toggleSection('queue', !isOverview && state.activeSubTab === 'queue');
|
|
toggleSection('execution', !isOverview && state.activeSubTab === 'execution');
|
|
}
|
|
|
|
function renderJobLink(jobID) {
|
|
var normalized = String(jobID || '').trim();
|
|
if (!normalized) {
|
|
return '<small>-</small>';
|
|
}
|
|
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>';
|
|
}
|
|
|
|
function toPrettyJson(value) {
|
|
if (value === null || value === undefined || value === '') {
|
|
return '';
|
|
}
|
|
if (typeof value === 'string') {
|
|
return value;
|
|
}
|
|
try {
|
|
return JSON.stringify(value, null, 2);
|
|
} catch (e) {
|
|
return String(value);
|
|
}
|
|
}
|
|
|
|
function clonePlainObject(value) {
|
|
if (!value || typeof value !== 'object') {
|
|
return null;
|
|
}
|
|
try {
|
|
return JSON.parse(JSON.stringify(value));
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function extractExecutionPlan(job) {
|
|
if (!job || !job.parameters) {
|
|
return null;
|
|
}
|
|
var plan = job.parameters.execution_plan;
|
|
if (!plan) {
|
|
return null;
|
|
}
|
|
if (typeof plan === 'string') {
|
|
try {
|
|
plan = JSON.parse(plan);
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
if (typeof plan !== 'object' || Array.isArray(plan)) {
|
|
return null;
|
|
}
|
|
return plan;
|
|
}
|
|
|
|
function removeExecutionPlanForDisplay(parameters) {
|
|
var copy = clonePlainObject(parameters);
|
|
if (!copy) {
|
|
return parameters;
|
|
}
|
|
if (copy.execution_plan !== undefined) {
|
|
delete copy.execution_plan;
|
|
}
|
|
if (Object.keys(copy).length === 0) {
|
|
return null;
|
|
}
|
|
return copy;
|
|
}
|
|
|
|
function parseIntOrDefault(value, fallback) {
|
|
var parsed = parseInt(value, 10);
|
|
if (Number.isNaN(parsed)) {
|
|
return fallback;
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function normalizeShardIDs(value) {
|
|
if (!Array.isArray(value)) {
|
|
return [];
|
|
}
|
|
var out = [];
|
|
for (var i = 0; i < value.length; i++) {
|
|
var parsed = parseIntOrDefault(value[i], -1);
|
|
if (parsed >= 0) {
|
|
out.push(parsed);
|
|
}
|
|
}
|
|
out.sort(function(a, b) { return a - b; });
|
|
return out;
|
|
}
|
|
|
|
function classifyShardLabel(shardID, dataShardCount) {
|
|
if (shardID < dataShardCount) {
|
|
return {
|
|
kind: 'data',
|
|
label: String(shardID),
|
|
className: 'bg-primary',
|
|
title: 'Data shard ' + shardID,
|
|
};
|
|
}
|
|
var parityIndex = shardID - dataShardCount;
|
|
return {
|
|
kind: 'parity',
|
|
label: String(shardID),
|
|
className: 'bg-warning text-dark',
|
|
title: 'Parity shard ' + shardID + ' (index ' + parityIndex + ')',
|
|
};
|
|
}
|
|
|
|
function renderShardBadges(shardIDs, dataShardCount) {
|
|
if (!Array.isArray(shardIDs) || shardIDs.length === 0) {
|
|
return '<span class="text-muted">-</span>';
|
|
}
|
|
var html = '';
|
|
for (var i = 0; i < shardIDs.length; i++) {
|
|
var shard = classifyShardLabel(shardIDs[i], dataShardCount);
|
|
html += '<span class="badge me-1 ' + shard.className + '" title="' + escapeHtml(shard.title) + '">' + escapeHtml(shard.label) + '</span>';
|
|
}
|
|
return html;
|
|
}
|
|
|
|
function renderExecutionPlan(plan) {
|
|
if (!plan || typeof plan !== 'object') {
|
|
return '';
|
|
}
|
|
|
|
var jobType = String(plan.job_type || '').trim().toLowerCase();
|
|
if (jobType !== 'erasure_coding') {
|
|
var fallbackText = toPrettyJson(plan);
|
|
if (!fallbackText) {
|
|
return '';
|
|
}
|
|
return '<div class="mb-3"><h6>Execution Plan</h6><pre class="plugin-job-detail-json">' + escapeHtml(fallbackText) + '</pre></div>';
|
|
}
|
|
|
|
var dataShards = parseIntOrDefault(plan.data_shards, 10);
|
|
var parityShards = parseIntOrDefault(plan.parity_shards, 4);
|
|
var totalShards = parseIntOrDefault(plan.total_shards, dataShards + parityShards);
|
|
var sources = Array.isArray(plan.sources) ? plan.sources : [];
|
|
var targets = Array.isArray(plan.targets) ? plan.targets : [];
|
|
var assignments = Array.isArray(plan.shard_assignments) ? plan.shard_assignments : [];
|
|
|
|
var html = '';
|
|
html += '<div class="mb-3"><h6>Execution Plan</h6>';
|
|
html += '<div class="row g-2 mb-2">' +
|
|
'<div class="col-md-3"><small><strong>Volume:</strong> ' + escapeHtml(textOrDash(plan.volume_id)) + '</small></div>' +
|
|
'<div class="col-md-3"><small><strong>Collection:</strong> ' + escapeHtml(textOrDash(plan.collection)) + '</small></div>' +
|
|
'<div class="col-md-3"><small><strong>Shards:</strong> ' + escapeHtml(String(dataShards)) + '+' + escapeHtml(String(parityShards)) + '</small></div>' +
|
|
'<div class="col-md-3"><small><strong>Total:</strong> ' + escapeHtml(String(totalShards)) + '</small></div>' +
|
|
'</div>';
|
|
|
|
html += '<div class="small text-muted mb-2">' +
|
|
'<span class="badge bg-primary me-1">Data</span>' +
|
|
'<span class="badge bg-warning text-dark me-2">Parity</span>' +
|
|
'Source and target placement per shard.' +
|
|
'</div>';
|
|
|
|
function renderEndpointsTable(title, endpoints) {
|
|
if (!endpoints.length) {
|
|
return '<div class="mb-2"><strong>' + escapeHtml(title) + ':</strong> <span class="text-muted">none</span></div>';
|
|
}
|
|
var table = '<div class="table-responsive mb-2"><table class="table table-sm table-striped mb-0">' +
|
|
'<thead><tr><th>#</th><th>Node</th><th>DC</th><th>Rack</th><th>Volume</th><th>Shards</th></tr></thead><tbody>';
|
|
for (var i = 0; i < endpoints.length; i++) {
|
|
var endpoint = endpoints[i] || {};
|
|
var endpointShards = normalizeShardIDs(endpoint.shard_ids);
|
|
table += '<tr>' +
|
|
'<td>' + escapeHtml(String(i + 1)) + '</td>' +
|
|
'<td><code>' + escapeHtml(textOrDash(endpoint.node)) + '</code></td>' +
|
|
'<td>' + escapeHtml(textOrDash(endpoint.data_center)) + '</td>' +
|
|
'<td>' + escapeHtml(textOrDash(endpoint.rack)) + '</td>' +
|
|
'<td>' + escapeHtml(textOrDash(endpoint.volume_id)) + '</td>' +
|
|
'<td>' + renderShardBadges(endpointShards, dataShards) + '</td>' +
|
|
'</tr>';
|
|
}
|
|
table += '</tbody></table></div>';
|
|
return '<div class="mb-2"><strong>' + escapeHtml(title) + '</strong></div>' + table;
|
|
}
|
|
|
|
html += renderEndpointsTable('Sources', sources);
|
|
html += renderEndpointsTable('Targets', targets);
|
|
|
|
if (assignments.length) {
|
|
html += '<div class="mb-2"><strong>Shard Assignments</strong></div>';
|
|
html += '<div class="table-responsive"><table class="table table-sm table-striped mb-0">' +
|
|
'<thead><tr><th>Shard</th><th>Kind</th><th>Target</th><th>DC</th><th>Rack</th></tr></thead><tbody>';
|
|
for (var j = 0; j < assignments.length; j++) {
|
|
var assignment = assignments[j] || {};
|
|
var shardID = parseIntOrDefault(assignment.shard_id, -1);
|
|
if (shardID < 0) {
|
|
continue;
|
|
}
|
|
var shard = classifyShardLabel(shardID, dataShards);
|
|
html += '<tr>' +
|
|
'<td><span class="badge ' + shard.className + '">' + escapeHtml(shard.label) + '</span></td>' +
|
|
'<td>' + escapeHtml(textOrDash(assignment.kind || shard.kind)) + '</td>' +
|
|
'<td><code>' + escapeHtml(textOrDash(assignment.target_node)) + '</code></td>' +
|
|
'<td>' + escapeHtml(textOrDash(assignment.target_data_center)) + '</td>' +
|
|
'<td>' + escapeHtml(textOrDash(assignment.target_rack)) + '</td>' +
|
|
'</tr>';
|
|
}
|
|
html += '</tbody></table></div>';
|
|
}
|
|
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
async function openJobDetail(jobID) {
|
|
var normalizedJobID = String(jobID || '').trim();
|
|
if (!normalizedJobID) {
|
|
return;
|
|
}
|
|
|
|
var modalElement = document.getElementById('plugin-job-detail-modal');
|
|
var contentRoot = document.getElementById('plugin-job-detail-content');
|
|
if (!modalElement || !contentRoot) {
|
|
return;
|
|
}
|
|
|
|
var modal;
|
|
if (window.bootstrap && window.bootstrap.Modal) {
|
|
modal = window.bootstrap.Modal.getOrCreateInstance(modalElement);
|
|
}
|
|
if (modal) {
|
|
modal.show();
|
|
}
|
|
|
|
contentRoot.innerHTML = '<div class="text-muted">Loading job detail...</div>';
|
|
try {
|
|
var detail = await pluginRequest('GET', '/api/plugin/jobs/' + encodePath(normalizedJobID) + '/detail?activity_limit=500&related_limit=20');
|
|
var job = (detail && detail.job) ? detail.job : {};
|
|
var runRecord = detail && detail.run_record ? detail.run_record : null;
|
|
var activities = (detail && Array.isArray(detail.activities)) ? detail.activities : [];
|
|
var relatedJobs = (detail && Array.isArray(detail.related_jobs)) ? detail.related_jobs : [];
|
|
var html = '';
|
|
|
|
html += '<div class="row g-3 mb-3">' +
|
|
'<div class="col-lg-8">' +
|
|
'<div><strong>Job ID:</strong> ' + escapeHtml(textOrDash(job.job_id || normalizedJobID)) + '</div>' +
|
|
'<div><strong>Type:</strong> ' + escapeHtml(textOrDash(job.job_type)) + '</div>' +
|
|
'<div><strong>State:</strong> ' + escapeHtml(textOrDash(job.state)) + '</div>' +
|
|
'<div><strong>Worker:</strong> ' + escapeHtml(textOrDash(job.worker_id)) + '</div>' +
|
|
'<div><strong>Updated:</strong> ' + escapeHtml(parseTime(job.updated_at) || '-') + '</div>' +
|
|
'</div>' +
|
|
'<div class="col-lg-4">' +
|
|
'<div><strong>Attempt:</strong> ' + escapeHtml(textOrDash(job.attempt)) + '</div>' +
|
|
'<div><strong>Progress:</strong> ' + escapeHtml(String(Math.round(Number(job.progress || 0)))) + '%</div>' +
|
|
'<div><strong>Created:</strong> ' + escapeHtml(parseTime(job.created_at) || '-') + '</div>' +
|
|
'<div><strong>Completed:</strong> ' + escapeHtml(parseTime(job.completed_at) || '-') + '</div>' +
|
|
'</div>' +
|
|
'</div>';
|
|
|
|
if (job.summary) {
|
|
html += '<div class="mb-2"><strong>Summary:</strong> ' + escapeHtml(String(job.summary)) + '</div>';
|
|
}
|
|
if (job.detail) {
|
|
html += '<div class="mb-2"><strong>Detail:</strong> ' + escapeHtml(String(job.detail)) + '</div>';
|
|
}
|
|
if (job.message) {
|
|
html += '<div class="mb-2"><strong>Message:</strong> ' + escapeHtml(String(job.message)) + '</div>';
|
|
}
|
|
if (job.error_message) {
|
|
html += '<div class="mb-2 text-danger"><strong>Error:</strong> ' + escapeHtml(String(job.error_message)) + '</div>';
|
|
}
|
|
if (job.result_summary) {
|
|
html += '<div class="mb-2"><strong>Result:</strong> ' + escapeHtml(String(job.result_summary)) + '</div>';
|
|
}
|
|
|
|
var executionPlan = extractExecutionPlan(job);
|
|
if (executionPlan) {
|
|
html += renderExecutionPlan(executionPlan);
|
|
}
|
|
|
|
var parametersText = toPrettyJson(removeExecutionPlanForDisplay(job.parameters));
|
|
if (parametersText) {
|
|
html += '<div class="mb-3"><h6>Parameters</h6><pre class="plugin-job-detail-json">' + escapeHtml(parametersText) + '</pre></div>';
|
|
}
|
|
var labelsText = toPrettyJson(job.labels);
|
|
if (labelsText) {
|
|
html += '<div class="mb-3"><h6>Labels</h6><pre class="plugin-job-detail-json">' + escapeHtml(labelsText) + '</pre></div>';
|
|
}
|
|
var outputsText = toPrettyJson(job.result_output_values);
|
|
if (outputsText) {
|
|
html += '<div class="mb-3"><h6>Result Output Values</h6><pre class="plugin-job-detail-json">' + escapeHtml(outputsText) + '</pre></div>';
|
|
}
|
|
|
|
if (runRecord) {
|
|
html += '<div class="mb-3"><h6>Run Record</h6>' +
|
|
'<div><strong>Outcome:</strong> ' + escapeHtml(textOrDash(runRecord.outcome)) + '</div>' +
|
|
'<div><strong>Duration:</strong> ' + escapeHtml(formatDurationMs(runRecord.duration_ms)) + '</div>' +
|
|
'<div><strong>Completed:</strong> ' + escapeHtml(parseTime(runRecord.completed_at) || '-') + '</div>' +
|
|
'<div><strong>Message:</strong> ' + escapeHtml(textOrDash(runRecord.message)) + '</div>' +
|
|
'</div>';
|
|
}
|
|
|
|
html += '<h6 class="mt-3">Activity Timeline</h6>';
|
|
if (!activities.length) {
|
|
html += '<div class="text-muted mb-3">No activity records.</div>';
|
|
} else {
|
|
html += '<div class="table-responsive mb-3"><table class="table table-sm table-striped">' +
|
|
'<thead><tr><th>Time</th><th>Stage</th><th>Source</th><th>Worker</th><th>Message</th></tr></thead><tbody>';
|
|
for (var i = 0; i < activities.length; i++) {
|
|
var activity = activities[i] || {};
|
|
html += '<tr>' +
|
|
'<td><small>' + escapeHtml(parseTime(activity.occurred_at) || '-') + '</small></td>' +
|
|
'<td>' + escapeHtml(textOrDash(activity.stage)) + '</td>' +
|
|
'<td>' + escapeHtml(textOrDash(activity.source)) + '</td>' +
|
|
'<td>' + escapeHtml(textOrDash(activity.worker_id)) + '</td>' +
|
|
'<td><small>' + escapeHtml(textOrDash(activity.message)) + '</small></td>' +
|
|
'</tr>';
|
|
}
|
|
html += '</tbody></table></div>';
|
|
}
|
|
|
|
if (relatedJobs.length) {
|
|
html += '<h6>Related Jobs</h6><div class="table-responsive"><table class="table table-sm table-hover">' +
|
|
'<thead><tr><th>Job ID</th><th>State</th><th>Worker</th><th>Updated</th></tr></thead><tbody>';
|
|
for (var j = 0; j < relatedJobs.length; j++) {
|
|
var related = relatedJobs[j] || {};
|
|
html += '<tr>' +
|
|
'<td><small>' + escapeHtml(textOrDash(related.job_id)) + '</small></td>' +
|
|
'<td>' + escapeHtml(textOrDash(related.state)) + '</td>' +
|
|
'<td>' + escapeHtml(textOrDash(related.worker_id)) + '</td>' +
|
|
'<td><small>' + escapeHtml(parseTime(related.updated_at) || '-') + '</small></td>' +
|
|
'</tr>';
|
|
}
|
|
html += '</tbody></table></div>';
|
|
}
|
|
|
|
contentRoot.innerHTML = html;
|
|
} catch (e) {
|
|
contentRoot.innerHTML = '<div class="alert alert-danger mb-0">Failed to load job detail: ' + escapeHtml(e.message) + '</div>';
|
|
}
|
|
}
|
|
|
|
function formatDurationMs(value) {
|
|
var ms = Number(value || 0);
|
|
if (!Number.isFinite(ms) || ms <= 0) {
|
|
return '-';
|
|
}
|
|
if (ms < 1000) {
|
|
return ms + 'ms';
|
|
}
|
|
var sec = Math.floor(ms / 1000);
|
|
var remainMs = ms % 1000;
|
|
if (sec < 60) {
|
|
return sec + '.' + String(Math.floor(remainMs / 100)) + 's';
|
|
}
|
|
var min = Math.floor(sec / 60);
|
|
var remainSec = sec % 60;
|
|
return min + 'm ' + remainSec + 's';
|
|
}
|
|
|
|
function getWorkerField(worker, name) {
|
|
if (!worker) {
|
|
return null;
|
|
}
|
|
if (worker[name] !== undefined) {
|
|
return worker[name];
|
|
}
|
|
var camel = name.charAt(0).toUpperCase() + name.slice(1);
|
|
if (worker[camel] !== undefined) {
|
|
return worker[camel];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function renderStatus() {
|
|
var allJobs = Array.isArray(state.allJobs) ? state.allJobs : [];
|
|
var allActivities = Array.isArray(state.allActivities) ? state.allActivities : [];
|
|
|
|
var activeCount = allJobs.filter(function(job) {
|
|
var st = String(job.state || '').toLowerCase();
|
|
return st === 'job_state_pending' || st === 'job_state_assigned' || st === 'job_state_running' || st === 'pending' || st === 'assigned' || st === 'running' || st === 'in_progress';
|
|
}).length;
|
|
|
|
document.getElementById('plugin-status-workers').textContent = String(state.workers.length);
|
|
document.getElementById('plugin-status-active-jobs').textContent = String(activeCount);
|
|
document.getElementById('plugin-status-activities').textContent = String(allActivities.length);
|
|
}
|
|
|
|
function renderJobTypeSummary() {
|
|
var tbody = document.getElementById('plugin-jobtype-summary-body');
|
|
if (!tbody) {
|
|
return;
|
|
}
|
|
|
|
var activeByType = {};
|
|
var activityByType = {};
|
|
|
|
var allJobs = Array.isArray(state.allJobs) ? state.allJobs : [];
|
|
var allActivities = Array.isArray(state.allActivities) ? state.allActivities : [];
|
|
|
|
for (var i = 0; i < allJobs.length; i++) {
|
|
var job = allJobs[i] || {};
|
|
var jobType = String(job.job_type || '').trim();
|
|
if (!jobType) {
|
|
continue;
|
|
}
|
|
var st = String(job.state || '').toLowerCase();
|
|
var isActive = st === 'job_state_pending' || st === 'job_state_assigned' || st === 'job_state_running' || st === 'pending' || st === 'assigned' || st === 'running' || st === 'in_progress';
|
|
if (!isActive) {
|
|
continue;
|
|
}
|
|
if (!activeByType[jobType]) {
|
|
activeByType[jobType] = 0;
|
|
}
|
|
activeByType[jobType]++;
|
|
}
|
|
|
|
for (var j = 0; j < allActivities.length; j++) {
|
|
var activity = allActivities[j] || {};
|
|
var activityJobType = String(activity.job_type || '').trim();
|
|
if (!activityJobType) {
|
|
continue;
|
|
}
|
|
if (!activityByType[activityJobType]) {
|
|
activityByType[activityJobType] = 0;
|
|
}
|
|
activityByType[activityJobType]++;
|
|
}
|
|
|
|
var known = Array.isArray(state.jobTypes) ? state.jobTypes.slice() : [];
|
|
var seen = {};
|
|
var jobTypes = [];
|
|
|
|
for (var k = 0; k < known.length; k++) {
|
|
var knownType = String(known[k] || '').trim();
|
|
if (!knownType || seen[knownType]) {
|
|
continue;
|
|
}
|
|
seen[knownType] = true;
|
|
jobTypes.push(knownType);
|
|
}
|
|
|
|
Object.keys(activeByType).forEach(function(jobType) {
|
|
if (!seen[jobType]) {
|
|
seen[jobType] = true;
|
|
jobTypes.push(jobType);
|
|
}
|
|
});
|
|
Object.keys(activityByType).forEach(function(jobType) {
|
|
if (!seen[jobType]) {
|
|
seen[jobType] = true;
|
|
jobTypes.push(jobType);
|
|
}
|
|
});
|
|
|
|
jobTypes.sort();
|
|
|
|
if (jobTypes.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan=\"3\" class=\"text-muted text-center py-3\">No job types discovered</td></tr>';
|
|
return;
|
|
}
|
|
|
|
var rows = '';
|
|
for (var m = 0; m < jobTypes.length; m++) {
|
|
var rowType = jobTypes[m];
|
|
rows += '<tr>' +
|
|
'<td>' + escapeHtml(rowType) + '</td>' +
|
|
'<td><span class=\"badge bg-warning text-dark\">' + escapeHtml(String(activeByType[rowType] || 0)) + '</span></td>' +
|
|
'<td><span class=\"badge bg-info\">' + escapeHtml(String(activityByType[rowType] || 0)) + '</span></td>' +
|
|
'</tr>';
|
|
}
|
|
tbody.innerHTML = rows;
|
|
}
|
|
|
|
function computeEffectiveExecutionCapacity(globalExec, perWorkerExec, executorWorkers) {
|
|
var capacities = [];
|
|
if (globalExec > 0) {
|
|
capacities.push(globalExec);
|
|
}
|
|
var workerCapacity = perWorkerExec > 0 && executorWorkers > 0 ? (perWorkerExec * executorWorkers) : 0;
|
|
if (workerCapacity > 0) {
|
|
capacities.push(workerCapacity);
|
|
}
|
|
if (!capacities.length) {
|
|
return 0;
|
|
}
|
|
var effective = capacities[0];
|
|
for (var i = 1; i < capacities.length; i++) {
|
|
if (capacities[i] < effective) {
|
|
effective = capacities[i];
|
|
}
|
|
}
|
|
return effective;
|
|
}
|
|
|
|
function renderSchedulerStates() {
|
|
var tbody = document.getElementById('plugin-scheduler-table-body');
|
|
if (!tbody) {
|
|
return;
|
|
}
|
|
|
|
var states = Array.isArray(state.schedulerStates) ? state.schedulerStates : [];
|
|
if (!states.length) {
|
|
tbody.innerHTML = '<tr><td colspan="10" class="text-muted text-center py-3">No scheduler state available</td></tr>';
|
|
return;
|
|
}
|
|
|
|
var rows = '';
|
|
for (var i = 0; i < states.length; i++) {
|
|
var item = states[i] || {};
|
|
var enabled = !!item.enabled;
|
|
var inFlight = !!item.detection_in_flight;
|
|
var detector = item.detector_available ? textOrDash(item.detector_worker_id) : 'No detector';
|
|
var intervalSeconds = Number(item.detection_interval_seconds || 0);
|
|
var intervalText = intervalSeconds > 0 ? (String(intervalSeconds) + 's') : '-';
|
|
var globalExec = Number(item.global_execution_concurrency || 0);
|
|
var perWorkerExec = Number(item.per_worker_execution_concurrency || 0);
|
|
var executorWorkers = Number(item.executor_worker_count || 0);
|
|
var effectiveExec = computeEffectiveExecutionCapacity(globalExec, perWorkerExec, executorWorkers);
|
|
var globalExecText = enabled ? String(globalExec) : '-';
|
|
var perWorkerExecText = enabled ? String(perWorkerExec) : '-';
|
|
var executorWorkersText = enabled ? String(executorWorkers) : '-';
|
|
var effectiveExecText = enabled ? String(effectiveExec) : '-';
|
|
|
|
var enabledBadge = enabled ? '<span class="badge bg-success">Enabled</span>' : '<span class="badge bg-secondary">Disabled</span>';
|
|
var inFlightBadge = inFlight ? '<span class="badge bg-warning text-dark">Yes</span>' : '<span class="badge bg-light text-dark">No</span>';
|
|
var policyErrorHtml = '';
|
|
if (item.policy_error) {
|
|
policyErrorHtml = '<div><small class="text-danger">' + escapeHtml(String(item.policy_error)) + '</small></div>';
|
|
}
|
|
|
|
rows += '<tr>' +
|
|
'<td>' + escapeHtml(textOrDash(item.job_type)) + policyErrorHtml + '</td>' +
|
|
'<td>' + enabledBadge + '</td>' +
|
|
'<td><small>' + escapeHtml(detector) + '</small></td>' +
|
|
'<td>' + inFlightBadge + '</td>' +
|
|
'<td><small>' + escapeHtml(parseTime(item.next_detection_at) || '-') + '</small></td>' +
|
|
'<td><small>' + escapeHtml(intervalText) + '</small></td>' +
|
|
'<td><small>' + escapeHtml(globalExecText) + '</small></td>' +
|
|
'<td><small>' + escapeHtml(perWorkerExecText) + '</small></td>' +
|
|
'<td><small>' + escapeHtml(executorWorkersText) + '</small></td>' +
|
|
'<td><small>' + escapeHtml(effectiveExecText) + '</small></td>' +
|
|
'</tr>';
|
|
}
|
|
|
|
tbody.innerHTML = rows;
|
|
}
|
|
|
|
function renderWorkers() {
|
|
var tbody = document.getElementById('plugin-workers-table-body');
|
|
if (!state.workers.length) {
|
|
tbody.innerHTML = '<tr><td colspan="4" class="text-muted text-center py-3">No connected workers</td></tr>';
|
|
return;
|
|
}
|
|
|
|
var rows = '';
|
|
for (var i = 0; i < state.workers.length; i++) {
|
|
var worker = state.workers[i];
|
|
var workerID = textOrDash(getWorkerField(worker, 'workerID'));
|
|
var address = textOrDash(getWorkerField(worker, 'address'));
|
|
var caps = getWorkerField(worker, 'capabilities') || {};
|
|
var capBadges = [];
|
|
|
|
Object.keys(caps).sort().forEach(function(jobType) {
|
|
var cap = caps[jobType] || {};
|
|
var labels = [];
|
|
if (cap.can_detect) {
|
|
labels.push('D');
|
|
}
|
|
if (cap.can_execute) {
|
|
labels.push('E');
|
|
}
|
|
var text = jobType;
|
|
if (labels.length) {
|
|
text += ' (' + labels.join('/') + ')';
|
|
}
|
|
capBadges.push('<span class="badge bg-secondary plugin-capability-badge">' + escapeHtml(text) + '</span>');
|
|
});
|
|
|
|
if (!capBadges.length) {
|
|
capBadges.push('<span class="text-muted">-</span>');
|
|
}
|
|
|
|
var heartbeat = getWorkerField(worker, 'heartbeat') || {};
|
|
var detUsed = Number(heartbeat.detection_slots_used || heartbeat.DetectionSlotsUsed || 0);
|
|
var detTotal = Number(heartbeat.detection_slots_total || heartbeat.DetectionSlotsTotal || 0);
|
|
var execUsed = Number(heartbeat.execution_slots_used || heartbeat.ExecutionSlotsUsed || 0);
|
|
var execTotal = Number(heartbeat.execution_slots_total || heartbeat.ExecutionSlotsTotal || 0);
|
|
var runningWork = heartbeat.running_work || heartbeat.RunningWork || [];
|
|
var queuedByType = heartbeat.queued_jobs_by_type || heartbeat.QueuedJobsByType || {};
|
|
var queuedTotal = 0;
|
|
Object.keys(queuedByType || {}).forEach(function(jobType) {
|
|
var queuedCount = Number(queuedByType[jobType] || 0);
|
|
if (Number.isFinite(queuedCount) && queuedCount > 0) {
|
|
queuedTotal += queuedCount;
|
|
}
|
|
});
|
|
var loadLines = [];
|
|
loadLines.push('Detect: ' + String(detUsed) + '/' + String(detTotal));
|
|
loadLines.push('Execute: ' + String(execUsed) + '/' + String(execTotal));
|
|
loadLines.push('Running: ' + String(Array.isArray(runningWork) ? runningWork.length : 0));
|
|
loadLines.push('Queued: ' + String(queuedTotal));
|
|
|
|
rows += '<tr>' +
|
|
'<td><div class="fw-semibold">' + escapeHtml(workerID) + '</div></td>' +
|
|
'<td><small>' + escapeHtml(address) + '</small></td>' +
|
|
'<td>' + capBadges.join('') + '</td>' +
|
|
'<td><small>' + escapeHtml(loadLines.join(' | ')) + '</small></td>' +
|
|
'</tr>';
|
|
}
|
|
|
|
tbody.innerHTML = rows;
|
|
}
|
|
|
|
function normalizeJobStateForFilter(value) {
|
|
var normalized = String(value || '').toLowerCase();
|
|
if (normalized.indexOf('job_state_') === 0) {
|
|
normalized = normalized.slice('job_state_'.length);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function matchesExecutionStateFilter(jobState, selectedFilter) {
|
|
var target = normalizeJobStateForFilter(selectedFilter);
|
|
if (!target) {
|
|
return true;
|
|
}
|
|
var actual = normalizeJobStateForFilter(jobState);
|
|
if (actual === target) {
|
|
return true;
|
|
}
|
|
if (target === 'running' && actual === 'in_progress') {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function isDetectionActivitySource(source) {
|
|
var normalized = String(source || '').toLowerCase();
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
if (normalized === 'worker_detection') {
|
|
return true;
|
|
}
|
|
return normalized.indexOf('detector') >= 0;
|
|
}
|
|
|
|
function renderExecutionJobs() {
|
|
var tbody = document.getElementById('plugin-execution-jobs-table-body');
|
|
if (!tbody) {
|
|
return;
|
|
}
|
|
|
|
var filteredJobs = [];
|
|
var allJobs = Array.isArray(state.allJobs) ? state.allJobs : [];
|
|
for (var i = 0; i < allJobs.length; i++) {
|
|
var job = allJobs[i] || {};
|
|
var jobType = String(job.job_type || '').trim();
|
|
if (state.selectedJobType && jobType !== state.selectedJobType) {
|
|
continue;
|
|
}
|
|
if (!matchesExecutionStateFilter(job.state, state.monitorExecutionJobState)) {
|
|
continue;
|
|
}
|
|
filteredJobs.push(job);
|
|
}
|
|
|
|
if (!filteredJobs.length) {
|
|
tbody.innerHTML = '<tr><td colspan="7" class="text-muted text-center py-3">No execution jobs tracked yet</td></tr>';
|
|
return;
|
|
}
|
|
|
|
var rows = '';
|
|
for (var j = 0; j < filteredJobs.length; j++) {
|
|
var executionJob = filteredJobs[j] || {};
|
|
var progress = Number(executionJob.progress || 0);
|
|
if (!Number.isFinite(progress) || progress < 0) {
|
|
progress = 0;
|
|
}
|
|
if (progress > 100) {
|
|
progress = 100;
|
|
}
|
|
|
|
var barClass = 'bg-info';
|
|
var stateText = String(executionJob.state || 'unknown').toLowerCase();
|
|
if (stateText.indexOf('failed') >= 0) {
|
|
barClass = 'bg-danger';
|
|
} else if (stateText.indexOf('succeed') >= 0 || stateText.indexOf('complete') >= 0) {
|
|
barClass = 'bg-success';
|
|
} else if (stateText.indexOf('run') >= 0 || stateText.indexOf('progress') >= 0) {
|
|
barClass = 'bg-warning';
|
|
}
|
|
|
|
rows += '<tr>' +
|
|
'<td>' + renderJobLink(executionJob.job_id) + '</td>' +
|
|
'<td>' + escapeHtml(textOrDash(executionJob.job_type)) + '</td>' +
|
|
'<td><span class="badge bg-light text-dark">' + escapeHtml(textOrDash(executionJob.state)) + '</span></td>' +
|
|
'<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>' +
|
|
'<td><small>' + escapeHtml(textOrDash(executionJob.worker_id)) + '</small></td>' +
|
|
'<td><small>' + escapeHtml(parseTime(executionJob.updated_at) || '-') + '</small></td>' +
|
|
'<td><small>' + escapeHtml(textOrDash(executionJob.message)) + '</small></td>' +
|
|
'</tr>';
|
|
}
|
|
|
|
tbody.innerHTML = rows;
|
|
}
|
|
|
|
function renderQueueJobs() {
|
|
var tbody = document.getElementById('plugin-queue-jobs-table-body');
|
|
if (!tbody) {
|
|
return;
|
|
}
|
|
|
|
var filteredJobs = [];
|
|
var allJobs = Array.isArray(state.allJobs) ? state.allJobs : [];
|
|
for (var i = 0; i < allJobs.length; i++) {
|
|
var queueJob = allJobs[i] || {};
|
|
var queueJobType = String(queueJob.job_type || '').trim();
|
|
if (state.selectedJobType && queueJobType !== state.selectedJobType) {
|
|
continue;
|
|
}
|
|
var normalizedState = normalizeJobStateForFilter(queueJob.state);
|
|
if (normalizedState !== 'pending' && normalizedState !== 'assigned' && normalizedState !== 'running' && normalizedState !== 'in_progress') {
|
|
continue;
|
|
}
|
|
filteredJobs.push(queueJob);
|
|
}
|
|
|
|
if (!filteredJobs.length) {
|
|
tbody.innerHTML = '<tr><td colspan="7" class="text-muted text-center py-3">No queued/active jobs</td></tr>';
|
|
return;
|
|
}
|
|
|
|
var rows = '';
|
|
for (var j = 0; j < filteredJobs.length; j++) {
|
|
var queueItem = filteredJobs[j] || {};
|
|
var progress = Number(queueItem.progress || 0);
|
|
if (!Number.isFinite(progress) || progress < 0) {
|
|
progress = 0;
|
|
}
|
|
if (progress > 100) {
|
|
progress = 100;
|
|
}
|
|
|
|
var barClass = 'bg-warning';
|
|
var queueStateText = String(queueItem.state || 'unknown').toLowerCase();
|
|
if (queueStateText.indexOf('run') >= 0 || queueStateText.indexOf('progress') >= 0) {
|
|
barClass = 'bg-info';
|
|
}
|
|
|
|
rows += '<tr>' +
|
|
'<td>' + renderJobLink(queueItem.job_id) + '</td>' +
|
|
'<td>' + escapeHtml(textOrDash(queueItem.job_type)) + '</td>' +
|
|
'<td><span class="badge bg-light text-dark">' + escapeHtml(textOrDash(queueItem.state)) + '</span></td>' +
|
|
'<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>' +
|
|
'<td><small>' + escapeHtml(textOrDash(queueItem.worker_id)) + '</small></td>' +
|
|
'<td><small>' + escapeHtml(parseTime(queueItem.updated_at) || '-') + '</small></td>' +
|
|
'<td><small>' + escapeHtml(textOrDash(queueItem.message)) + '</small></td>' +
|
|
'</tr>';
|
|
}
|
|
|
|
tbody.innerHTML = rows;
|
|
}
|
|
|
|
function renderDetectionJobs() {
|
|
var tbody = document.getElementById('plugin-detection-table-body');
|
|
if (!tbody) {
|
|
return;
|
|
}
|
|
|
|
var filteredActivities = [];
|
|
var allActivities = Array.isArray(state.allActivities) ? state.allActivities : [];
|
|
for (var i = 0; i < allActivities.length; i++) {
|
|
var activity = allActivities[i] || {};
|
|
var jobType = String(activity.job_type || '').trim();
|
|
if (state.selectedJobType && jobType !== state.selectedJobType) {
|
|
continue;
|
|
}
|
|
if (!isDetectionActivitySource(activity.source)) {
|
|
continue;
|
|
}
|
|
filteredActivities.push(activity);
|
|
}
|
|
|
|
if (!filteredActivities.length) {
|
|
tbody.innerHTML = '<tr><td colspan="7" class="text-muted text-center py-3">No detection jobs tracked yet</td></tr>';
|
|
return;
|
|
}
|
|
|
|
var rows = '';
|
|
for (var j = 0; j < filteredActivities.length; j++) {
|
|
var detectionActivity = filteredActivities[j] || {};
|
|
rows += '<tr>' +
|
|
'<td><small>' + escapeHtml(parseTime(detectionActivity.occurred_at) || '-') + '</small></td>' +
|
|
'<td>' + escapeHtml(textOrDash(detectionActivity.job_type)) + '</td>' +
|
|
'<td><small>' + escapeHtml(textOrDash(detectionActivity.request_id)) + '</small></td>' +
|
|
'<td><small>' + escapeHtml(textOrDash(detectionActivity.worker_id)) + '</small></td>' +
|
|
'<td>' + escapeHtml(textOrDash(detectionActivity.stage)) + '</td>' +
|
|
'<td><span class="badge bg-light text-dark">' + escapeHtml(textOrDash(detectionActivity.source)) + '</span></td>' +
|
|
'<td><small>' + escapeHtml(textOrDash(detectionActivity.message)) + '</small></td>' +
|
|
'</tr>';
|
|
}
|
|
|
|
tbody.innerHTML = rows;
|
|
}
|
|
|
|
function renderExecutionActivities() {
|
|
var tbody = document.getElementById('plugin-execution-activities-table-body');
|
|
if (!tbody) {
|
|
return;
|
|
}
|
|
|
|
var filteredActivities = [];
|
|
var allActivities = Array.isArray(state.allActivities) ? state.allActivities : [];
|
|
for (var i = 0; i < allActivities.length; i++) {
|
|
var activity = allActivities[i] || {};
|
|
if (isDetectionActivitySource(activity.source)) {
|
|
continue;
|
|
}
|
|
var jobType = String(activity.job_type || '').trim();
|
|
if (state.selectedJobType && jobType !== state.selectedJobType) {
|
|
continue;
|
|
}
|
|
filteredActivities.push(activity);
|
|
}
|
|
|
|
if (!filteredActivities.length) {
|
|
tbody.innerHTML = '<tr><td colspan="6" class="text-muted text-center py-3">No execution activities yet</td></tr>';
|
|
return;
|
|
}
|
|
|
|
var rows = '';
|
|
for (var j = 0; j < filteredActivities.length; j++) {
|
|
var executionActivity = filteredActivities[j] || {};
|
|
rows += '<tr>' +
|
|
'<td><small>' + escapeHtml(parseTime(executionActivity.occurred_at) || '-') + '</small></td>' +
|
|
'<td>' + escapeHtml(textOrDash(executionActivity.job_type)) + '</td>' +
|
|
'<td>' + renderJobLink(executionActivity.job_id) + '</td>' +
|
|
'<td><span class="badge bg-light text-dark">' + escapeHtml(textOrDash(executionActivity.source)) + '</span></td>' +
|
|
'<td>' + escapeHtml(textOrDash(executionActivity.stage)) + '</td>' +
|
|
'<td><small>' + escapeHtml(textOrDash(executionActivity.message)) + '</small></td>' +
|
|
'</tr>';
|
|
}
|
|
|
|
tbody.innerHTML = rows;
|
|
}
|
|
|
|
function getSelectValueArray(selectEl) {
|
|
if (!selectEl) {
|
|
return [];
|
|
}
|
|
var values = [];
|
|
for (var i = 0; i < selectEl.options.length; i++) {
|
|
if (selectEl.options[i].selected) {
|
|
values.push(selectEl.options[i].value);
|
|
}
|
|
}
|
|
return values;
|
|
}
|
|
|
|
function configValueToNative(configValue) {
|
|
if (!configValue || typeof configValue !== 'object') {
|
|
return null;
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(configValue, 'bool_value')) {
|
|
return !!configValue.bool_value;
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(configValue, 'int64_value')) {
|
|
return configValue.int64_value;
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(configValue, 'double_value')) {
|
|
return configValue.double_value;
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(configValue, 'string_value')) {
|
|
return configValue.string_value;
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(configValue, 'bytes_value')) {
|
|
return configValue.bytes_value;
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(configValue, 'duration_value')) {
|
|
return configValue.duration_value;
|
|
}
|
|
if (configValue.string_list && Array.isArray(configValue.string_list.values)) {
|
|
return configValue.string_list.values.slice();
|
|
}
|
|
if (configValue.int64_list && Array.isArray(configValue.int64_list.values)) {
|
|
return configValue.int64_list.values.slice();
|
|
}
|
|
if (configValue.double_list && Array.isArray(configValue.double_list.values)) {
|
|
return configValue.double_list.values.slice();
|
|
}
|
|
if (configValue.bool_list && Array.isArray(configValue.bool_list.values)) {
|
|
return configValue.bool_list.values.slice();
|
|
}
|
|
if (configValue.list_value && Array.isArray(configValue.list_value.values)) {
|
|
return configValue.list_value.values.map(function(v) {
|
|
return configValueToNative(v);
|
|
});
|
|
}
|
|
if (configValue.map_value && configValue.map_value.fields) {
|
|
var out = {};
|
|
Object.keys(configValue.map_value.fields).forEach(function(k) {
|
|
out[k] = configValueToNative(configValue.map_value.fields[k]);
|
|
});
|
|
return out;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function inferConfigValue(value) {
|
|
if (value === null || value === undefined) {
|
|
return { string_value: '' };
|
|
}
|
|
if (typeof value === 'boolean') {
|
|
return { bool_value: value };
|
|
}
|
|
if (typeof value === 'number') {
|
|
if (Number.isInteger(value)) {
|
|
return { int64_value: String(value) };
|
|
}
|
|
return { double_value: value };
|
|
}
|
|
if (typeof value === 'string') {
|
|
return { string_value: value };
|
|
}
|
|
if (Array.isArray(value)) {
|
|
var listValues = value.map(function(item) {
|
|
return inferConfigValue(item);
|
|
});
|
|
return { list_value: { values: listValues } };
|
|
}
|
|
if (typeof value === 'object') {
|
|
var fields = {};
|
|
Object.keys(value).forEach(function(key) {
|
|
fields[key] = inferConfigValue(value[key]);
|
|
});
|
|
return { map_value: { fields: fields } };
|
|
}
|
|
return { string_value: String(value) };
|
|
}
|
|
|
|
function nativeToConfigValue(value, fieldType, widget, field) {
|
|
var normalizedFieldType = normalizeEnum(fieldType);
|
|
var normalizedWidget = normalizeEnum(widget);
|
|
|
|
if (normalizedFieldType === 'CONFIG_FIELD_TYPE_BOOL') {
|
|
return { bool_value: !!value };
|
|
}
|
|
|
|
if (normalizedFieldType === 'CONFIG_FIELD_TYPE_INT64') {
|
|
if (value === '' || value === null || value === undefined) {
|
|
return null;
|
|
}
|
|
var intValue = parseInt(value, 10);
|
|
if (Number.isNaN(intValue)) {
|
|
throw new Error('Invalid int64 value for field ' + field.name);
|
|
}
|
|
return { int64_value: String(intValue) };
|
|
}
|
|
|
|
if (normalizedFieldType === 'CONFIG_FIELD_TYPE_DOUBLE') {
|
|
if (value === '' || value === null || value === undefined) {
|
|
return null;
|
|
}
|
|
var doubleValue = parseFloat(value);
|
|
if (Number.isNaN(doubleValue)) {
|
|
throw new Error('Invalid number for field ' + field.name);
|
|
}
|
|
return { double_value: doubleValue };
|
|
}
|
|
|
|
if (normalizedFieldType === 'CONFIG_FIELD_TYPE_DURATION') {
|
|
if (value === '' || value === null || value === undefined) {
|
|
return null;
|
|
}
|
|
var text = String(value).trim();
|
|
if (!text) {
|
|
return null;
|
|
}
|
|
if (/^\d+$/.test(text)) {
|
|
text += 's';
|
|
}
|
|
return { duration_value: text };
|
|
}
|
|
|
|
if (normalizedFieldType === 'CONFIG_FIELD_TYPE_LIST') {
|
|
if (value === '' || value === null || value === undefined) {
|
|
return null;
|
|
}
|
|
|
|
if (normalizedWidget === 'CONFIG_WIDGET_MULTI_SELECT' && Array.isArray(value)) {
|
|
return { string_list: { values: value } };
|
|
}
|
|
|
|
if (typeof value === 'string') {
|
|
try {
|
|
var parsed = JSON.parse(value);
|
|
if (!Array.isArray(parsed)) {
|
|
throw new Error('Expected JSON array');
|
|
}
|
|
return inferConfigValue(parsed);
|
|
} catch (e) {
|
|
throw new Error('Invalid JSON list for field ' + field.name + ': ' + e.message);
|
|
}
|
|
}
|
|
return inferConfigValue(value);
|
|
}
|
|
|
|
if (normalizedFieldType === 'CONFIG_FIELD_TYPE_OBJECT') {
|
|
if (value === '' || value === null || value === undefined) {
|
|
return null;
|
|
}
|
|
if (typeof value === 'string') {
|
|
try {
|
|
var parsedObject = JSON.parse(value);
|
|
if (!parsedObject || typeof parsedObject !== 'object' || Array.isArray(parsedObject)) {
|
|
throw new Error('Expected JSON object');
|
|
}
|
|
return inferConfigValue(parsedObject);
|
|
} catch (e) {
|
|
throw new Error('Invalid JSON object for field ' + field.name + ': ' + e.message);
|
|
}
|
|
}
|
|
return inferConfigValue(value);
|
|
}
|
|
|
|
if (normalizedFieldType === 'CONFIG_FIELD_TYPE_ENUM') {
|
|
if (normalizedWidget === 'CONFIG_WIDGET_MULTI_SELECT') {
|
|
if (!value || !value.length) {
|
|
return null;
|
|
}
|
|
return { string_list: { values: value } };
|
|
}
|
|
if (value === '' || value === null || value === undefined) {
|
|
return null;
|
|
}
|
|
return { string_value: String(value) };
|
|
}
|
|
|
|
if (value === '' || value === null || value === undefined) {
|
|
return null;
|
|
}
|
|
|
|
return { string_value: String(value) };
|
|
}
|
|
|
|
function toInputID(scope, fieldName) {
|
|
return 'plugin-' + scope + '-field-' + String(fieldName || '').replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
}
|
|
|
|
function resolveFieldConfigValue(scope, field) {
|
|
if (!field || !field.name) {
|
|
return null;
|
|
}
|
|
var fromConfig = null;
|
|
if (state.config) {
|
|
if (scope === 'admin' && state.config.admin_config_values) {
|
|
fromConfig = state.config.admin_config_values[field.name];
|
|
}
|
|
if (scope === 'worker' && state.config.worker_config_values) {
|
|
fromConfig = state.config.worker_config_values[field.name];
|
|
}
|
|
}
|
|
if (fromConfig) {
|
|
return fromConfig;
|
|
}
|
|
|
|
var formDefaults = null;
|
|
if (scope === 'admin' && state.descriptor && state.descriptor.admin_config_form && state.descriptor.admin_config_form.default_values) {
|
|
formDefaults = state.descriptor.admin_config_form.default_values[field.name];
|
|
}
|
|
if (scope === 'worker' && state.descriptor && state.descriptor.worker_config_form && state.descriptor.worker_config_form.default_values) {
|
|
formDefaults = state.descriptor.worker_config_form.default_values[field.name];
|
|
}
|
|
if (formDefaults) {
|
|
return formDefaults;
|
|
}
|
|
|
|
if (scope === 'worker' && state.descriptor && state.descriptor.worker_default_values) {
|
|
return state.descriptor.worker_default_values[field.name] || null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function renderFieldInput(scope, field, value) {
|
|
var inputID = toInputID(scope, field.name);
|
|
var normalizedType = normalizeEnum(field.field_type);
|
|
var normalizedWidget = normalizeEnum(field.widget);
|
|
var input;
|
|
|
|
if (normalizedType === 'CONFIG_FIELD_TYPE_BOOL' || normalizedWidget === 'CONFIG_WIDGET_TOGGLE') {
|
|
input = document.createElement('input');
|
|
input.type = 'checkbox';
|
|
input.className = 'form-check-input';
|
|
input.id = inputID;
|
|
input.checked = !!value;
|
|
} else if ((normalizedType === 'CONFIG_FIELD_TYPE_ENUM' && normalizedWidget === 'CONFIG_WIDGET_MULTI_SELECT') || normalizedWidget === 'CONFIG_WIDGET_MULTI_SELECT') {
|
|
input = document.createElement('select');
|
|
input.className = 'form-select';
|
|
input.id = inputID;
|
|
input.multiple = true;
|
|
var selected = Array.isArray(value) ? value : [];
|
|
var options = Array.isArray(field.options) ? field.options : [];
|
|
for (var i = 0; i < options.length; i++) {
|
|
var option = document.createElement('option');
|
|
option.value = options[i].value || '';
|
|
option.textContent = options[i].label || options[i].value || '';
|
|
if (selected.indexOf(option.value) >= 0) {
|
|
option.selected = true;
|
|
}
|
|
input.appendChild(option);
|
|
}
|
|
} else if (normalizedType === 'CONFIG_FIELD_TYPE_ENUM' || normalizedWidget === 'CONFIG_WIDGET_SELECT') {
|
|
input = document.createElement('select');
|
|
input.className = 'form-select';
|
|
input.id = inputID;
|
|
var blankOption = document.createElement('option');
|
|
blankOption.value = '';
|
|
blankOption.textContent = '-- select --';
|
|
input.appendChild(blankOption);
|
|
var enumOptions = Array.isArray(field.options) ? field.options : [];
|
|
for (var j = 0; j < enumOptions.length; j++) {
|
|
var enumOption = document.createElement('option');
|
|
enumOption.value = enumOptions[j].value || '';
|
|
enumOption.textContent = enumOptions[j].label || enumOptions[j].value || '';
|
|
if (String(value) === enumOption.value) {
|
|
enumOption.selected = true;
|
|
}
|
|
input.appendChild(enumOption);
|
|
}
|
|
} else if (normalizedType === 'CONFIG_FIELD_TYPE_INT64' || normalizedType === 'CONFIG_FIELD_TYPE_DOUBLE' || normalizedWidget === 'CONFIG_WIDGET_NUMBER') {
|
|
input = document.createElement('input');
|
|
input.type = 'number';
|
|
if (normalizedType === 'CONFIG_FIELD_TYPE_DOUBLE') {
|
|
input.step = 'any';
|
|
}
|
|
input.className = 'form-control';
|
|
input.id = inputID;
|
|
if (value !== null && value !== undefined && value !== '') {
|
|
input.value = String(value);
|
|
}
|
|
} else if (normalizedType === 'CONFIG_FIELD_TYPE_LIST' || normalizedType === 'CONFIG_FIELD_TYPE_OBJECT' || normalizedWidget === 'CONFIG_WIDGET_TEXTAREA') {
|
|
input = document.createElement('textarea');
|
|
input.className = 'form-control';
|
|
input.id = inputID;
|
|
input.rows = 4;
|
|
if (normalizedType === 'CONFIG_FIELD_TYPE_LIST' || normalizedType === 'CONFIG_FIELD_TYPE_OBJECT') {
|
|
input.placeholder = normalizedType === 'CONFIG_FIELD_TYPE_LIST' ? '[...]' : '{...}';
|
|
}
|
|
if (value !== null && value !== undefined && value !== '') {
|
|
if (typeof value === 'object') {
|
|
input.value = JSON.stringify(value, null, 2);
|
|
} else {
|
|
input.value = String(value);
|
|
}
|
|
}
|
|
} else {
|
|
input = document.createElement('input');
|
|
input.className = 'form-control';
|
|
input.id = inputID;
|
|
if (normalizedWidget === 'CONFIG_WIDGET_PASSWORD' || field.sensitive) {
|
|
input.type = 'password';
|
|
} else {
|
|
input.type = 'text';
|
|
}
|
|
if (value !== null && value !== undefined && value !== '') {
|
|
input.value = String(value);
|
|
}
|
|
}
|
|
|
|
if (field.placeholder && input.tagName === 'INPUT') {
|
|
input.placeholder = field.placeholder;
|
|
}
|
|
if (field.placeholder && input.tagName === 'TEXTAREA') {
|
|
input.placeholder = field.placeholder;
|
|
}
|
|
|
|
if (field.required) {
|
|
input.required = true;
|
|
}
|
|
if (field.read_only) {
|
|
input.disabled = true;
|
|
}
|
|
|
|
return input;
|
|
}
|
|
|
|
function renderConfigForm(scope, rootID, form) {
|
|
var root = document.getElementById(rootID);
|
|
if (!root) {
|
|
return;
|
|
}
|
|
|
|
root.innerHTML = '';
|
|
if (!form || !Array.isArray(form.sections) || form.sections.length === 0) {
|
|
root.innerHTML = '<div class="text-muted">No fields provided by descriptor.</div>';
|
|
return;
|
|
}
|
|
|
|
for (var s = 0; s < form.sections.length; s++) {
|
|
var section = form.sections[s] || {};
|
|
var sectionCard = document.createElement('div');
|
|
sectionCard.className = 'card mb-3';
|
|
|
|
var sectionHeader = document.createElement('div');
|
|
sectionHeader.className = 'card-header py-2';
|
|
sectionHeader.innerHTML = '<strong>' + escapeHtml(textOrDash(section.title || section.section_id)) + '</strong>';
|
|
sectionCard.appendChild(sectionHeader);
|
|
|
|
var sectionBody = document.createElement('div');
|
|
sectionBody.className = 'card-body';
|
|
|
|
if (section.description) {
|
|
var sectionDescription = document.createElement('p');
|
|
sectionDescription.className = 'text-muted small';
|
|
sectionDescription.textContent = section.description;
|
|
sectionBody.appendChild(sectionDescription);
|
|
}
|
|
|
|
var fields = Array.isArray(section.fields) ? section.fields : [];
|
|
if (!fields.length) {
|
|
var noFields = document.createElement('div');
|
|
noFields.className = 'text-muted';
|
|
noFields.textContent = 'No fields in this section.';
|
|
sectionBody.appendChild(noFields);
|
|
}
|
|
|
|
for (var f = 0; f < fields.length; f++) {
|
|
var field = fields[f] || {};
|
|
if (!field.name) {
|
|
continue;
|
|
}
|
|
|
|
var configValue = resolveFieldConfigValue(scope, field);
|
|
var nativeValue = configValueToNative(configValue);
|
|
|
|
var wrapper = document.createElement('div');
|
|
wrapper.className = 'mb-3 plugin-field';
|
|
wrapper.dataset.scope = scope;
|
|
wrapper.dataset.fieldName = field.name;
|
|
wrapper.dataset.fieldType = normalizeEnum(field.field_type);
|
|
wrapper.dataset.widget = normalizeEnum(field.widget);
|
|
wrapper.dataset.visibleWhenField = field.visible_when_field || '';
|
|
wrapper.dataset.visibleWhenEquals = JSON.stringify(field.visible_when_equals || null);
|
|
|
|
var label = document.createElement('label');
|
|
label.className = 'form-label';
|
|
label.setAttribute('for', toInputID(scope, field.name));
|
|
label.innerHTML = escapeHtml(field.label || field.name) + (field.required ? ' <span class="text-danger">*</span>' : '');
|
|
wrapper.appendChild(label);
|
|
|
|
var input = renderFieldInput(scope, field, nativeValue);
|
|
wrapper.appendChild(input);
|
|
|
|
if (field.description || field.help_text) {
|
|
var help = document.createElement('div');
|
|
help.className = 'form-text';
|
|
help.textContent = field.help_text || field.description || '';
|
|
wrapper.appendChild(help);
|
|
}
|
|
|
|
sectionBody.appendChild(wrapper);
|
|
}
|
|
|
|
sectionCard.appendChild(sectionBody);
|
|
root.appendChild(sectionCard);
|
|
}
|
|
|
|
root.addEventListener('change', function() {
|
|
applyVisibility(scope);
|
|
});
|
|
root.addEventListener('input', function() {
|
|
applyVisibility(scope);
|
|
});
|
|
|
|
applyVisibility(scope);
|
|
}
|
|
|
|
function readRawFieldValue(scope, fieldName) {
|
|
var input = document.getElementById(toInputID(scope, fieldName));
|
|
if (!input) {
|
|
return null;
|
|
}
|
|
if (input.tagName === 'SELECT' && input.multiple) {
|
|
return getSelectValueArray(input);
|
|
}
|
|
if (input.type === 'checkbox') {
|
|
return !!input.checked;
|
|
}
|
|
return input.value;
|
|
}
|
|
|
|
function compareVisibleValue(currentValue, expectedValueObject) {
|
|
if (!expectedValueObject) {
|
|
return true;
|
|
}
|
|
var expectedNative = configValueToNative(expectedValueObject);
|
|
if (Array.isArray(expectedNative)) {
|
|
if (!Array.isArray(currentValue)) {
|
|
return false;
|
|
}
|
|
if (expectedNative.length !== currentValue.length) {
|
|
return false;
|
|
}
|
|
for (var i = 0; i < expectedNative.length; i++) {
|
|
if (String(expectedNative[i]) !== String(currentValue[i])) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
return String(currentValue) === String(expectedNative);
|
|
}
|
|
|
|
function applyVisibility(scope) {
|
|
var fields = document.querySelectorAll('.plugin-field[data-scope="' + scope + '"]');
|
|
for (var i = 0; i < fields.length; i++) {
|
|
var wrapper = fields[i];
|
|
var dependsOn = wrapper.dataset.visibleWhenField;
|
|
if (!dependsOn) {
|
|
wrapper.classList.remove('plugin-field-hidden');
|
|
continue;
|
|
}
|
|
|
|
var expectedRaw = null;
|
|
try {
|
|
expectedRaw = JSON.parse(wrapper.dataset.visibleWhenEquals || 'null');
|
|
} catch (e) {
|
|
expectedRaw = null;
|
|
}
|
|
var current = readRawFieldValue(scope, dependsOn);
|
|
var visible = compareVisibleValue(current, expectedRaw);
|
|
if (visible) {
|
|
wrapper.classList.remove('plugin-field-hidden');
|
|
} else {
|
|
wrapper.classList.add('plugin-field-hidden');
|
|
}
|
|
}
|
|
}
|
|
|
|
function collectConfigValues(scope, form) {
|
|
var out = {};
|
|
if (!form || !Array.isArray(form.sections)) {
|
|
return out;
|
|
}
|
|
|
|
for (var s = 0; s < form.sections.length; s++) {
|
|
var section = form.sections[s] || {};
|
|
var fields = Array.isArray(section.fields) ? section.fields : [];
|
|
for (var f = 0; f < fields.length; f++) {
|
|
var field = fields[f] || {};
|
|
if (!field.name) {
|
|
continue;
|
|
}
|
|
|
|
var wrapper = document.querySelector('.plugin-field[data-scope="' + scope + '"][data-field-name="' + field.name + '"]');
|
|
if (wrapper && wrapper.classList.contains('plugin-field-hidden')) {
|
|
continue;
|
|
}
|
|
|
|
var input = document.getElementById(toInputID(scope, field.name));
|
|
if (!input) {
|
|
continue;
|
|
}
|
|
|
|
var rawValue;
|
|
if (input.tagName === 'SELECT' && input.multiple) {
|
|
rawValue = getSelectValueArray(input);
|
|
} else if (input.type === 'checkbox') {
|
|
rawValue = !!input.checked;
|
|
} else {
|
|
rawValue = input.value;
|
|
}
|
|
|
|
if (field.required) {
|
|
var missing = false;
|
|
if (input.type === 'checkbox') {
|
|
missing = false;
|
|
} else if (Array.isArray(rawValue)) {
|
|
missing = rawValue.length === 0;
|
|
} else {
|
|
missing = String(rawValue || '').trim() === '';
|
|
}
|
|
if (missing) {
|
|
throw new Error('Missing required field: ' + (field.label || field.name));
|
|
}
|
|
}
|
|
|
|
var converted = nativeToConfigValue(rawValue, field.field_type, field.widget, field);
|
|
if (converted !== null) {
|
|
out[field.name] = converted;
|
|
}
|
|
}
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
function renderDescriptorSummary() {
|
|
var summary = document.getElementById('plugin-descriptor-summary');
|
|
if (!state.descriptor) {
|
|
summary.innerHTML = 'No descriptor loaded.';
|
|
return;
|
|
}
|
|
|
|
var descriptor = state.descriptor;
|
|
var html = '';
|
|
html += '<div><strong>' + escapeHtml(textOrDash(descriptor.display_name || descriptor.job_type)) + '</strong></div>';
|
|
html += '<div><small>Job Type: ' + escapeHtml(textOrDash(descriptor.job_type)) + '</small></div>';
|
|
html += '<div><small>Descriptor Version: ' + escapeHtml(textOrDash(descriptor.descriptor_version)) + '</small></div>';
|
|
if (descriptor.description) {
|
|
html += '<div class="mt-1">' + escapeHtml(descriptor.description) + '</div>';
|
|
}
|
|
summary.innerHTML = html;
|
|
}
|
|
|
|
function fillAdminSettings() {
|
|
var runtimeConfig = (state.config && state.config.admin_runtime) ? state.config.admin_runtime : {};
|
|
var defaults = (state.descriptor && state.descriptor.admin_runtime_defaults) ? state.descriptor.admin_runtime_defaults : {};
|
|
|
|
function pickNumber(key) {
|
|
if (runtimeConfig && runtimeConfig[key] !== undefined && runtimeConfig[key] !== null) {
|
|
return runtimeConfig[key];
|
|
}
|
|
if (defaults && defaults[key] !== undefined && defaults[key] !== null) {
|
|
return defaults[key];
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function pickBool(key) {
|
|
if (runtimeConfig && runtimeConfig[key] !== undefined && runtimeConfig[key] !== null) {
|
|
return !!runtimeConfig[key];
|
|
}
|
|
if (defaults && defaults[key] !== undefined && defaults[key] !== null) {
|
|
return !!defaults[key];
|
|
}
|
|
return true;
|
|
}
|
|
|
|
document.getElementById('plugin-admin-enabled').checked = pickBool('enabled');
|
|
document.getElementById('plugin-admin-detection-interval').value = String(pickNumber('detection_interval_seconds'));
|
|
document.getElementById('plugin-admin-detection-timeout').value = String(pickNumber('detection_timeout_seconds'));
|
|
document.getElementById('plugin-admin-max-results').value = String(pickNumber('max_jobs_per_detection'));
|
|
document.getElementById('plugin-admin-global-exec').value = String(pickNumber('global_execution_concurrency'));
|
|
document.getElementById('plugin-admin-per-worker-exec').value = String(pickNumber('per_worker_execution_concurrency'));
|
|
document.getElementById('plugin-admin-retry-limit').value = String(pickNumber('retry_limit'));
|
|
document.getElementById('plugin-admin-retry-backoff').value = String(pickNumber('retry_backoff_seconds'));
|
|
}
|
|
|
|
function collectAdminSettings() {
|
|
function getInt(id) {
|
|
var raw = String(document.getElementById(id).value || '').trim();
|
|
if (!raw) {
|
|
return 0;
|
|
}
|
|
var parsed = parseInt(raw, 10);
|
|
if (Number.isNaN(parsed) || parsed < 0) {
|
|
throw new Error('Invalid number for ' + id);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
return {
|
|
enabled: !!document.getElementById('plugin-admin-enabled').checked,
|
|
detection_interval_seconds: getInt('plugin-admin-detection-interval'),
|
|
detection_timeout_seconds: getInt('plugin-admin-detection-timeout'),
|
|
max_jobs_per_detection: getInt('plugin-admin-max-results'),
|
|
global_execution_concurrency: getInt('plugin-admin-global-exec'),
|
|
per_worker_execution_concurrency: getInt('plugin-admin-per-worker-exec'),
|
|
retry_limit: getInt('plugin-admin-retry-limit'),
|
|
retry_backoff_seconds: getInt('plugin-admin-retry-backoff'),
|
|
};
|
|
}
|
|
|
|
function renderRunHistory(history) {
|
|
var successBody = document.getElementById('plugin-runs-success-body');
|
|
var errorBody = document.getElementById('plugin-runs-error-body');
|
|
|
|
var successRuns = (history && Array.isArray(history.successful_runs)) ? history.successful_runs : [];
|
|
var errorRuns = (history && Array.isArray(history.error_runs)) ? history.error_runs : [];
|
|
|
|
if (!successRuns.length) {
|
|
successBody.innerHTML = '<tr><td colspan="4" class="text-muted">No successful runs</td></tr>';
|
|
} else {
|
|
var successRows = '';
|
|
for (var i = 0; i < successRuns.length; i++) {
|
|
var run = successRuns[i] || {};
|
|
successRows += '<tr>' +
|
|
'<td><small>' + escapeHtml(parseTime(run.completed_at) || '-') + '</small></td>' +
|
|
'<td>' + renderJobLink(run.job_id) + '</td>' +
|
|
'<td><small>' + escapeHtml(textOrDash(run.worker_id)) + '</small></td>' +
|
|
'<td><small>' + escapeHtml(formatDurationMs(run.duration_ms)) + '</small></td>' +
|
|
'</tr>';
|
|
}
|
|
successBody.innerHTML = successRows;
|
|
}
|
|
|
|
if (!errorRuns.length) {
|
|
errorBody.innerHTML = '<tr><td colspan="4" class="text-muted">No error runs</td></tr>';
|
|
} else {
|
|
var errorRows = '';
|
|
for (var j = 0; j < errorRuns.length; j++) {
|
|
var errorRun = errorRuns[j] || {};
|
|
errorRows += '<tr>' +
|
|
'<td><small>' + escapeHtml(parseTime(errorRun.completed_at) || '-') + '</small></td>' +
|
|
'<td>' + renderJobLink(errorRun.job_id) + '</td>' +
|
|
'<td><small>' + escapeHtml(textOrDash(errorRun.worker_id)) + '</small></td>' +
|
|
'<td><small class="text-danger">' + escapeHtml(textOrDash(errorRun.message)) + '</small></td>' +
|
|
'</tr>';
|
|
}
|
|
errorBody.innerHTML = errorRows;
|
|
}
|
|
}
|
|
|
|
function renderDetectionResult(data) {
|
|
var root = document.getElementById('plugin-detection-results');
|
|
if (!root) {
|
|
return;
|
|
}
|
|
|
|
function normalizeProposals(payload) {
|
|
if (!payload || typeof payload !== 'object') {
|
|
return [];
|
|
}
|
|
if (Array.isArray(payload.proposals)) {
|
|
return payload.proposals;
|
|
}
|
|
if (payload.proposals && typeof payload.proposals === 'object') {
|
|
return Object.keys(payload.proposals).map(function(key) {
|
|
return payload.proposals[key];
|
|
}).filter(function(item) {
|
|
return !!item;
|
|
});
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function proposalID(proposal) {
|
|
return proposal.proposal_id || proposal.proposalId || proposal.id || '';
|
|
}
|
|
|
|
function proposalSummary(proposal) {
|
|
return proposal.summary || proposal.title || proposalID(proposal) || 'Proposal';
|
|
}
|
|
|
|
function proposalDetail(proposal) {
|
|
return proposal.detail || proposal.reason || proposal.message || '-';
|
|
}
|
|
|
|
function normalizeActivities(payload) {
|
|
if (!payload || typeof payload !== 'object') {
|
|
return [];
|
|
}
|
|
if (Array.isArray(payload.activities)) {
|
|
return payload.activities;
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function encodeDetails(details) {
|
|
if (!details || typeof details !== 'object') {
|
|
return '';
|
|
}
|
|
var text = '';
|
|
try {
|
|
text = JSON.stringify(details);
|
|
} catch (e) {
|
|
text = '';
|
|
}
|
|
if (!text) {
|
|
return '';
|
|
}
|
|
if (text.length > 360) {
|
|
text = text.slice(0, 360) + '...';
|
|
}
|
|
return text;
|
|
}
|
|
|
|
if (!data) {
|
|
root.innerHTML = '<div class="text-muted">No results.</div>';
|
|
return;
|
|
}
|
|
|
|
var proposals = normalizeProposals(data);
|
|
var activities = normalizeActivities(data);
|
|
var responseCount = Number(data.count || 0);
|
|
if (!Number.isFinite(responseCount) || responseCount < 0) {
|
|
responseCount = proposals.length;
|
|
}
|
|
var requestID = data.request_id || data.requestId || '';
|
|
var detectorWorkerID = data.detector_worker_id || data.detectorWorkerId || '';
|
|
var totalProposals = Number(data.total_proposals || 0);
|
|
if (!Number.isFinite(totalProposals) || totalProposals < 0) {
|
|
totalProposals = responseCount;
|
|
}
|
|
|
|
var html = '';
|
|
html += '<div class="mb-2"><strong>Detection Run</strong></div>';
|
|
if (requestID) {
|
|
html += '<div><small>request_id: ' + escapeHtml(String(requestID)) + '</small></div>';
|
|
}
|
|
if (detectorWorkerID) {
|
|
html += '<div><small>detector: ' + escapeHtml(String(detectorWorkerID)) + '</small></div>';
|
|
}
|
|
html += '<div><small>proposals: ' + escapeHtml(String(responseCount)) + ' / total: ' + escapeHtml(String(totalProposals)) + '</small></div>';
|
|
if (data.error) {
|
|
html += '<div class="mt-1 text-danger"><small>' + escapeHtml(String(data.error)) + '</small></div>';
|
|
}
|
|
|
|
if (!proposals.length) {
|
|
html += '<div class="mt-2 text-muted">No proposals returned.</div>';
|
|
} else {
|
|
html += '<div class="mt-2 mb-1"><strong>' + escapeHtml(String(proposals.length)) + '</strong> proposal(s)</div>';
|
|
for (var i = 0; i < proposals.length; i++) {
|
|
var proposal = proposals[i] || {};
|
|
var pID = proposalID(proposal);
|
|
var pSummary = proposalSummary(proposal);
|
|
var pDetail = proposalDetail(proposal);
|
|
var pPriority = proposal.priority;
|
|
if (pPriority === null || pPriority === undefined || pPriority === '') {
|
|
pPriority = '-';
|
|
}
|
|
html += '<div class="plugin-detection-item">' +
|
|
'<div class="fw-semibold">' + escapeHtml(textOrDash(pSummary)) + '</div>' +
|
|
'<div><small>proposal_id: ' + escapeHtml(textOrDash(pID)) + '</small></div>' +
|
|
'<div><small>priority: ' + escapeHtml(textOrDash(pPriority)) + '</small></div>' +
|
|
'<div><small>detail: ' + escapeHtml(textOrDash(pDetail)) + '</small></div>' +
|
|
'</div>';
|
|
}
|
|
}
|
|
|
|
if (activities.length) {
|
|
html += '<hr class="my-2"/>';
|
|
html += '<div class="mb-2"><strong>Detection Process</strong></div>';
|
|
for (var j = 0; j < activities.length; j++) {
|
|
var activity = activities[j] || {};
|
|
var activityStage = activity.stage || '-';
|
|
var activitySource = activity.source || '-';
|
|
var activityMessage = activity.message || '-';
|
|
var activityTime = parseTime(activity.occurred_at) || '-';
|
|
var detailsText = encodeDetails(activity.details);
|
|
html += '<div class="plugin-detection-item">' +
|
|
'<div class="fw-semibold">' + escapeHtml(textOrDash(activityStage)) + '</div>' +
|
|
'<div><small>source: ' + escapeHtml(textOrDash(activitySource)) + ' | at: ' + escapeHtml(activityTime) + '</small></div>' +
|
|
'<div><small>' + escapeHtml(textOrDash(activityMessage)) + '</small></div>';
|
|
if (detailsText) {
|
|
html += '<div><small class="text-muted">details: ' + escapeHtml(detailsText) + '</small></div>';
|
|
}
|
|
html += '</div>';
|
|
}
|
|
}
|
|
|
|
root.innerHTML = html;
|
|
}
|
|
|
|
function getSelectedJobType() {
|
|
return String(state.selectedJobType || '').trim();
|
|
}
|
|
|
|
function resetJobTypePanels() {
|
|
state.loadedJobType = '';
|
|
state.descriptor = null;
|
|
state.config = null;
|
|
document.getElementById('plugin-descriptor-summary').textContent = 'No descriptor loaded.';
|
|
document.getElementById('plugin-admin-config-form').innerHTML = '<div class="text-muted">No admin form loaded.</div>';
|
|
document.getElementById('plugin-worker-config-form').innerHTML = '<div class="text-muted">No worker form loaded.</div>';
|
|
document.getElementById('plugin-config-updated-at').textContent = 'Not loaded';
|
|
renderRunHistory(null);
|
|
renderDetectionResult(null);
|
|
}
|
|
|
|
async function loadDescriptorAndConfig(jobType, forceRefresh) {
|
|
if (!jobType) {
|
|
return;
|
|
}
|
|
|
|
state.selectedJobType = jobType;
|
|
renderNavigationState();
|
|
|
|
var summary = document.getElementById('plugin-descriptor-summary');
|
|
summary.textContent = 'Loading descriptor and config...';
|
|
|
|
try {
|
|
if (forceRefresh) {
|
|
var refreshURL = '/api/plugin/job-types/' + encodePath(jobType) + '/schema?force_refresh=true';
|
|
state.descriptor = await pluginRequest('POST', refreshURL, null);
|
|
} else {
|
|
try {
|
|
state.descriptor = await pluginRequest('GET', '/api/plugin/job-types/' + encodePath(jobType) + '/descriptor');
|
|
} catch (descriptorLoadError) {
|
|
state.descriptor = await pluginRequest('POST', '/api/plugin/job-types/' + encodePath(jobType) + '/schema', null);
|
|
}
|
|
}
|
|
} catch (schemaError) {
|
|
state.loadedJobType = '';
|
|
state.descriptor = null;
|
|
summary.innerHTML = '<span class="text-danger">Failed to load descriptor: ' + escapeHtml(schemaError.message) + '</span>';
|
|
document.getElementById('plugin-admin-config-form').innerHTML = '<div class="text-muted">No admin form loaded.</div>';
|
|
document.getElementById('plugin-worker-config-form').innerHTML = '<div class="text-muted">No worker form loaded.</div>';
|
|
throw schemaError;
|
|
}
|
|
|
|
try {
|
|
state.config = await pluginRequest('GET', '/api/plugin/job-types/' + encodePath(jobType) + '/config');
|
|
} catch (configError) {
|
|
state.config = null;
|
|
notify('Failed to load config: ' + configError.message, 'error');
|
|
}
|
|
|
|
renderDescriptorSummary();
|
|
fillAdminSettings();
|
|
|
|
if (state.descriptor) {
|
|
renderConfigForm('admin', 'plugin-admin-config-form', state.descriptor.admin_config_form || {});
|
|
renderConfigForm('worker', 'plugin-worker-config-form', state.descriptor.worker_config_form || {});
|
|
}
|
|
|
|
if (state.config && state.config.updated_at) {
|
|
document.getElementById('plugin-config-updated-at').textContent = 'Updated: ' + (parseTime(state.config.updated_at) || state.config.updated_at);
|
|
} else {
|
|
document.getElementById('plugin-config-updated-at').textContent = 'Not saved yet';
|
|
}
|
|
state.loadedJobType = jobType;
|
|
|
|
await loadRunHistory(jobType);
|
|
|
|
if (state.lastDetectionByJobType && state.lastDetectionByJobType[jobType]) {
|
|
renderDetectionResult(state.lastDetectionByJobType[jobType]);
|
|
} else {
|
|
renderDetectionResult(null);
|
|
}
|
|
}
|
|
|
|
async function loadRunHistory(jobType) {
|
|
if (!jobType) {
|
|
renderRunHistory(null);
|
|
return;
|
|
}
|
|
try {
|
|
var history = await pluginRequest('GET', '/api/plugin/job-types/' + encodePath(jobType) + '/runs');
|
|
renderRunHistory(history);
|
|
} catch (e) {
|
|
renderRunHistory(null);
|
|
notify('Failed to load run history: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function saveConfig() {
|
|
var jobType = state.selectedJobType || getSelectedJobType();
|
|
if (!jobType) {
|
|
notify('Select a job type first.', 'error');
|
|
return;
|
|
}
|
|
if (!state.descriptor) {
|
|
notify('Load descriptor before saving config.', 'error');
|
|
return;
|
|
}
|
|
|
|
var payload;
|
|
try {
|
|
payload = {
|
|
job_type: jobType,
|
|
descriptor_version: state.descriptor.descriptor_version || 0,
|
|
admin_runtime: collectAdminSettings(),
|
|
admin_config_values: collectConfigValues('admin', state.descriptor.admin_config_form || {}),
|
|
worker_config_values: collectConfigValues('worker', state.descriptor.worker_config_form || {}),
|
|
};
|
|
} catch (err) {
|
|
notify(err.message, 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
state.config = await pluginRequest('PUT', '/api/plugin/job-types/' + encodePath(jobType) + '/config', payload);
|
|
document.getElementById('plugin-config-updated-at').textContent = 'Updated: ' + (parseTime(state.config.updated_at) || state.config.updated_at || 'just now');
|
|
notify('Config saved for ' + jobType, 'success');
|
|
} catch (e) {
|
|
notify('Failed to save config: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
function getMaxResults() {
|
|
var raw = String(document.getElementById('plugin-admin-max-results').value || '').trim();
|
|
if (!raw) {
|
|
return 0;
|
|
}
|
|
var parsed = parseInt(raw, 10);
|
|
if (Number.isNaN(parsed) || parsed < 0) {
|
|
return 0;
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
async function runDetection() {
|
|
var jobType = state.selectedJobType || getSelectedJobType();
|
|
if (!jobType) {
|
|
notify('Select a job type first.', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
var data = await pluginRequest('POST', '/api/plugin/job-types/' + encodePath(jobType) + '/detect', {
|
|
max_results: getMaxResults(),
|
|
});
|
|
state.lastDetectionByJobType[jobType] = data;
|
|
renderDetectionResult(data);
|
|
} catch (e) {
|
|
if (e && e.responseData) {
|
|
state.lastDetectionByJobType[jobType] = e.responseData;
|
|
renderDetectionResult(e.responseData);
|
|
}
|
|
notify('Detection failed: ' + e.message, 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await refreshJobsAndActivities();
|
|
} catch (refreshErr) {
|
|
notify('Detection succeeded, but monitoring refresh failed: ' + refreshErr.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function runWorkflow() {
|
|
var jobType = state.selectedJobType || getSelectedJobType();
|
|
if (!jobType) {
|
|
notify('Select a job type first.', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
var data = await pluginRequest('POST', '/api/plugin/job-types/' + encodePath(jobType) + '/run', {
|
|
max_results: getMaxResults(),
|
|
});
|
|
var resultRoot = document.getElementById('plugin-detection-results');
|
|
resultRoot.innerHTML = '<div class="mb-2"><strong>Workflow completed</strong></div>' +
|
|
'<div>Detected: <strong>' + escapeHtml(String(data.detected_count || 0)) + '</strong></div>' +
|
|
'<div>Ready to Execute: <strong>' + escapeHtml(String(data.ready_to_execute_count || 0)) + '</strong></div>' +
|
|
'<div>Skipped (Already Active): <strong>' + escapeHtml(String(data.skipped_active_count || 0)) + '</strong></div>' +
|
|
'<div>Executed: <strong>' + escapeHtml(String(data.executed_count || 0)) + '</strong></div>' +
|
|
'<div class="text-success">Success: ' + escapeHtml(String(data.success_count || 0)) + '</div>' +
|
|
'<div class="text-danger">Errors: ' + escapeHtml(String(data.error_count || 0)) + '</div>';
|
|
await loadRunHistory(jobType);
|
|
await refreshJobsAndActivities();
|
|
} catch (e) {
|
|
notify('Workflow run failed: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function refreshJobsAndActivities() {
|
|
var executionStateFilter = document.getElementById('plugin-monitor-job-state-filter');
|
|
|
|
state.monitorExecutionJobState = executionStateFilter ? String(executionStateFilter.value || '').trim() : '';
|
|
|
|
var allJobsPromise = pluginRequest('GET', '/api/plugin/jobs?limit=500');
|
|
var allActivitiesPromise = pluginRequest('GET', '/api/plugin/activities?limit=500');
|
|
var schedulerPromise = pluginRequest('GET', '/api/plugin/scheduler-states');
|
|
|
|
var allJobs = await allJobsPromise;
|
|
var allActivities = await allActivitiesPromise;
|
|
var schedulerStates = await schedulerPromise;
|
|
|
|
state.jobs = Array.isArray(allJobs) ? allJobs : [];
|
|
state.activities = Array.isArray(allActivities) ? allActivities : [];
|
|
state.allJobs = state.jobs;
|
|
state.allActivities = state.activities;
|
|
state.schedulerStates = Array.isArray(schedulerStates) ? schedulerStates : [];
|
|
renderQueueJobs();
|
|
renderDetectionJobs();
|
|
renderExecutionJobs();
|
|
renderExecutionActivities();
|
|
renderSchedulerStates();
|
|
renderStatus();
|
|
renderJobTypeSummary();
|
|
}
|
|
|
|
async function refreshAll() {
|
|
try {
|
|
var previousSelectedJobType = state.selectedJobType;
|
|
|
|
var workers = await pluginRequest('GET', '/api/plugin/workers');
|
|
state.workers = Array.isArray(workers) ? workers : [];
|
|
renderWorkers();
|
|
|
|
var jobTypes = await pluginRequest('GET', '/api/plugin/job-types');
|
|
state.jobTypes = Array.isArray(jobTypes) ? jobTypes : [];
|
|
applyInitialNavigation();
|
|
ensureActiveNavigation();
|
|
renderTopTabs();
|
|
renderNavigationState();
|
|
|
|
if (!state.selectedJobType) {
|
|
resetJobTypePanels();
|
|
} else if (state.loadedJobType !== state.selectedJobType || previousSelectedJobType !== state.selectedJobType) {
|
|
try {
|
|
await loadDescriptorAndConfig(state.selectedJobType, false);
|
|
} catch (loadError) {
|
|
// Already rendered in UI.
|
|
}
|
|
}
|
|
|
|
await refreshJobsAndActivities();
|
|
} catch (e) {
|
|
notify('Refresh failed: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
function bindActions() {
|
|
var monitorStateFilter = document.getElementById('plugin-monitor-job-state-filter');
|
|
if (monitorStateFilter) {
|
|
monitorStateFilter.addEventListener('change', function() {
|
|
state.monitorExecutionJobState = String(monitorStateFilter.value || '').trim();
|
|
renderExecutionJobs();
|
|
});
|
|
}
|
|
|
|
document.getElementById('plugin-refresh-all-btn').addEventListener('click', function() {
|
|
refreshAll();
|
|
});
|
|
|
|
document.getElementById('plugin-refresh-schema-btn').addEventListener('click', async function() {
|
|
var jobType = getSelectedJobType();
|
|
if (!jobType) {
|
|
notify('Select a job type first.', 'error');
|
|
return;
|
|
}
|
|
try {
|
|
await loadDescriptorAndConfig(jobType, true);
|
|
notify('Schema refreshed for ' + jobType, 'success');
|
|
} catch (e) {
|
|
notify('Schema refresh failed: ' + e.message, 'error');
|
|
}
|
|
});
|
|
|
|
document.getElementById('plugin-save-config-btn').addEventListener('click', function() {
|
|
saveConfig();
|
|
});
|
|
|
|
document.getElementById('plugin-trigger-detection-btn').addEventListener('click', function() {
|
|
runDetection();
|
|
});
|
|
|
|
document.getElementById('plugin-run-workflow-btn').addEventListener('click', function() {
|
|
runWorkflow();
|
|
});
|
|
|
|
page.addEventListener('click', async function(event) {
|
|
var target = event.target;
|
|
if (!target) {
|
|
return;
|
|
}
|
|
|
|
var topTab = target.closest('[data-plugin-top-tab]');
|
|
if (topTab) {
|
|
event.preventDefault();
|
|
var topTabKey = String(topTab.getAttribute('data-plugin-top-tab') || '').trim();
|
|
if (!topTabKey) {
|
|
return;
|
|
}
|
|
if (topTabKey === 'overview') {
|
|
state.activeTopTab = 'overview';
|
|
renderNavigationState();
|
|
return;
|
|
}
|
|
|
|
var jobType = parseJobTypeFromTopTab(topTabKey);
|
|
if (!jobType) {
|
|
return;
|
|
}
|
|
state.activeTopTab = topTabKey;
|
|
state.selectedJobType = jobType;
|
|
ensureActiveNavigation();
|
|
renderNavigationState();
|
|
if (state.loadedJobType !== jobType) {
|
|
try {
|
|
await loadDescriptorAndConfig(jobType, false);
|
|
} catch (e) {
|
|
// Already rendered in UI.
|
|
}
|
|
} else {
|
|
if (state.lastDetectionByJobType && state.lastDetectionByJobType[jobType]) {
|
|
renderDetectionResult(state.lastDetectionByJobType[jobType]);
|
|
} else {
|
|
renderDetectionResult(null);
|
|
}
|
|
await loadRunHistory(jobType);
|
|
}
|
|
renderQueueJobs();
|
|
renderDetectionJobs();
|
|
renderExecutionJobs();
|
|
renderExecutionActivities();
|
|
return;
|
|
}
|
|
|
|
var subTab = target.closest('[data-plugin-subtab]');
|
|
if (subTab) {
|
|
event.preventDefault();
|
|
var subTabKey = normalizeSubTab(subTab.getAttribute('data-plugin-subtab'));
|
|
state.activeSubTab = subTabKey;
|
|
renderNavigationState();
|
|
return;
|
|
}
|
|
|
|
var trigger = target.closest('.plugin-job-link');
|
|
if (!trigger) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
var jobID = trigger.getAttribute('data-job-id');
|
|
if (!jobID) {
|
|
return;
|
|
}
|
|
openJobDetail(jobID);
|
|
});
|
|
}
|
|
|
|
async function init() {
|
|
bindActions();
|
|
renderTopTabs();
|
|
ensureActiveNavigation();
|
|
renderNavigationState();
|
|
await refreshAll();
|
|
|
|
state.refreshTimer = setInterval(function() {
|
|
refreshAll();
|
|
}, 3000);
|
|
}
|
|
|
|
window.addEventListener('beforeunload', function() {
|
|
if (state.refreshTimer) {
|
|
clearInterval(state.refreshTimer);
|
|
state.refreshTimer = null;
|
|
}
|
|
});
|
|
|
|
init();
|
|
})();
|
|
</script>
|
|
}
|