feat(service): add pagination to GetJobs endpoint
GetJobs now accepts page/page_size query parameters and returns JobListResponse instead of raw array. Uses in-memory pagination matching GetJobHistory pattern. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -46,16 +46,30 @@ func (h *JobHandler) SubmitJob(c *gin.Context) {
|
|||||||
server.Created(c, resp)
|
server.Created(c, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetJobs handles GET /api/v1/jobs.
|
// GetJobs handles GET /api/v1/jobs with pagination.
|
||||||
func (h *JobHandler) GetJobs(c *gin.Context) {
|
func (h *JobHandler) GetJobs(c *gin.Context) {
|
||||||
jobs, err := h.jobSvc.GetJobs(c.Request.Context())
|
var query model.JobListQuery
|
||||||
|
if err := c.ShouldBindQuery(&query); err != nil {
|
||||||
|
h.logger.Warn("bad request", zap.String("method", "GetJobs"), zap.String("error", "invalid query params"))
|
||||||
|
server.BadRequest(c, "invalid query params")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if query.Page < 1 {
|
||||||
|
query.Page = 1
|
||||||
|
}
|
||||||
|
if query.PageSize < 1 {
|
||||||
|
query.PageSize = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.jobSvc.GetJobs(c.Request.Context(), &query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("handler error", zap.String("method", "GetJobs"), zap.Int("status", http.StatusInternalServerError), zap.Error(err))
|
h.logger.Error("handler error", zap.String("method", "GetJobs"), zap.Int("status", http.StatusInternalServerError), zap.Error(err))
|
||||||
server.InternalError(c, err.Error())
|
server.InternalError(c, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
server.OK(c, jobs)
|
server.OK(c, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetJob handles GET /api/v1/jobs/:id.
|
// GetJob handles GET /api/v1/jobs/:id.
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ func TestGetJobs_Success(t *testing.T) {
|
|||||||
|
|
||||||
router := setupJobRouter(handler)
|
router := setupJobRouter(handler)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/jobs", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/jobs?page=1&page_size=10", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
router.ServeHTTP(w, req)
|
router.ServeHTTP(w, req)
|
||||||
@@ -202,6 +202,93 @@ func TestGetJobs_Success(t *testing.T) {
|
|||||||
if !resp["success"].(bool) {
|
if !resp["success"].(bool) {
|
||||||
t.Fatal("expected success=true")
|
t.Fatal("expected success=true")
|
||||||
}
|
}
|
||||||
|
data := resp["data"].(map[string]interface{})
|
||||||
|
jobs := data["jobs"].([]interface{})
|
||||||
|
if len(jobs) != 2 {
|
||||||
|
t.Fatalf("expected 2 jobs, got %d", len(jobs))
|
||||||
|
}
|
||||||
|
if int(data["total"].(float64)) != 2 {
|
||||||
|
t.Errorf("expected total=2, got %v", data["total"])
|
||||||
|
}
|
||||||
|
if int(data["page"].(float64)) != 1 {
|
||||||
|
t.Errorf("expected page=1, got %v", data["page"])
|
||||||
|
}
|
||||||
|
if int(data["page_size"].(float64)) != 10 {
|
||||||
|
t.Errorf("expected page_size=10, got %v", data["page_size"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetJobs_Pagination(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/slurm/v0.0.40/jobs", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(slurm.OpenapiJobInfoResp{
|
||||||
|
Jobs: []slurm.JobInfo{
|
||||||
|
{JobID: slurm.Ptr(int32(1)), Name: slurm.Ptr("job1")},
|
||||||
|
{JobID: slurm.Ptr(int32(2)), Name: slurm.Ptr("job2")},
|
||||||
|
{JobID: slurm.Ptr(int32(3)), Name: slurm.Ptr("job3")},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
srv, handler := setupJobHandler(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupJobRouter(handler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/jobs?page=2&page_size=1", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
data := resp["data"].(map[string]interface{})
|
||||||
|
jobs := data["jobs"].([]interface{})
|
||||||
|
if len(jobs) != 1 {
|
||||||
|
t.Fatalf("expected 1 job on page 2, got %d", len(jobs))
|
||||||
|
}
|
||||||
|
if int(data["total"].(float64)) != 3 {
|
||||||
|
t.Errorf("expected total=3, got %v", data["total"])
|
||||||
|
}
|
||||||
|
jobData := jobs[0].(map[string]interface{})
|
||||||
|
if int(jobData["job_id"].(float64)) != 2 {
|
||||||
|
t.Errorf("expected job_id=2 on page 2, got %v", jobData["job_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetJobs_DefaultPagination(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/slurm/v0.0.40/jobs", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(slurm.OpenapiJobInfoResp{Jobs: []slurm.JobInfo{}})
|
||||||
|
})
|
||||||
|
srv, handler := setupJobHandler(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupJobRouter(handler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/jobs", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
data := resp["data"].(map[string]interface{})
|
||||||
|
if int(data["page"].(float64)) != 1 {
|
||||||
|
t.Errorf("expected default page=1, got %v", data["page"])
|
||||||
|
}
|
||||||
|
if int(data["page_size"].(float64)) != 20 {
|
||||||
|
t.Errorf("expected default page_size=20, got %v", data["page_size"])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetJob_Success(t *testing.T) {
|
func TestGetJob_Success(t *testing.T) {
|
||||||
|
|||||||
@@ -66,6 +66,12 @@ type JobListResponse struct {
|
|||||||
PageSize int `json:"page_size"` // 每页条数
|
PageSize int `json:"page_size"` // 每页条数
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JobListQuery contains pagination parameters for active job listing.
|
||||||
|
type JobListQuery struct {
|
||||||
|
Page int `form:"page,default=1" json:"page,omitempty"` // 页码 (从 1 开始)
|
||||||
|
PageSize int `form:"page_size,default=20" json:"page_size,omitempty"` // 每页条数
|
||||||
|
}
|
||||||
|
|
||||||
// JobHistoryQuery contains query parameters for job history.
|
// JobHistoryQuery contains query parameters for job history.
|
||||||
type JobHistoryQuery struct {
|
type JobHistoryQuery struct {
|
||||||
Users string `form:"users" json:"users,omitempty"` // 按用户名过滤 (逗号分隔)
|
Users string `form:"users" json:"users,omitempty"` // 按用户名过滤 (逗号分隔)
|
||||||
|
|||||||
@@ -82,8 +82,8 @@ func (s *JobService) SubmitJob(ctx context.Context, req *model.SubmitJobRequest)
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetJobs lists all current jobs from Slurm.
|
// GetJobs lists all current jobs from Slurm with in-memory pagination.
|
||||||
func (s *JobService) GetJobs(ctx context.Context) ([]model.JobResponse, error) {
|
func (s *JobService) GetJobs(ctx context.Context, query *model.JobListQuery) (*model.JobListResponse, error) {
|
||||||
s.logger.Debug("slurm API request",
|
s.logger.Debug("slurm API request",
|
||||||
zap.String("operation", "GetJobs"),
|
zap.String("operation", "GetJobs"),
|
||||||
)
|
)
|
||||||
@@ -109,11 +109,36 @@ func (s *JobService) GetJobs(ctx context.Context) ([]model.JobResponse, error) {
|
|||||||
zap.Any("body", result),
|
zap.Any("body", result),
|
||||||
)
|
)
|
||||||
|
|
||||||
jobs := make([]model.JobResponse, 0, len(result.Jobs))
|
allJobs := make([]model.JobResponse, 0, len(result.Jobs))
|
||||||
for i := range result.Jobs {
|
for i := range result.Jobs {
|
||||||
jobs = append(jobs, mapJobInfo(&result.Jobs[i]))
|
allJobs = append(allJobs, mapJobInfo(&result.Jobs[i]))
|
||||||
}
|
}
|
||||||
return jobs, nil
|
|
||||||
|
total := len(allJobs)
|
||||||
|
page := query.Page
|
||||||
|
pageSize := query.PageSize
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
startIdx := (page - 1) * pageSize
|
||||||
|
end := startIdx + pageSize
|
||||||
|
if startIdx > total {
|
||||||
|
startIdx = total
|
||||||
|
}
|
||||||
|
if end > total {
|
||||||
|
end = total
|
||||||
|
}
|
||||||
|
|
||||||
|
return &model.JobListResponse{
|
||||||
|
Jobs: allJobs[startIdx:end],
|
||||||
|
Total: total,
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetJob retrieves a single job by ID. If the job is not found in the active
|
// GetJob retrieves a single job by ID. If the job is not found in the active
|
||||||
|
|||||||
@@ -149,14 +149,17 @@ func TestGetJobs(t *testing.T) {
|
|||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
svc := NewJobService(client, zap.NewNop())
|
svc := NewJobService(client, zap.NewNop())
|
||||||
jobs, err := svc.GetJobs(context.Background())
|
result, err := svc.GetJobs(context.Background(), &model.JobListQuery{Page: 1, PageSize: 20})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetJobs: %v", err)
|
t.Fatalf("GetJobs: %v", err)
|
||||||
}
|
}
|
||||||
if len(jobs) != 1 {
|
if result.Total != 1 {
|
||||||
t.Fatalf("expected 1 job, got %d", len(jobs))
|
t.Fatalf("expected total 1, got %d", result.Total)
|
||||||
}
|
}
|
||||||
j := jobs[0]
|
if len(result.Jobs) != 1 {
|
||||||
|
t.Fatalf("expected 1 job, got %d", len(result.Jobs))
|
||||||
|
}
|
||||||
|
j := result.Jobs[0]
|
||||||
if j.JobID != 100 {
|
if j.JobID != 100 {
|
||||||
t.Errorf("expected JobID 100, got %d", j.JobID)
|
t.Errorf("expected JobID 100, got %d", j.JobID)
|
||||||
}
|
}
|
||||||
@@ -175,6 +178,12 @@ func TestGetJobs(t *testing.T) {
|
|||||||
if j.Nodes != "node01" {
|
if j.Nodes != "node01" {
|
||||||
t.Errorf("expected Nodes node01, got %s", j.Nodes)
|
t.Errorf("expected Nodes node01, got %s", j.Nodes)
|
||||||
}
|
}
|
||||||
|
if result.Page != 1 {
|
||||||
|
t.Errorf("expected Page 1, got %d", result.Page)
|
||||||
|
}
|
||||||
|
if result.PageSize != 20 {
|
||||||
|
t.Errorf("expected PageSize 20, got %d", result.PageSize)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetJob(t *testing.T) {
|
func TestGetJob(t *testing.T) {
|
||||||
@@ -621,7 +630,7 @@ func TestJobService_GetJobs_ErrorLog(t *testing.T) {
|
|||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
svc, recorded := newJobServiceWithObserver(srv)
|
svc, recorded := newJobServiceWithObserver(srv)
|
||||||
_, err := svc.GetJobs(context.Background())
|
_, err := svc.GetJobs(context.Background(), &model.JobListQuery{Page: 1, PageSize: 20})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user