Init commit of jobs output/error message peek project.
This commit is contained in:
parent
c772d7e601
commit
d2b47e3985
38 changed files with 2267 additions and 21 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
153
src/frontend/xcecli/cmd/jobs/peek.go
Normal file
153
src/frontend/xcecli/cmd/jobs/peek.go
Normal 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)
|
||||
}
|
||||
}
|
||||
80
src/frontend/xcecli/cmd/jobs/peek_test.go
Normal file
80
src/frontend/xcecli/cmd/jobs/peek_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue