myxce/src/frontend/xcecli/common/output/page_print_jobs.go
lei.chen 5753123798 xcecli: fix job list "cannot unmarshal pending_reason" + verbose fields
apiserver now returns pending_reason as an object ({main_reason,
main_reason_name, host_reasons, res_reasons}) since the PEND-reason work.
xcecli's QueryJob.PendingReason was a plain string, so every job list
failed with "cannot unmarshal object into string". Two fixes:

- xcecli/models/jobs.go: PendingReason is now a string-backed type with a
  custom UnmarshalJSON that accepts null / empty string / plain string
  (older API) / the object form (current API, rendered as readable text —
  LSF bjobs -p style: "match_fail; host t1: match_fail; ..."). Unknown
  objects fall back to the raw bytes instead of erroring, so a job list
  can never fail on an unexpected pending_reason shape.
- apiserver/models/jobs.go: JobAdditionalFields gains PendingReason so the
  fields= query (xcecli `jobs list -l`) no longer rejects pending_reason
  with "unsupported query field".
- xcecli/common/output/page_print_jobs.go: adapt the verbose printer to the
  new type.

Tests: xcecli/models/jobs_test.go — 6 cases (null / empty / plain string /
object / within QueryJob / unknown object). xcecli 9 packages + apiserver
models/controllers/utils all pass.

Verified on the test cluster: `xcecli jobs list` (no unmarshal error) and
`xcecli jobs list -l` show "PENDING REASONS: host_unavail; host t1:
host_unavail; host t2: match_fail; host t3: match_fail".

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-15 10:46:47 +00:00

445 lines
15 KiB
Go

package output
import (
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"text/tabwriter"
jobfmt "xcecli/common/format"
"xcecli/models"
)
// JobsPagePrinter is the printer for jobs
type JobsPagePrinter struct {
*BasePagePrinter
}
// NewJobsPagePrinter creates a new jobs page printer
func NewJobsPagePrinter(opt ...BasePageOption) *JobsPagePrinter {
return &JobsPagePrinter{
BasePagePrinter: newBasePagePrinter(opt...),
}
}
// PrepareForPrinting prepares for printing, initializes field formats and column widths
func (p *JobsPagePrinter) PrepareForPrinting(firstPage interface{}) {
jobs, ok := firstPage.(*models.JobsResponse)
if !ok {
fmt.Println("error: wrong type of data passed to JobsPagePrinter")
return
}
// set the field formats
p.fieldFormats = p.prepareFieldFormats()
// initialize the column widths
p.initColumnWidths(jobs)
// prepare the headers
p.prepareHeaders()
}
// prepare the field formats
func (p *JobsPagePrinter) prepareFieldFormats() []*fieldFormat {
var fieldFormats []*fieldFormat
if p.fields == "" {
// default fields
defaultFields := []string{
models.HEADER_JOB_JOBID,
models.HEADER_JOB_USER,
models.HEADER_JOB_STATUS,
models.HEADER_JOB_QUEUE,
models.HEADER_JOB_FROM_HOST,
models.HEADER_JOB_EXEC_HOST,
models.HEADER_JOB_NAME,
models.HEADER_JOB_SUBMIT_TIME,
}
for _, field := range defaultFields {
fieldFormats = append(fieldFormats, &fieldFormat{name: field, align: "-"})
}
} else {
// parse user defined fields
for _, field := range strings.Split(p.fields, ",") {
fieldFormats = append(fieldFormats, parseFieldFormat(field))
}
}
return fieldFormats
}
// initialize the column widths
func (p *JobsPagePrinter) initColumnWidths(jobs *models.JobsResponse) {
// initialize the column widths to the length of the headers
for _, format := range p.fieldFormats {
p.columnWidths[format.name] = len(format.name)
if format.width > 0 {
p.columnWidths[format.name] = format.width
}
}
// calculate the maximum width of the first page data
for _, job := range jobs.Data.Rows {
for _, format := range p.fieldFormats {
width := 0
switch format.name {
case models.HEADER_JOB_JOBID:
width = len(jobfmt.FormatJobID(job.UniqueJobID))
case models.HEADER_JOB_USER:
width = len(job.SubmitUser)
case models.HEADER_JOB_STATUS:
width = len(job.JobStatus)
case models.HEADER_JOB_QUEUE:
width = len(job.QueueName)
case models.HEADER_JOB_FROM_HOST:
width = len(job.FromHost)
case models.HEADER_JOB_EXEC_HOST:
continue
case models.HEADER_JOB_NAME:
width = len(job.JobName)
case models.HEADER_JOB_SUBMIT_TIME:
width = len(fmtUnixTime(job.SubmitTime))
case models.HEADER_JOB_CORE_LIMIT:
width = len(fmt.Sprintf("%f", job.CoreLimit))
case models.HEADER_JOB_MEM_LIMIT:
width = len(fmt.Sprintf("%f", job.MemLimit))
}
if !p.wideFormat && width > 15 {
width = 15
}
if width > p.columnWidths[format.name] && format.width == 0 {
p.columnWidths[format.name] = width
}
}
}
}
// PrintPage prints a page of data
func (p *JobsPagePrinter) PrintPage(page interface{}) {
jobs, ok := page.(*models.JobsResponse)
if !ok {
fmt.Println("error: wrong type of data passed to JobsPagePrinter")
return
}
p.pageCount++
if p.verbose {
printJobsVerboseOutput(jobs, p.jobStatusFilter)
return
}
if p.formatJson {
p.printJsonPage(jobs)
return
} else {
// print the header (if needed)
p.printHeader()
// print the data rows
rows := p.formatRows(jobs, true)
for _, row := range rows {
fmt.Fprintln(p.w, strings.Join(row, "\t"))
}
p.w.Flush()
}
}
func (p *JobsPagePrinter) printJsonPage(jobs *models.JobsResponse) {
// if the format is JSON, directly convert the current page to JSON
headers := make([]string, len(p.fieldFormats))
for i, format := range p.fieldFormats {
headers[i] = format.name
}
rows := p.formatRows(jobs, false)
p.PrintJsonPageGeneric(rows, headers)
}
// format the data rows
func (p *JobsPagePrinter) formatRows(jobs *models.JobsResponse, truncatFlag bool) [][]string {
var rows [][]string
if p.wideFormat {
truncatFlag = false
}
for _, job := range jobs.Data.Rows {
mainRow := make([]string, 0, len(p.fieldFormats))
var extraRows [][]string
for _, format := range p.fieldFormats {
width := p.columnWidths[format.name]
align := format.align
switch format.name {
case models.HEADER_JOB_JOBID:
mainRow = appendOrDefault(mainRow,
fmt.Sprintf("%"+align+"*s", 20, jobfmt.FormatJobID(job.UniqueJobID)), "-")
case models.HEADER_JOB_EXEC_HOST:
var execHosts models.ExecHosts
if err := json.Unmarshal([]byte(job.ExecHosts.String()), &execHosts); err == nil && len(execHosts.HostSlots) > 0 && execHosts.HostSlots[0].HostName != "" {
firstHost := fmt.Sprintf("%d*%s", execHosts.HostSlots[0].NumSlots, execHosts.HostSlots[0].HostName)
mainRow = appendOrDefault(mainRow,
FormatStringField(firstHost, width, truncatFlag, false), "-")
if len(execHosts.HostSlots) > 1 {
for _, slot := range execHosts.HostSlots[1:] {
newLine := make([]string, len(p.fieldFormats))
for i, f := range p.fieldFormats {
if f.name == "EXEC_HOST" {
continue
}
newLine[i] = strings.Repeat(" ", p.columnWidths[f.name])
}
hostText := fmt.Sprintf("%d*%s", slot.NumSlots, slot.HostName)
newLine[getFieldFormatIndex(p.fieldFormats, "EXEC_HOST")] = FormatStringField(hostText, width, truncatFlag, false)
extraRows = append(extraRows, append([]string{}, newLine...))
}
}
} else {
mainRow = appendOrDefault(mainRow, fmt.Sprintf("%"+align+"*s", width, "-"), "-")
}
case models.HEADER_JOB_SUBMIT_TIME:
mainRow = appendOrDefault(mainRow, FormatStringField(fmtUnixTime(job.SubmitTime), width, truncatFlag, true), "-")
// normal fields
case models.HEADER_JOB_NAME:
mainRow = appendOrDefault(mainRow, FormatStringField(job.JobName, width, truncatFlag, true), "-")
case models.HEADER_JOB_USER:
mainRow = appendOrDefault(mainRow, FormatStringField(job.SubmitUser, width, truncatFlag, true), "-")
case models.HEADER_JOB_STATUS:
mainRow = appendOrDefault(mainRow, FormatStringField(job.JobStatus, width, truncatFlag, true), "-")
case models.HEADER_JOB_QUEUE:
mainRow = appendOrDefault(mainRow, FormatStringField(job.QueueName, width, truncatFlag, true), "-")
case models.HEADER_JOB_FROM_HOST:
mainRow = appendOrDefault(mainRow, FormatStringField(job.FromHost, width, truncatFlag, true), "-")
case models.HEADER_JOB_COMMAND:
mainRow = appendOrDefault(mainRow, FormatStringField(job.Command, width, truncatFlag, true), "-")
case models.HEADER_JOB_PROJECT_NAME:
mainRow = appendOrDefault(mainRow, FormatStringField(job.ProjectName, width, truncatFlag, true), "-")
case models.HEADER_JOB_START_TIME:
mainRow = appendOrDefault(mainRow, FormatStringField(fmtUnixTime(job.StartTime), width, truncatFlag, true), "-")
case models.HEADER_JOB_END_TIME:
mainRow = appendOrDefault(mainRow, FormatStringField(fmtUnixTime(job.EndTime), width, truncatFlag, true), "-")
case models.HEADER_JOB_CWD:
mainRow = appendOrDefault(mainRow, FormatStringField(job.CWD, width, truncatFlag, true), "-")
case models.HEADER_JOB_LAUNCH_TYPE:
mainRow = appendOrDefault(mainRow, FormatStringField(job.LaunchType, width, truncatFlag, true), "-")
case models.HEADER_JOB_INTERACTIVE_URL:
mainRow = appendOrDefault(mainRow, FormatStringField(job.InteractiveJobURL, width, truncatFlag, true), "-")
// not string field
case models.HEADER_JOB_NUM_PROCESSORS:
mainRow = appendOrDefault(mainRow, fmt.Sprintf("%"+align+"*d", width, job.NumProcessors), "-")
case models.HEADER_JOB_CORE_LIMIT:
mainRow = appendOrDefault(mainRow, fmt.Sprintf("%"+align+"*s", width, formatSizeString(job.CoreLimit)), "-")
case models.HEADER_JOB_MEM_LIMIT:
mainRow = appendOrDefault(mainRow, fmt.Sprintf("%"+align+"*s", width, formatSizeString(job.MemLimit)), "-")
default:
mainRow = appendOrDefault(mainRow, fmt.Sprintf("%"+align+"*v", width, "-"), "-")
}
}
rows = append(rows, mainRow)
rows = append(rows, extraRows...)
}
return rows
}
func printJobsVerboseOutput(jobs *models.JobsResponse, filter []string) {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', 0)
defer w.Flush()
for jobIndex, job := range jobs.Data.Rows {
// if the filter is not empty, only show the jobs that match the filter
if len(filter) > 0 {
found := false
for _, status := range filter {
if job.JobStatus == status {
found = true
break
}
}
if !found {
continue
}
}
jobId := jobfmt.FormatJobID(job.UniqueJobID)
// (1) print the job baisc info when sending "jobs list -l" command
firstLine := "Job <" + jobId + ">, "
if job.JobName != "" {
firstLine = firstLine + "Job Name <" + job.JobName + ">, "
}
if job.SubmitUser != "" {
firstLine = firstLine + "User <" + job.SubmitUser + ">, "
}
if job.ProjectName != "" {
firstLine = firstLine + "Project <" + job.ProjectName + ">, "
}
if job.JobStatus != "" {
firstLine = firstLine + "Status <" + job.JobStatus + ">, "
}
if job.QueueName != "" {
firstLine = firstLine + "Queue <" + job.QueueName + ">, "
}
if job.JobPriority != 0 {
firstLine = firstLine + "Priority <" + strconv.Itoa(job.JobPriority) + ">, "
}
if job.LaunchType != "" {
firstLine = firstLine + "Launch Type <" + job.LaunchType + ">, "
}
if job.Command != "" {
firstLine = firstLine + "Command <" + job.Command + ">, "
}
if job.JobDescription != "" {
firstLine = firstLine + "Job Description <" + job.JobDescription + "> "
}
PrintWrappedLine(w, firstLine, 80, 18)
// (2) print the submit time info
submitLine := ""
if submitTime, ok := unixToTime(job.SubmitTime); ok {
submitLine = submitLine + fmt.Sprintf("%s %s %02d:%02d:%02d: ",
submitTime.Format("Jan"), submitTime.Format("02"),
submitTime.Hour(), submitTime.Minute(), submitTime.Second())
if job.FromHost != "" {
submitLine = submitLine + "Submitted from host <" + job.FromHost + ">, "
}
if job.CWD != "" {
submitLine = submitLine + "CWD <" + job.CWD + ">, "
}
if job.NumProcessors != 0 {
submitLine = submitLine + strconv.Itoa(job.NumProcessors) + " Task(s), "
}
if job.Resreq != "" {
submitLine = submitLine + "Requested Resources <" + job.Resreq + ">, "
}
}
PrintWrappedLine(w, submitLine, 80, 18)
// (3) print the pending reason for jobs waiting to be dispatched
if job.JobStatus == "PEND" || job.JobStatus == "PSUSP" {
if string(job.PendingReason) != "" {
fmt.Fprintf(w, " PENDING REASONS:\n")
fmt.Fprintf(w, " %s\n", string(job.PendingReason))
}
}
// (4) print the start time info — skip for jobs that haven't started yet
startLine := ""
if job.JobStatus == "PEND" || job.JobStatus == "PSUSP" {
goto printEndLine
}
if startTime, ok := unixToTime(job.StartTime); ok {
month := startTime.Format("Jan")
day := startTime.Format("02")
exec_hostname := ""
started_slots := 0
if len(job.ExecHosts.HostSlots) > 0 {
for _, host := range job.ExecHosts.HostSlots {
started_slots += host.NumSlots
if exec_hostname == "" {
exec_hostname = exec_hostname + fmt.Sprintf("<%d*%s>", host.NumSlots, host.HostName)
} else {
exec_hostname = exec_hostname + " " + fmt.Sprintf("<%d*%s>", host.NumSlots, host.HostName)
}
}
}
startLine = startLine + fmt.Sprintf("%s %s %02d:%02d:%02d: ",
month, day, startTime.Hour(), startTime.Minute(), startTime.Second())
if started_slots > 0 {
startSlotMsg := fmt.Sprintf("%d", started_slots)
startLine = startLine + "Started " + startSlotMsg + " Task(s) "
}
if exec_hostname != "" {
startLine = startLine + "on Host(s) " + exec_hostname + ", "
}
if job.ExecHome != "" {
startLine = startLine + "Execution Home <" + job.ExecHome + ">, "
}
if job.ExecCWD != "" {
startLine = startLine + "Execution CWD <" + job.ExecCWD + ">, "
}
if job.InteractiveJobURL != "" {
startLine = startLine + "Interactive Job URL <" + job.InteractiveJobURL + ">, "
}
//fmt.Fprintf(w, "%s %s %02d:%02d:%02d: Resource usage collected.\n",
// month, day, submitTime.Hour(), submitTime.Minute(), submitTime.Second())
//fmt.Fprintf(w, " MEM: Mbytes; SWAP: Mbytes; NTHREAD: \n")
pgids := make([]string, len(job.PGIDs))
pids := make([]string, len(job.PID))
for i, pgid := range job.PGIDs {
pgids[i] = fmt.Sprintf("%d", pgid)
}
for i, pid := range job.PID {
pids[i] = fmt.Sprintf("%d", pid)
}
if len(pgids) > 0 {
startLine = startLine + "PGID: " + strings.Join(pgids, ",") + ", "
} else if len(pids) > 0 {
// only show pids
startLine = startLine + "PIDs: " + strings.Join(pids, ",") + ", "
}
}
PrintWrappedLine(w, startLine, 80, 18)
printEndLine:
// (4) print end line
endLine := ""
if job.JobStatus == "DONE" || job.JobStatus == "EXIT" {
if endTime, ok := unixToTime(job.EndTime); ok {
endLine = endLine + fmt.Sprintf("%s %s %02d:%02d:%02d: ",
endTime.Format("Jan"), endTime.Format("02"),
endTime.Hour(), endTime.Minute(), endTime.Second())
}
if job.JobStatus == "EXIT" {
endLine = endLine + "Completed <" + strings.ToLower(job.JobStatus) + ">. "
} else {
endLine = endLine + "Done successfully. "
}
if job.CPUTime != 0 {
endLine = endLine + "The CPU time used is <" + strconv.Itoa(job.CPUTime) + "> seconds."
}
}
PrintWrappedLine(w, endLine, 80, 18)
fmt.Println("")
// print the scheduling parameters table
fmt.Printf("SCHEDULING PARAMETERS\n")
headers2 := append([]string{""}, models.JobLoadSchedulingHeaders...)
loadSched := []string{"loadSched", "-", "-", "-", "-", "-", "-", "-", "-", "-", "-", "-", "-"}
loadStop := []string{"loadStop", "-", "-", "-", "-", "-", "-", "-", "-", "-", "-", "-", "-"}
widths2 := make([]int, len(headers2))
for i := range headers2 {
widths2[i] = max(len(headers2[i]), len(loadSched[i]))
widths2[i] = max(widths2[i], len(loadStop[i]))
}
rowSched := make([]string, len(headers2))
rowStop := make([]string, len(headers2))
for i := range headers2 {
rowSched[i] = FormatStringField(loadSched[i], widths2[i], false, true)
rowStop[i] = FormatStringField(loadStop[i], widths2[i], false, true)
}
tp2 := TablePrinter{Headers: headers2, Rows: [][]string{rowSched, rowStop}, ColWidths: widths2}
tp2.Print()
fmt.Println("")
// print the resource requirement details
fmt.Printf("RESOURCE REQUIREMENT DETAILS:\n")
fmt.Fprintln(w, " Combined: select[type == local] order[r15s:pg]")
fmt.Fprintln(w, " Effective: select[type == local] order[r15s:pg]")
if jobIndex != len(jobs.Data.Rows)-1 {
fmt.Fprintln(w, "------------------------------------------------------------------------------")
}
}
}