Init commit of jobs output/error message peek project.

This commit is contained in:
Lei Chen 2026-05-09 16:57:54 +08:00
parent c772d7e601
commit d2b47e3985
38 changed files with 2267 additions and 21 deletions

View file

@ -31,6 +31,8 @@ Use 'xce jobs <action>' to submit/kill/stop/resume/checkpoint/restart jobs.`,
cmd.AddCommand(NewResumeJobsCmd(configManager))
cmd.AddCommand(NewCheckPointJobsCmd(configManager))
cmd.AddCommand(NewRestartJobsCmd(configManager))
// Add peek command to view job output
cmd.AddCommand(NewPeekJobsCmd(configManager))
return cmd
}

View file

@ -0,0 +1,153 @@
package jobs
import (
"fmt"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"xcecli/common/format"
"xcecli/common/request"
"xcecli/config"
"github.com/spf13/cobra"
)
// PeekQuerier implements the jobs peek command.
type PeekQuerier struct {
configManager *config.ConfigManager
lines int
stderr bool
noFollow bool
}
// NewPeekQuerier creates a new PeekQuerier.
func NewPeekQuerier(configManager *config.ConfigManager) *PeekQuerier {
return &PeekQuerier{
configManager: configManager,
lines: 100, // default show last 100 lines
}
}
// NewPeekJobsCmd creates the xce jobs peek command.
func NewPeekJobsCmd(configManager *config.ConfigManager) *cobra.Command {
querier := NewPeekQuerier(configManager)
cmd := &cobra.Command{
Use: "peek",
Short: "Peek at job output",
Long: `Peek at job output in real-time.
Examples:
xce jobs peek jobid # stream stdout output
xce jobs peek jobid -n 50 # initial show last 50 lines
xce jobs peek jobid --err # view stderr output
xce jobs peek jobid --no-follow # one-shot snapshot, no streaming`,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
fmt.Println("error: job ID is required")
fmt.Println()
cmd.Help()
return nil
}
jobID := args[0]
if err := querier.Run(jobID); err != nil {
fmt.Println(strings.ToLower(err.Error()))
return nil
}
return nil
},
}
flags := cmd.Flags()
flags.IntVarP(&querier.lines, "lines", "n", 100, "number of initial lines to show")
flags.BoolVar(&querier.stderr, "err", false, "view stderr output instead of stdout")
flags.BoolVar(&querier.noFollow, "no-follow", false, "one-shot snapshot, do not follow new output")
return cmd
}
// Run executes the peek command.
func (q *PeekQuerier) Run(jobID string) error {
serverInfo, err := q.configManager.GetServerInfo()
if err != nil {
return err
}
// Convert job ID to API format
apiJobID, err := format.ToAPIJobID(jobID)
if err != nil {
return fmt.Errorf("invalid job ID %q: %w", jobID, err)
}
apiClient := request.NewAPIClient(serverInfo.URL)
apiClient.SetRootCAs(serverInfo.CACert)
// Determine stream type
streamType := "stdout"
if q.stderr {
streamType = "stderr"
}
// Setup signal handling for graceful exit
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
offset := int64(0)
lines := q.lines
for {
// Check for interrupt
select {
case <-sigChan:
fmt.Println()
return nil
default:
}
// Build query params
params := map[string]string{
"type": streamType,
}
if offset == 0 {
params["lines"] = strconv.Itoa(lines)
} else {
params["offset"] = strconv.FormatInt(offset, 10)
}
// Fetch output
resp, err := apiClient.GetJobOutput(serverInfo.Token, serverInfo.CACert, apiJobID, params)
if err != nil {
return err
}
// Check for error in response
if resp.Code != 200 {
return fmt.Errorf("job <%s>: %s", jobID, resp.Msg)
}
// Print new content
if resp.Data.Content != "" {
fmt.Print(resp.Data.Content)
}
// Update offset for next poll
offset = resp.Data.Offset
// If done, exit
if resp.Data.Done {
return nil
}
// If one-shot mode, exit after first fetch
if q.noFollow {
return nil
}
// Poll interval
time.Sleep(500 * time.Millisecond)
}
}

View file

@ -0,0 +1,80 @@
package jobs
import (
"strings"
"testing"
"xcecli/common/format"
)
func TestPeekJobIDParsing(t *testing.T) {
tests := []struct {
input string
wantAPI string
}{
{"12345", "12345"},
{"12345[0]", "12345[0]"},
{"12345[2]", "12345[2]"},
{"plain_id", "plain_id"}, // ToAPIJobID passes through non-numeric strings
}
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
apiID, err := format.ToAPIJobID(tc.input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if apiID != tc.wantAPI {
t.Errorf("apiID = %q, want %q", apiID, tc.wantAPI)
}
})
}
}
func TestPeekDefaults(t *testing.T) {
q := NewPeekQuerier(nil)
if q.lines != 100 {
t.Errorf("default lines = %d, want 100", q.lines)
}
if q.stderr {
t.Error("default stderr should be false")
}
if q.noFollow {
t.Error("default noFollow should be false")
}
}
func TestPeekStreamType(t *testing.T) {
q := NewPeekQuerier(nil)
streamType := "stdout"
if q.stderr {
streamType = "stderr"
}
if streamType != "stdout" {
t.Errorf("default stream type = %q, want stdout", streamType)
}
q2 := NewPeekQuerier(nil)
q2.stderr = true
streamType = "stdout"
if q2.stderr {
streamType = "stderr"
}
if streamType != "stderr" {
t.Errorf("stderr stream type = %q, want stderr", streamType)
}
}
func TestPeekCommand_NoJobID(t *testing.T) {
q := NewPeekQuerier(nil)
err := q.Run("")
if err == nil {
t.Error("expected error for empty job ID")
}
// Should fail on job ID parsing, not on config
if !strings.Contains(strings.ToLower(err.Error()), "invalid") {
t.Logf("error message (may vary): %v", err)
}
}

View file

@ -170,6 +170,45 @@ func (c *APIClient) SubmitJob(token string, req *models.JobSubmitRequest) (*mode
return &resp, nil
}
// GetJobOutput retrieves job output from the API server.
func (c *APIClient) GetJobOutput(token, certPath, jobID string, params map[string]string) (*models.JobOutputResponse, error) {
var resp models.JobOutputResponse
baseURL := c.BaseURL
if strings.HasPrefix(baseURL, "https://") {
c.Client.SetTLSClientConfig(&tls.Config{
RootCAs: loadRootCAs(certPath),
})
}
if idx := strings.Index(baseURL, "/xce/v1"); idx != -1 {
baseURL = baseURL[:idx]
}
url := baseURL + "/xce/v1/jobs/" + jobID + "/output"
req := c.Client.R().
SetHeader("Authorization", token).
SetResult(&resp)
for k, v := range params {
req.SetQueryParam(k, v)
}
httpResp, err := req.Get(url)
if err != nil {
errMsg := ExtractErrorMessage(err)
return nil, fmt.Errorf("get job output request failed: [%s]", errMsg)
}
if httpResp.StatusCode() != http.StatusOK {
logger.Info("get job output failed", zap.Any("resp", resp), zap.Int("status", httpResp.StatusCode()))
if resp.Msg != "" {
return &resp, fmt.Errorf("get job output failed: %s", resp.Msg)
}
return &resp, fmt.Errorf("get job output failed: status %d", httpResp.StatusCode())
}
return &resp, nil
}
// extractErrorMessage extracts a user-friendly error message from various error types
func ExtractErrorMessage(err error) string {
if err == nil {

View file

@ -515,3 +515,18 @@ func (e *ExitStatusValue) UnmarshalJSON(data []byte) error {
*e = ExitStatusValue(s)
return nil
}
// JobOutputResponse represents the response from the job output API.
type JobOutputResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data JobOutputData `json:"data"`
}
// JobOutputData represents the payload in a job output response.
type JobOutputData struct {
Content string `json:"content"`
Offset int64 `json:"offset"`
JobStatus string `json:"job_status"`
Done bool `json:"done"`
}