Add customizable plugin display names and weights (#8459)
* feat: add customizable plugin display names and weights - Add weight field to JobTypeCapability proto message - Modify ListKnownJobTypes() to return JobTypeInfo with display names and weights - Modify ListPluginJobTypes() to return JobTypeInfo instead of string - Sort plugins by weight (descending) then alphabetically - Update admin API to return enriched job type metadata - Update plugin UI template to display names instead of IDs - Consolidate API by reusing existing function names instead of suffixed variants * perf: optimize plugin job type capability lookup and add null-safe parsing - Pre-calculate job type capabilities in a map to reduce O(n*m) nested loops to O(n+m) lookup time in ListKnownJobTypes() - Add parseJobTypeItem() helper function for null-safe job type item parsing - Refactor plugin.templ to use parseJobTypeItem() in all job type access points (hasJobType, applyInitialNavigation, ensureActiveNavigation, renderTopTabs) - Deterministic capability resolution by using first worker's capability * templ * refactor: use parseJobTypeItem helper consistently in plugin.templ Replace duplicated job type extraction logic at line 1296-1298 with parseJobTypeItem() helper function for consistency and maintainability. * improve: prefer richer capability metadata and add null-safety checks - Improve capability selection in ListKnownJobTypes() to prefer capabilities with non-empty DisplayName and higher Weight across all workers instead of first-wins approach. Handles mixed-version clusters better. - Add defensive null checks in renderJobTypeSummary() to safely access parseJobTypeItem() result before property access - Ensures malformed or missing entries won't break the rendering pipeline * fix: preserve existing DisplayName when merging capabilities Fix capability merge logic to respect existing DisplayName values: - If existing has DisplayName but candidate doesn't, preserve existing - If existing doesn't have DisplayName but candidate does, use candidate - Only use Weight comparison if DisplayName status is equal - Prevents higher-weight capabilities with empty DisplayName from overriding capabilities with non-empty DisplayName
This commit is contained in:
@@ -3,6 +3,11 @@ package dash
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/maintenance"
|
||||
adminplugin "github.com/seaweedfs/seaweedfs/weed/admin/plugin"
|
||||
"github.com/seaweedfs/seaweedfs/weed/cluster"
|
||||
@@ -19,10 +24,6 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"github.com/seaweedfs/seaweedfs/weed/wdclient"
|
||||
"google.golang.org/grpc"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
@@ -980,7 +981,7 @@ func (s *AdminServer) GetPluginRunHistory(jobType string) (*adminplugin.JobTypeR
|
||||
}
|
||||
|
||||
// ListPluginJobTypes returns known plugin job types from connected worker registry and persisted data.
|
||||
func (s *AdminServer) ListPluginJobTypes() ([]string, error) {
|
||||
func (s *AdminServer) ListPluginJobTypes() ([]adminplugin.JobTypeInfo, error) {
|
||||
if s.plugin == nil {
|
||||
return nil, fmt.Errorf("plugin is not enabled")
|
||||
}
|
||||
|
||||
@@ -34,6 +34,13 @@ type Options struct {
|
||||
ClusterContextProvider func(context.Context) (*plugin_pb.ClusterContext, error)
|
||||
}
|
||||
|
||||
// JobTypeInfo contains metadata about a plugin job type.
|
||||
type JobTypeInfo struct {
|
||||
JobType string `json:"job_type"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Weight int32 `json:"weight"`
|
||||
}
|
||||
|
||||
type Plugin struct {
|
||||
plugin_pb.UnimplementedPluginControlServiceServer
|
||||
|
||||
@@ -623,7 +630,7 @@ func (r *Plugin) ListWorkers() []*WorkerSession {
|
||||
return r.registry.List()
|
||||
}
|
||||
|
||||
func (r *Plugin) ListKnownJobTypes() ([]string, error) {
|
||||
func (r *Plugin) ListKnownJobTypes() ([]JobTypeInfo, error) {
|
||||
registryJobTypes := r.registry.JobTypes()
|
||||
storedJobTypes, err := r.store.ListJobTypes()
|
||||
if err != nil {
|
||||
@@ -638,12 +645,72 @@ func (r *Plugin) ListKnownJobTypes() ([]string, error) {
|
||||
jobTypeSet[jobType] = struct{}{}
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(jobTypeSet))
|
||||
jobTypeList := make([]string, 0, len(jobTypeSet))
|
||||
for jobType := range jobTypeSet {
|
||||
out = append(out, jobType)
|
||||
jobTypeList = append(jobTypeList, jobType)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
sort.Strings(jobTypeList)
|
||||
|
||||
result := make([]JobTypeInfo, 0, len(jobTypeList))
|
||||
workers := r.registry.List()
|
||||
|
||||
// Pre-calculate the best capability for each job type from available workers.
|
||||
// Prefer capabilities with non-empty DisplayName, then higher Weight.
|
||||
jobTypeToCap := make(map[string]*plugin_pb.JobTypeCapability)
|
||||
for _, worker := range workers {
|
||||
for jobType, cap := range worker.Capabilities {
|
||||
if cap == nil {
|
||||
continue
|
||||
}
|
||||
existing, exists := jobTypeToCap[jobType]
|
||||
if !exists || existing == nil {
|
||||
jobTypeToCap[jobType] = cap
|
||||
continue
|
||||
}
|
||||
// Preserve existing if it has DisplayName but cap doesn't.
|
||||
if existing.DisplayName != "" && cap.DisplayName == "" {
|
||||
continue
|
||||
}
|
||||
// Prefer capabilities with a non-empty DisplayName.
|
||||
if existing.DisplayName == "" && cap.DisplayName != "" {
|
||||
jobTypeToCap[jobType] = cap
|
||||
continue
|
||||
}
|
||||
// If DisplayName statuses are equal, prefer higher Weight.
|
||||
if cap.Weight > existing.Weight {
|
||||
jobTypeToCap[jobType] = cap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, jobType := range jobTypeList {
|
||||
info := JobTypeInfo{JobType: jobType}
|
||||
|
||||
// Get display name and weight from pre-calculated capabilities
|
||||
if cap, ok := jobTypeToCap[jobType]; ok && cap != nil {
|
||||
if cap.DisplayName != "" {
|
||||
info.DisplayName = cap.DisplayName
|
||||
}
|
||||
info.Weight = cap.Weight
|
||||
}
|
||||
|
||||
// Default display name to job type if not set
|
||||
if info.DisplayName == "" {
|
||||
info.DisplayName = jobType
|
||||
}
|
||||
|
||||
result = append(result, info)
|
||||
}
|
||||
|
||||
// Sort by weight (descending) then by job type (ascending)
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].Weight != result[j].Weight {
|
||||
return result[i].Weight > result[j].Weight // higher weight first
|
||||
}
|
||||
return result[i].JobType < result[j].JobType // alphabetical as tiebreaker
|
||||
})
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// FilterProposalsWithActiveJobs drops proposals that are already assigned/running.
|
||||
|
||||
@@ -175,7 +175,8 @@ func (r *Plugin) ListSchedulerStates() ([]SchedulerJobTypeState, error) {
|
||||
r.schedulerMu.Unlock()
|
||||
|
||||
states := make([]SchedulerJobTypeState, 0, len(jobTypes))
|
||||
for _, jobType := range jobTypes {
|
||||
for _, jobTypeInfo := range jobTypes {
|
||||
jobType := jobTypeInfo.JobType
|
||||
state := SchedulerJobTypeState{
|
||||
JobType: jobType,
|
||||
DetectionInFlight: detectionInFlight[jobType],
|
||||
@@ -187,6 +188,7 @@ func (r *Plugin) ListSchedulerStates() ([]SchedulerJobTypeState, error) {
|
||||
}
|
||||
|
||||
policy, enabled, loadErr := r.loadSchedulerPolicy(jobType)
|
||||
|
||||
if loadErr != nil {
|
||||
state.PolicyError = loadErr.Error()
|
||||
} else {
|
||||
|
||||
@@ -683,6 +683,25 @@ templ Plugin(page string) {
|
||||
return 'configuration';
|
||||
}
|
||||
|
||||
// Parse a job type item (string or object) into a safe object shape
|
||||
function parseJobTypeItem(item) {
|
||||
var jobType = '';
|
||||
var displayName = '';
|
||||
|
||||
if (typeof item === 'string') {
|
||||
jobType = String(item || '').trim();
|
||||
displayName = jobType;
|
||||
} else if (item && typeof item === 'object') {
|
||||
jobType = String(item.job_type || item.jobType || '').trim();
|
||||
displayName = String(item.display_name || item.displayName || jobType || '').trim();
|
||||
if (!displayName) {
|
||||
displayName = jobType;
|
||||
}
|
||||
}
|
||||
|
||||
return { jobType: jobType, displayName: displayName };
|
||||
}
|
||||
|
||||
function topTabKeyForJobType(jobType) {
|
||||
var normalized = String(jobType || '').trim();
|
||||
if (!normalized) {
|
||||
@@ -706,7 +725,8 @@ templ Plugin(page string) {
|
||||
}
|
||||
var jobTypes = Array.isArray(state.jobTypes) ? state.jobTypes : [];
|
||||
for (var i = 0; i < jobTypes.length; i++) {
|
||||
if (String(jobTypes[i] || '').trim() === normalized) {
|
||||
var item = parseJobTypeItem(jobTypes[i]);
|
||||
if (item.jobType === normalized) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -728,7 +748,8 @@ templ Plugin(page string) {
|
||||
state.activeTopTab = 'overview';
|
||||
return;
|
||||
}
|
||||
state.selectedJobType = String(state.jobTypes[0] || '').trim();
|
||||
var firstItem = parseJobTypeItem(state.jobTypes[0]);
|
||||
state.selectedJobType = firstItem.jobType;
|
||||
state.activeTopTab = topTabKeyForJobType(state.selectedJobType);
|
||||
}
|
||||
|
||||
@@ -749,7 +770,8 @@ templ Plugin(page string) {
|
||||
|
||||
if (state.activeTopTab === 'overview') {
|
||||
if (!state.selectedJobType && Array.isArray(state.jobTypes) && state.jobTypes.length > 0) {
|
||||
state.selectedJobType = String(state.jobTypes[0] || '').trim();
|
||||
var item = parseJobTypeItem(state.jobTypes[0]);
|
||||
state.selectedJobType = item.jobType;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -758,7 +780,8 @@ templ Plugin(page string) {
|
||||
topTabJobType = state.selectedJobType;
|
||||
}
|
||||
if (!topTabJobType && Array.isArray(state.jobTypes) && state.jobTypes.length > 0) {
|
||||
topTabJobType = String(state.jobTypes[0] || '').trim();
|
||||
var item = parseJobTypeItem(state.jobTypes[0]);
|
||||
topTabJobType = item.jobType;
|
||||
}
|
||||
if (!topTabJobType) {
|
||||
state.activeTopTab = 'overview';
|
||||
@@ -784,14 +807,17 @@ templ Plugin(page string) {
|
||||
|
||||
var jobTypes = Array.isArray(state.jobTypes) ? state.jobTypes : [];
|
||||
for (var i = 0; i < jobTypes.length; i++) {
|
||||
var jobType = String(jobTypes[i] || '').trim();
|
||||
var item = parseJobTypeItem(jobTypes[i]);
|
||||
var jobType = item.jobType;
|
||||
var displayName = item.displayName;
|
||||
|
||||
if (!jobType) {
|
||||
continue;
|
||||
}
|
||||
html += '' +
|
||||
'<li class="nav-item">' +
|
||||
'<button type="button" class="nav-link" data-plugin-top-tab="' + escapeHtml(topTabKeyForJobType(jobType)) + '">' +
|
||||
escapeHtml(jobType) +
|
||||
escapeHtml(displayName) +
|
||||
'</button>' +
|
||||
'</li>';
|
||||
}
|
||||
@@ -1267,7 +1293,8 @@ templ Plugin(page string) {
|
||||
var jobTypes = [];
|
||||
|
||||
for (var k = 0; k < known.length; k++) {
|
||||
var knownType = String(known[k] || '').trim();
|
||||
var item = parseJobTypeItem(known[k]);
|
||||
var knownType = item && item.jobType ? String(item.jobType).trim() : '';
|
||||
if (!knownType || seen[knownType]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -96,6 +96,7 @@ message JobTypeCapability {
|
||||
int32 max_execution_concurrency = 5;
|
||||
string display_name = 6;
|
||||
string description = 7;
|
||||
int32 weight = 8;
|
||||
}
|
||||
|
||||
message RequestConfigSchema {
|
||||
|
||||
@@ -1209,6 +1209,7 @@ type JobTypeCapability struct {
|
||||
MaxExecutionConcurrency int32 `protobuf:"varint,5,opt,name=max_execution_concurrency,json=maxExecutionConcurrency,proto3" json:"max_execution_concurrency,omitempty"`
|
||||
DisplayName string `protobuf:"bytes,6,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"`
|
||||
Description string `protobuf:"bytes,7,opt,name=description,proto3" json:"description,omitempty"`
|
||||
Weight int32 `protobuf:"varint,8,opt,name=weight,proto3" json:"weight,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -1292,6 +1293,13 @@ func (x *JobTypeCapability) GetDescription() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *JobTypeCapability) GetWeight() int32 {
|
||||
if x != nil {
|
||||
return x.Weight
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type RequestConfigSchema struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
JobType string `protobuf:"bytes,1,opt,name=job_type,json=jobType,proto3" json:"job_type,omitempty"`
|
||||
@@ -3948,7 +3956,7 @@ const file_plugin_proto_rawDesc = "" +
|
||||
"\bjob_type\x18\x03 \x01(\tR\ajobType\x12&\n" +
|
||||
"\x05state\x18\x04 \x01(\x0e2\x10.plugin.JobStateR\x05state\x12)\n" +
|
||||
"\x10progress_percent\x18\x05 \x01(\x01R\x0fprogressPercent\x12\x14\n" +
|
||||
"\x05stage\x18\x06 \x01(\tR\x05stage\"\xab\x02\n" +
|
||||
"\x05stage\x18\x06 \x01(\tR\x05stage\"\xc3\x02\n" +
|
||||
"\x11JobTypeCapability\x12\x19\n" +
|
||||
"\bjob_type\x18\x01 \x01(\tR\ajobType\x12\x1d\n" +
|
||||
"\n" +
|
||||
@@ -3958,7 +3966,8 @@ const file_plugin_proto_rawDesc = "" +
|
||||
"\x19max_detection_concurrency\x18\x04 \x01(\x05R\x17maxDetectionConcurrency\x12:\n" +
|
||||
"\x19max_execution_concurrency\x18\x05 \x01(\x05R\x17maxExecutionConcurrency\x12!\n" +
|
||||
"\fdisplay_name\x18\x06 \x01(\tR\vdisplayName\x12 \n" +
|
||||
"\vdescription\x18\a \x01(\tR\vdescription\"U\n" +
|
||||
"\vdescription\x18\a \x01(\tR\vdescription\x12\x16\n" +
|
||||
"\x06weight\x18\b \x01(\x05R\x06weight\"U\n" +
|
||||
"\x13RequestConfigSchema\x12\x19\n" +
|
||||
"\bjob_type\x18\x01 \x01(\tR\ajobType\x12#\n" +
|
||||
"\rforce_refresh\x18\x02 \x01(\bR\fforceRefresh\"\xda\x01\n" +
|
||||
|
||||
Reference in New Issue
Block a user