Compare commits
22
Commits
9092278d26
...
a7c48dae84
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7c48dae84 | ||
|
|
6c19fed2f3 | ||
|
|
c197b9622b | ||
|
|
f5e021d652 | ||
|
|
d71cda3420 | ||
|
|
1ebf43355b | ||
|
|
13ce86b1ef | ||
|
|
9aea1ea710 | ||
|
|
e90904cedb | ||
|
|
166ca3092c | ||
|
|
f894e870ed | ||
|
|
db06e99967 | ||
|
|
0c7a282386 | ||
|
|
9a04874847 | ||
|
|
a5ce173b54 | ||
|
|
1356f91b9d | ||
|
|
01a1119d13 | ||
|
|
6a7bde4801 | ||
|
|
07ae8ad6cd | ||
|
|
c1d0665b42 | ||
|
|
29525c3fa9 | ||
|
|
1d591efeba |
@@ -1,2 +1,8 @@
|
|||||||
bin/
|
bin/
|
||||||
*.exe
|
*.exe
|
||||||
|
|
||||||
|
# Vue frontend
|
||||||
|
web/node_modules/
|
||||||
|
web/dist/
|
||||||
|
web/src/auto-imports.d.ts
|
||||||
|
web/src/components.d.ts
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gcy_hpc_server/internal/mockserver"
|
||||||
|
"gcy_hpc_server/internal/model"
|
||||||
|
"gcy_hpc_server/internal/slurm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
defaultPort := 8080
|
||||||
|
if v := os.Getenv("MOCK_PORT"); v != "" {
|
||||||
|
if p, err := strconv.Atoi(v); err == nil {
|
||||||
|
defaultPort = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
port := flag.Int("port", defaultPort, "listen port")
|
||||||
|
seed := flag.Bool("seed", true, "inject seed data")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
srv, err := mockserver.New(mockserver.WithPort(*port))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to create server: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *seed {
|
||||||
|
injectSeedData(srv)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := srv.Run(); err != nil {
|
||||||
|
log.Fatalf("server error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func injectSeedData(srv *mockserver.Server) {
|
||||||
|
client := srv.MockSlurmHTTPClient()
|
||||||
|
baseURL := srv.MockSlurmURL()
|
||||||
|
|
||||||
|
type jobDef struct {
|
||||||
|
name string
|
||||||
|
partition string
|
||||||
|
states []string
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs := []jobDef{
|
||||||
|
{"gpu-training-job", "gpu", nil},
|
||||||
|
{"data-processing", "normal", []string{"RUNNING"}},
|
||||||
|
{"benchmark-test", "normal", []string{"RUNNING"}},
|
||||||
|
{"model-evaluation", "gpu", []string{"RUNNING", "COMPLETED"}},
|
||||||
|
{"failed-script", "normal", []string{"RUNNING", "FAILED"}},
|
||||||
|
{"cancelled-job", "gpu", []string{"RUNNING", "CANCELLED"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
mockSlurm := srv.MockSlurm()
|
||||||
|
for _, j := range jobs {
|
||||||
|
id, err := submitJob(client, baseURL, j.name, j.partition)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("warning: failed to submit job %q: %v", j.name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, state := range j.states {
|
||||||
|
mockSlurm.SetJobState(id, state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appSvc := srv.ApplicationService()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
apps := []model.CreateApplicationRequest{
|
||||||
|
{
|
||||||
|
Name: "图像分类训练",
|
||||||
|
Description: "基于ResNet的图像分类模型训练",
|
||||||
|
ScriptTemplate: "#!/bin/bash\n#SBATCH --job-name={{.Name}}\necho 'Running {{.Name}}'",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "数据处理流水线",
|
||||||
|
Description: "ETL数据处理脚本",
|
||||||
|
ScriptTemplate: "#!/bin/bash\necho 'Processing data'",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, app := range apps {
|
||||||
|
if _, err := appSvc.CreateApplication(ctx, &app); err != nil {
|
||||||
|
log.Printf("warning: failed to create application %q: %v", app.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Seeded 6 jobs (1 PENDING, 2 RUNNING, 1 COMPLETED, 1 FAILED, 1 CANCELLED) and 2 applications")
|
||||||
|
}
|
||||||
|
|
||||||
|
func submitJob(client *http.Client, baseURL, name, partition string) (int32, error) {
|
||||||
|
body := fmt.Sprintf(
|
||||||
|
`{"script":"#!/bin/bash\necho %s","job":{"name":"%s","partition":"%s"}}`,
|
||||||
|
name, name, partition,
|
||||||
|
)
|
||||||
|
req, err := http.NewRequest("POST", baseURL+"/slurm/v0.0.40/job/submit", strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("do request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
return 0, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result slurm.OpenapiJobSubmitResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return 0, fmt.Errorf("decode response: %w", err)
|
||||||
|
}
|
||||||
|
if result.JobID == nil {
|
||||||
|
return 0, fmt.Errorf("no job_id in response")
|
||||||
|
}
|
||||||
|
return *result.JobID, nil
|
||||||
|
}
|
||||||
@@ -185,7 +185,7 @@ func setupFileTestRouter(t *testing.T) (*gin.Engine, *gorm.DB, *inMemoryStorage)
|
|||||||
uploadSvc := service.NewUploadService(memStore, blobStore, fileStore, uploadStore, cfg, db, zap.NewNop())
|
uploadSvc := service.NewUploadService(memStore, blobStore, fileStore, uploadStore, cfg, db, zap.NewNop())
|
||||||
_ = service.NewDownloadService(memStore, blobStore, fileStore, "files", zap.NewNop())
|
_ = service.NewDownloadService(memStore, blobStore, fileStore, "files", zap.NewNop())
|
||||||
folderSvc := service.NewFolderService(folderStore, fileStore, zap.NewNop())
|
folderSvc := service.NewFolderService(folderStore, fileStore, zap.NewNop())
|
||||||
fileSvc := service.NewFileService(memStore, blobStore, fileStore, "files", db, zap.NewNop())
|
fileSvc := service.NewFileService(memStore, blobStore, fileStore, folderStore, "files", db, zap.NewNop())
|
||||||
|
|
||||||
uploadH := handler.NewUploadHandler(uploadSvc, zap.NewNop())
|
uploadH := handler.NewUploadHandler(uploadSvc, zap.NewNop())
|
||||||
fileH := handler.NewFileHandler(fileSvc, zap.NewNop())
|
fileH := handler.NewFileHandler(fileSvc, zap.NewNop())
|
||||||
|
|||||||
@@ -31,11 +31,38 @@ type taskListData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type taskListItem struct {
|
type taskListItem struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
TaskName string `json:"task_name"`
|
TaskName string `json:"task_name"`
|
||||||
AppID int64 `json:"app_id"`
|
AppID int64 `json:"app_id"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
SlurmJobID *int32 `json:"slurm_job_id"`
|
SlurmJobID *int32 `json:"slurm_job_id"`
|
||||||
|
Partition string `json:"partition,omitempty"`
|
||||||
|
Cpus *int32 `json:"cpus,omitempty"`
|
||||||
|
MemoryPerNode *int64 `json:"memory_per_node,omitempty"`
|
||||||
|
MemoryPerCpu *int64 `json:"memory_per_cpu,omitempty"`
|
||||||
|
TimeLimit *int32 `json:"time_limit,omitempty"`
|
||||||
|
QOS *string `json:"qos,omitempty"`
|
||||||
|
JobName *string `json:"job_name,omitempty"`
|
||||||
|
Nodes *string `json:"nodes,omitempty"`
|
||||||
|
Tasks *int32 `json:"tasks,omitempty"`
|
||||||
|
CpusPerTask *int32 `json:"cpus_per_task,omitempty"`
|
||||||
|
Constraints *string `json:"constraints,omitempty"`
|
||||||
|
Reservation *string `json:"reservation,omitempty"`
|
||||||
|
Account *string `json:"account,omitempty"`
|
||||||
|
Nice *int32 `json:"nice,omitempty"`
|
||||||
|
MailType *string `json:"mail_type,omitempty"`
|
||||||
|
MailUser *string `json:"mail_user,omitempty"`
|
||||||
|
StandardOutput *string `json:"standard_output,omitempty"`
|
||||||
|
StandardError *string `json:"standard_error,omitempty"`
|
||||||
|
StandardInput *string `json:"standard_input,omitempty"`
|
||||||
|
RequiredNodes *string `json:"required_nodes,omitempty"`
|
||||||
|
ExcludedNodes *string `json:"excluded_nodes,omitempty"`
|
||||||
|
BeginTime *int64 `json:"begin_time,omitempty"`
|
||||||
|
Deadline *int64 `json:"deadline,omitempty"`
|
||||||
|
Array *string `json:"array,omitempty"`
|
||||||
|
Dependency *string `json:"dependency,omitempty"`
|
||||||
|
Requeue *bool `json:"requeue,omitempty"`
|
||||||
|
KillOnNodeFail *bool `json:"kill_on_node_fail,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// taskSendReq sends an HTTP request via the test env and returns the response.
|
// taskSendReq sends an HTTP request via the test env and returns the response.
|
||||||
@@ -259,3 +286,233 @@ func TestIntegration_Task_Validation(t *testing.T) {
|
|||||||
t.Error("expected non-empty error message")
|
t.Error("expected non-empty error message")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIntegration_Task_WithSchedulingParams(t *testing.T) {
|
||||||
|
env := testenv.NewTestEnv(t)
|
||||||
|
|
||||||
|
appID, err := env.CreateApp("sched-param-app", "#!/bin/bash\necho hello", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create app: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := fmt.Sprintf(`{
|
||||||
|
"app_id": %d,
|
||||||
|
"task_name": "sched-task",
|
||||||
|
"values": {},
|
||||||
|
"file_ids": [],
|
||||||
|
"partition": "gpu",
|
||||||
|
"cpus": 16,
|
||||||
|
"memory_per_node": 32768,
|
||||||
|
"time_limit": 120
|
||||||
|
}`, appID)
|
||||||
|
|
||||||
|
resp := taskSendReq(t, env, http.MethodPost, "/api/v1/tasks", body)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf("expected 201, got %d: %s", resp.StatusCode, string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed := taskParseResp(t, resp)
|
||||||
|
if !parsed.Success {
|
||||||
|
t.Fatalf("expected success=true, got error: %s", parsed.Error)
|
||||||
|
}
|
||||||
|
var data taskCreateData
|
||||||
|
if err := json.Unmarshal(parsed.Data, &data); err != nil {
|
||||||
|
t.Fatalf("unmarshal create data: %v", err)
|
||||||
|
}
|
||||||
|
if data.ID == 0 {
|
||||||
|
t.Fatal("expected non-zero task ID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegration_Task_ListWithScheduling(t *testing.T) {
|
||||||
|
env := testenv.NewTestEnv(t)
|
||||||
|
|
||||||
|
appID, err := env.CreateApp("sched-list-app", "#!/bin/bash\necho hello", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create app: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := fmt.Sprintf(`{
|
||||||
|
"app_id": %d,
|
||||||
|
"task_name": "sched-list-task",
|
||||||
|
"values": {},
|
||||||
|
"file_ids": [],
|
||||||
|
"partition": "gpu",
|
||||||
|
"cpus": 16,
|
||||||
|
"memory_per_node": 32768,
|
||||||
|
"time_limit": 120
|
||||||
|
}`, appID)
|
||||||
|
|
||||||
|
createResp := taskSendReq(t, env, http.MethodPost, "/api/v1/tasks", body)
|
||||||
|
defer createResp.Body.Close()
|
||||||
|
|
||||||
|
if createResp.StatusCode != http.StatusCreated {
|
||||||
|
b, _ := io.ReadAll(createResp.Body)
|
||||||
|
t.Fatalf("expected 201 creating task, got %d: %s", createResp.StatusCode, string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
createParsed := taskParseResp(t, createResp)
|
||||||
|
var createData taskCreateData
|
||||||
|
if err := json.Unmarshal(createParsed.Data, &createData); err != nil {
|
||||||
|
t.Fatalf("unmarshal create data: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
|
||||||
|
listResp := taskSendReq(t, env, http.MethodGet, "/api/v1/tasks", "")
|
||||||
|
defer listResp.Body.Close()
|
||||||
|
|
||||||
|
if listResp.StatusCode != http.StatusOK {
|
||||||
|
b, _ := io.ReadAll(listResp.Body)
|
||||||
|
t.Fatalf("expected 200 listing tasks, got %d: %s", listResp.StatusCode, string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
listParsed := taskParseResp(t, listResp)
|
||||||
|
var listData taskListData
|
||||||
|
if err := json.Unmarshal(listParsed.Data, &listData); err != nil {
|
||||||
|
t.Fatalf("unmarshal list data: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var found *taskListItem
|
||||||
|
for i := range listData.Items {
|
||||||
|
if listData.Items[i].ID == createData.ID {
|
||||||
|
found = &listData.Items[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found == nil {
|
||||||
|
t.Fatalf("task %d not found in list", createData.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if found.Partition != "gpu" {
|
||||||
|
t.Errorf("expected partition=gpu, got %q", found.Partition)
|
||||||
|
}
|
||||||
|
if found.Cpus == nil || *found.Cpus != 16 {
|
||||||
|
t.Errorf("expected cpus=16, got %v", found.Cpus)
|
||||||
|
}
|
||||||
|
if found.MemoryPerNode == nil || *found.MemoryPerNode != 32768 {
|
||||||
|
t.Errorf("expected memory_per_node=32768, got %v", found.MemoryPerNode)
|
||||||
|
}
|
||||||
|
if found.TimeLimit == nil || *found.TimeLimit != 120 {
|
||||||
|
t.Errorf("expected time_limit=120, got %v", found.TimeLimit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegration_Task_PartialScheduling(t *testing.T) {
|
||||||
|
env := testenv.NewTestEnv(t)
|
||||||
|
|
||||||
|
appID, err := env.CreateApp("partial-sched-app", "#!/bin/bash\necho hello", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create app: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := fmt.Sprintf(`{
|
||||||
|
"app_id": %d,
|
||||||
|
"task_name": "partial-sched-task",
|
||||||
|
"values": {},
|
||||||
|
"file_ids": [],
|
||||||
|
"partition": "gpu",
|
||||||
|
"cpus": 8
|
||||||
|
}`, appID)
|
||||||
|
|
||||||
|
createResp := taskSendReq(t, env, http.MethodPost, "/api/v1/tasks", body)
|
||||||
|
defer createResp.Body.Close()
|
||||||
|
|
||||||
|
if createResp.StatusCode != http.StatusCreated {
|
||||||
|
b, _ := io.ReadAll(createResp.Body)
|
||||||
|
t.Fatalf("expected 201, got %d: %s", createResp.StatusCode, string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
createParsed := taskParseResp(t, createResp)
|
||||||
|
var createData taskCreateData
|
||||||
|
if err := json.Unmarshal(createParsed.Data, &createData); err != nil {
|
||||||
|
t.Fatalf("unmarshal create data: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
|
||||||
|
listResp := taskSendReq(t, env, http.MethodGet, "/api/v1/tasks", "")
|
||||||
|
defer listResp.Body.Close()
|
||||||
|
|
||||||
|
listParsed := taskParseResp(t, listResp)
|
||||||
|
var listData taskListData
|
||||||
|
if err := json.Unmarshal(listParsed.Data, &listData); err != nil {
|
||||||
|
t.Fatalf("unmarshal list data: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var found *taskListItem
|
||||||
|
for i := range listData.Items {
|
||||||
|
if listData.Items[i].ID == createData.ID {
|
||||||
|
found = &listData.Items[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found == nil {
|
||||||
|
t.Fatalf("task %d not found in list", createData.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if found.Partition != "gpu" {
|
||||||
|
t.Errorf("expected partition=gpu, got %q", found.Partition)
|
||||||
|
}
|
||||||
|
if found.Cpus == nil || *found.Cpus != 8 {
|
||||||
|
t.Errorf("expected cpus=8, got %v", found.Cpus)
|
||||||
|
}
|
||||||
|
if found.MemoryPerNode != nil {
|
||||||
|
t.Errorf("expected memory_per_node=nil, got %v", found.MemoryPerNode)
|
||||||
|
}
|
||||||
|
if found.TimeLimit != nil {
|
||||||
|
t.Errorf("expected time_limit=nil, got %v", found.TimeLimit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegration_Task_BackwardCompat(t *testing.T) {
|
||||||
|
env := testenv.NewTestEnv(t)
|
||||||
|
|
||||||
|
appID, err := env.CreateApp("compat-app", "#!/bin/bash\necho hello", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create app: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
taskID := taskCreateViaAPI(t, env, appID, "compat-task")
|
||||||
|
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
|
||||||
|
listResp := taskSendReq(t, env, http.MethodGet, "/api/v1/tasks", "")
|
||||||
|
defer listResp.Body.Close()
|
||||||
|
|
||||||
|
listParsed := taskParseResp(t, listResp)
|
||||||
|
var listData taskListData
|
||||||
|
if err := json.Unmarshal(listParsed.Data, &listData); err != nil {
|
||||||
|
t.Fatalf("unmarshal list data: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var found *taskListItem
|
||||||
|
for i := range listData.Items {
|
||||||
|
if listData.Items[i].ID == taskID {
|
||||||
|
found = &listData.Items[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found == nil {
|
||||||
|
t.Fatalf("task %d not found in list", taskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if found.Partition != "" {
|
||||||
|
t.Errorf("expected empty partition, got %q", found.Partition)
|
||||||
|
}
|
||||||
|
if found.Cpus != nil {
|
||||||
|
t.Errorf("expected nil cpus, got %v", found.Cpus)
|
||||||
|
}
|
||||||
|
if found.MemoryPerNode != nil {
|
||||||
|
t.Errorf("expected nil memory_per_node, got %v", found.MemoryPerNode)
|
||||||
|
}
|
||||||
|
if found.TimeLimit != nil {
|
||||||
|
t.Errorf("expected nil time_limit, got %v", found.TimeLimit)
|
||||||
|
}
|
||||||
|
if found.QOS != nil {
|
||||||
|
t.Errorf("expected nil qos, got %v", found.QOS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+235
-1
@@ -1381,6 +1381,13 @@
|
|||||||
"required": false,
|
"required": false,
|
||||||
"description": "Search files by name",
|
"description": "Search files by name",
|
||||||
"schema": { "type": "string" }
|
"schema": { "type": "string" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "user_id",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"description": "Filter by file owner user ID",
|
||||||
|
"schema": { "type": "integer", "format": "int64" }
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -2738,7 +2745,18 @@
|
|||||||
"folder_id": {
|
"folder_id": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int64",
|
"format": "int64",
|
||||||
"description": "Parent folder ID",
|
"description": "Parent folder ID (null for root files)",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"folder_path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Full folder path (\"/\" for root)",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"description": "File owner user ID",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
},
|
},
|
||||||
"size": {
|
"size": {
|
||||||
@@ -2869,6 +2887,114 @@
|
|||||||
"format": "int64"
|
"format": "int64"
|
||||||
},
|
},
|
||||||
"description": "Input file IDs"
|
"description": "Input file IDs"
|
||||||
|
},
|
||||||
|
"partition": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Slurm scheduling: partition name"
|
||||||
|
},
|
||||||
|
"cpus": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Slurm scheduling: minimum number of CPUs"
|
||||||
|
},
|
||||||
|
"memory_per_node": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Slurm scheduling: memory per node in MB"
|
||||||
|
},
|
||||||
|
"memory_per_cpu": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Slurm scheduling: memory per CPU in MB"
|
||||||
|
},
|
||||||
|
"time_limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Slurm scheduling: time limit in minutes"
|
||||||
|
},
|
||||||
|
"qos": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Slurm scheduling: quality of service"
|
||||||
|
},
|
||||||
|
"job_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Slurm scheduling: job name"
|
||||||
|
},
|
||||||
|
"nodes": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Slurm scheduling: number of nodes (supports ranges)"
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Slurm scheduling: number of tasks"
|
||||||
|
},
|
||||||
|
"cpus_per_task": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Slurm scheduling: CPUs per task"
|
||||||
|
},
|
||||||
|
"constraints": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Slurm scheduling: feature constraints"
|
||||||
|
},
|
||||||
|
"reservation": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Slurm scheduling: reservation name"
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Slurm scheduling: charge account"
|
||||||
|
},
|
||||||
|
"nice": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Slurm scheduling: nice adjustment"
|
||||||
|
},
|
||||||
|
"mail_type": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Slurm scheduling: mail notification types (comma-separated)"
|
||||||
|
},
|
||||||
|
"mail_user": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Slurm scheduling: email address for notifications"
|
||||||
|
},
|
||||||
|
"standard_output": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Slurm scheduling: stdout file path"
|
||||||
|
},
|
||||||
|
"standard_error": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Slurm scheduling: stderr file path"
|
||||||
|
},
|
||||||
|
"standard_input": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Slurm scheduling: stdin file path"
|
||||||
|
},
|
||||||
|
"required_nodes": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Slurm scheduling: required node list (comma-separated)"
|
||||||
|
},
|
||||||
|
"excluded_nodes": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Slurm scheduling: excluded node list (comma-separated)"
|
||||||
|
},
|
||||||
|
"begin_time": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Slurm scheduling: begin time as Unix timestamp"
|
||||||
|
},
|
||||||
|
"deadline": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Slurm scheduling: deadline as Unix timestamp"
|
||||||
|
},
|
||||||
|
"array": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Slurm scheduling: job array specification"
|
||||||
|
},
|
||||||
|
"dependency": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Slurm scheduling: job dependency specification"
|
||||||
|
},
|
||||||
|
"requeue": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Slurm scheduling: requeue on failure"
|
||||||
|
},
|
||||||
|
"kill_on_node_fail": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Slurm scheduling: kill on node failure"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2929,6 +3055,114 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
"description": "Last update timestamp"
|
"description": "Last update timestamp"
|
||||||
|
},
|
||||||
|
"partition": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Slurm scheduling: partition name"
|
||||||
|
},
|
||||||
|
"cpus": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Slurm scheduling: minimum number of CPUs"
|
||||||
|
},
|
||||||
|
"memory_per_node": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Slurm scheduling: memory per node in MB"
|
||||||
|
},
|
||||||
|
"memory_per_cpu": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Slurm scheduling: memory per CPU in MB"
|
||||||
|
},
|
||||||
|
"time_limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Slurm scheduling: time limit in minutes"
|
||||||
|
},
|
||||||
|
"qos": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Slurm scheduling: quality of service"
|
||||||
|
},
|
||||||
|
"job_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Slurm scheduling: job name"
|
||||||
|
},
|
||||||
|
"nodes": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Slurm scheduling: number of nodes (supports ranges)"
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Slurm scheduling: number of tasks"
|
||||||
|
},
|
||||||
|
"cpus_per_task": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Slurm scheduling: CPUs per task"
|
||||||
|
},
|
||||||
|
"constraints": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Slurm scheduling: feature constraints"
|
||||||
|
},
|
||||||
|
"reservation": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Slurm scheduling: reservation name"
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Slurm scheduling: charge account"
|
||||||
|
},
|
||||||
|
"nice": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Slurm scheduling: nice adjustment"
|
||||||
|
},
|
||||||
|
"mail_type": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Slurm scheduling: mail notification types (comma-separated)"
|
||||||
|
},
|
||||||
|
"mail_user": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Slurm scheduling: email address for notifications"
|
||||||
|
},
|
||||||
|
"standard_output": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Slurm scheduling: stdout file path"
|
||||||
|
},
|
||||||
|
"standard_error": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Slurm scheduling: stderr file path"
|
||||||
|
},
|
||||||
|
"standard_input": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Slurm scheduling: stdin file path"
|
||||||
|
},
|
||||||
|
"required_nodes": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Slurm scheduling: required node list (comma-separated)"
|
||||||
|
},
|
||||||
|
"excluded_nodes": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Slurm scheduling: excluded node list (comma-separated)"
|
||||||
|
},
|
||||||
|
"begin_time": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Slurm scheduling: begin time as Unix timestamp"
|
||||||
|
},
|
||||||
|
"deadline": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Slurm scheduling: deadline as Unix timestamp"
|
||||||
|
},
|
||||||
|
"array": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Slurm scheduling: job array specification"
|
||||||
|
},
|
||||||
|
"dependency": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Slurm scheduling: job dependency specification"
|
||||||
|
},
|
||||||
|
"requeue": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Slurm scheduling: requeue on failure"
|
||||||
|
},
|
||||||
|
"kill_on_node_fail": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Slurm scheduling: kill on node failure"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -188,7 +188,7 @@ func initHTTPServer(cfg *config.Config, db *gorm.DB, slurmClient *slurm.Client,
|
|||||||
|
|
||||||
uploadSvc := service.NewUploadService(minioClient, blobStore, fileStore, uploadStore, cfg.Minio, db, logger)
|
uploadSvc := service.NewUploadService(minioClient, blobStore, fileStore, uploadStore, cfg.Minio, db, logger)
|
||||||
folderSvc := service.NewFolderService(folderStore, fileStore, logger)
|
folderSvc := service.NewFolderService(folderStore, fileStore, logger)
|
||||||
fileSvc := service.NewFileService(minioClient, blobStore, fileStore, cfg.Minio.Bucket, db, logger)
|
fileSvc := service.NewFileService(minioClient, blobStore, fileStore, folderStore, cfg.Minio.Bucket, db, logger)
|
||||||
|
|
||||||
uploadH = handler.NewUploadHandler(uploadSvc, logger)
|
uploadH = handler.NewUploadHandler(uploadSvc, logger)
|
||||||
fileH = handler.NewFileHandler(fileSvc, logger)
|
fileH = handler.NewFileHandler(fileSvc, logger)
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type fileServiceProvider interface {
|
type fileServiceProvider interface {
|
||||||
ListFiles(ctx context.Context, folderID *int64, page, pageSize int, search string) ([]model.FileResponse, int64, error)
|
ListFiles(ctx context.Context, folderID *int64, userID *int64, page, pageSize int, search string) ([]model.FileResponse, int64, error)
|
||||||
|
GetFileResponse(ctx context.Context, fileID int64) (*model.FileResponse, error)
|
||||||
GetFileMetadata(ctx context.Context, fileID int64) (*model.File, *model.FileBlob, error)
|
GetFileMetadata(ctx context.Context, fileID int64) (*model.File, *model.FileBlob, error)
|
||||||
DownloadFile(ctx context.Context, fileID int64, rangeHeader string) (io.ReadCloser, *model.File, *model.FileBlob, int64, int64, error)
|
DownloadFile(ctx context.Context, fileID int64, rangeHeader string) (io.ReadCloser, *model.File, *model.FileBlob, int64, int64, error)
|
||||||
DeleteFile(ctx context.Context, fileID int64) error
|
DeleteFile(ctx context.Context, fileID int64) error
|
||||||
@@ -50,9 +51,20 @@ func (h *FileHandler) ListFiles(c *gin.Context) {
|
|||||||
folderID = &id
|
folderID = &id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var userID *int64
|
||||||
|
if v := c.Query("user_id"); v != "" {
|
||||||
|
id, err := strconv.ParseInt(v, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("invalid user_id", zap.String("user_id", v))
|
||||||
|
server.BadRequest(c, "invalid user_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID = &id
|
||||||
|
}
|
||||||
|
|
||||||
search := c.Query("search")
|
search := c.Query("search")
|
||||||
|
|
||||||
files, total, err := h.svc.ListFiles(c.Request.Context(), folderID, page, pageSize, search)
|
files, total, err := h.svc.ListFiles(c.Request.Context(), folderID, userID, page, pageSize, search)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("failed to list files", zap.Error(err))
|
h.logger.Error("failed to list files", zap.Error(err))
|
||||||
server.InternalError(c, err.Error())
|
server.InternalError(c, err.Error())
|
||||||
@@ -75,23 +87,13 @@ func (h *FileHandler) GetFile(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
file, blob, err := h.svc.GetFileMetadata(c.Request.Context(), id)
|
resp, err := h.svc.GetFileResponse(c.Request.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("failed to get file", zap.Int64("id", id), zap.Error(err))
|
h.logger.Error("failed to get file", zap.Int64("id", id), zap.Error(err))
|
||||||
server.InternalError(c, err.Error())
|
server.InternalError(c, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := model.FileResponse{
|
|
||||||
ID: file.ID,
|
|
||||||
Name: file.Name,
|
|
||||||
FolderID: file.FolderID,
|
|
||||||
Size: blob.FileSize,
|
|
||||||
MimeType: blob.MimeType,
|
|
||||||
SHA256: file.BlobSHA256,
|
|
||||||
CreatedAt: file.CreatedAt,
|
|
||||||
UpdatedAt: file.UpdatedAt,
|
|
||||||
}
|
|
||||||
server.OK(c, resp)
|
server.OK(c, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,14 +18,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type mockFileService struct {
|
type mockFileService struct {
|
||||||
listFilesFn func(ctx context.Context, folderID *int64, page, pageSize int, search string) ([]model.FileResponse, int64, error)
|
listFilesFn func(ctx context.Context, folderID *int64, userID *int64, page, pageSize int, search string) ([]model.FileResponse, int64, error)
|
||||||
|
getFileResponseFn func(ctx context.Context, fileID int64) (*model.FileResponse, error)
|
||||||
getFileMetadataFn func(ctx context.Context, fileID int64) (*model.File, *model.FileBlob, error)
|
getFileMetadataFn func(ctx context.Context, fileID int64) (*model.File, *model.FileBlob, error)
|
||||||
downloadFileFn func(ctx context.Context, fileID int64, rangeHeader string) (io.ReadCloser, *model.File, *model.FileBlob, int64, int64, error)
|
downloadFileFn func(ctx context.Context, fileID int64, rangeHeader string) (io.ReadCloser, *model.File, *model.FileBlob, int64, int64, error)
|
||||||
deleteFileFn func(ctx context.Context, fileID int64) error
|
deleteFileFn func(ctx context.Context, fileID int64) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockFileService) ListFiles(ctx context.Context, folderID *int64, page, pageSize int, search string) ([]model.FileResponse, int64, error) {
|
func (m *mockFileService) ListFiles(ctx context.Context, folderID *int64, userID *int64, page, pageSize int, search string) ([]model.FileResponse, int64, error) {
|
||||||
return m.listFilesFn(ctx, folderID, page, pageSize, search)
|
return m.listFilesFn(ctx, folderID, userID, page, pageSize, search)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockFileService) GetFileResponse(ctx context.Context, fileID int64) (*model.FileResponse, error) {
|
||||||
|
return m.getFileResponseFn(ctx, fileID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockFileService) GetFileMetadata(ctx context.Context, fileID int64) (*model.File, *model.FileBlob, error) {
|
func (m *mockFileService) GetFileMetadata(ctx context.Context, fileID int64) (*model.File, *model.FileBlob, error) {
|
||||||
@@ -67,7 +72,7 @@ func newFileHandlerSetup() *fileHandlerSetup {
|
|||||||
|
|
||||||
func TestListFiles_Empty(t *testing.T) {
|
func TestListFiles_Empty(t *testing.T) {
|
||||||
s := newFileHandlerSetup()
|
s := newFileHandlerSetup()
|
||||||
s.mock.listFilesFn = func(ctx context.Context, folderID *int64, page, pageSize int, search string) ([]model.FileResponse, int64, error) {
|
s.mock.listFilesFn = func(ctx context.Context, folderID *int64, userID *int64, page, pageSize int, search string) ([]model.FileResponse, int64, error) {
|
||||||
return []model.FileResponse{}, 0, nil
|
return []model.FileResponse{}, 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +102,7 @@ func TestListFiles_Empty(t *testing.T) {
|
|||||||
func TestListFiles_WithFiles(t *testing.T) {
|
func TestListFiles_WithFiles(t *testing.T) {
|
||||||
s := newFileHandlerSetup()
|
s := newFileHandlerSetup()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
s.mock.listFilesFn = func(ctx context.Context, folderID *int64, page, pageSize int, search string) ([]model.FileResponse, int64, error) {
|
s.mock.listFilesFn = func(ctx context.Context, folderID *int64, userID *int64, page, pageSize int, search string) ([]model.FileResponse, int64, error) {
|
||||||
return []model.FileResponse{
|
return []model.FileResponse{
|
||||||
{ID: 1, Name: "a.txt", Size: 100, MimeType: "text/plain", SHA256: "abc123", CreatedAt: now, UpdatedAt: now},
|
{ID: 1, Name: "a.txt", Size: 100, MimeType: "text/plain", SHA256: "abc123", CreatedAt: now, UpdatedAt: now},
|
||||||
{ID: 2, Name: "b.pdf", Size: 200, MimeType: "application/pdf", SHA256: "def456", CreatedAt: now, UpdatedAt: now},
|
{ID: 2, Name: "b.pdf", Size: 200, MimeType: "application/pdf", SHA256: "def456", CreatedAt: now, UpdatedAt: now},
|
||||||
@@ -133,7 +138,7 @@ func TestListFiles_WithFiles(t *testing.T) {
|
|||||||
func TestListFiles_WithFolderID(t *testing.T) {
|
func TestListFiles_WithFolderID(t *testing.T) {
|
||||||
s := newFileHandlerSetup()
|
s := newFileHandlerSetup()
|
||||||
var capturedFolderID *int64
|
var capturedFolderID *int64
|
||||||
s.mock.listFilesFn = func(ctx context.Context, folderID *int64, page, pageSize int, search string) ([]model.FileResponse, int64, error) {
|
s.mock.listFilesFn = func(ctx context.Context, folderID *int64, userID *int64, page, pageSize int, search string) ([]model.FileResponse, int64, error) {
|
||||||
capturedFolderID = folderID
|
capturedFolderID = folderID
|
||||||
return []model.FileResponse{}, 0, nil
|
return []model.FileResponse{}, 0, nil
|
||||||
}
|
}
|
||||||
@@ -152,7 +157,7 @@ func TestListFiles_WithFolderID(t *testing.T) {
|
|||||||
|
|
||||||
func TestListFiles_ServiceError(t *testing.T) {
|
func TestListFiles_ServiceError(t *testing.T) {
|
||||||
s := newFileHandlerSetup()
|
s := newFileHandlerSetup()
|
||||||
s.mock.listFilesFn = func(ctx context.Context, folderID *int64, page, pageSize int, search string) ([]model.FileResponse, int64, error) {
|
s.mock.listFilesFn = func(ctx context.Context, folderID *int64, userID *int64, page, pageSize int, search string) ([]model.FileResponse, int64, error) {
|
||||||
return nil, 0, fmt.Errorf("db error")
|
return nil, 0, fmt.Errorf("db error")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,12 +175,11 @@ func TestListFiles_ServiceError(t *testing.T) {
|
|||||||
func TestGetFile_Found(t *testing.T) {
|
func TestGetFile_Found(t *testing.T) {
|
||||||
s := newFileHandlerSetup()
|
s := newFileHandlerSetup()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
s.mock.getFileMetadataFn = func(ctx context.Context, fileID int64) (*model.File, *model.FileBlob, error) {
|
rootPath := "/"
|
||||||
return &model.File{
|
s.mock.getFileResponseFn = func(ctx context.Context, fileID int64) (*model.FileResponse, error) {
|
||||||
ID: 1, Name: "test.txt", BlobSHA256: "abc123", CreatedAt: now, UpdatedAt: now,
|
return &model.FileResponse{
|
||||||
}, &model.FileBlob{
|
ID: 1, Name: "test.txt", SHA256: "abc123", FolderPath: &rootPath, CreatedAt: now, UpdatedAt: now,
|
||||||
ID: 1, SHA256: "abc123", FileSize: 1024, MimeType: "text/plain", CreatedAt: now,
|
}, nil
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -195,8 +199,8 @@ func TestGetFile_Found(t *testing.T) {
|
|||||||
|
|
||||||
func TestGetFile_NotFound(t *testing.T) {
|
func TestGetFile_NotFound(t *testing.T) {
|
||||||
s := newFileHandlerSetup()
|
s := newFileHandlerSetup()
|
||||||
s.mock.getFileMetadataFn = func(ctx context.Context, fileID int64) (*model.File, *model.FileBlob, error) {
|
s.mock.getFileResponseFn = func(ctx context.Context, fileID int64) (*model.FileResponse, error) {
|
||||||
return nil, nil, fmt.Errorf("file not found: 999")
|
return nil, fmt.Errorf("file not found: 999")
|
||||||
}
|
}
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -76,18 +76,45 @@ func (h *TaskHandler) ListTasks(c *gin.Context) {
|
|||||||
responses := make([]model.TaskResponse, 0, len(tasks))
|
responses := make([]model.TaskResponse, 0, len(tasks))
|
||||||
for i := range tasks {
|
for i := range tasks {
|
||||||
responses = append(responses, model.TaskResponse{
|
responses = append(responses, model.TaskResponse{
|
||||||
ID: tasks[i].ID,
|
ID: tasks[i].ID,
|
||||||
TaskName: tasks[i].TaskName,
|
TaskName: tasks[i].TaskName,
|
||||||
AppID: tasks[i].AppID,
|
AppID: tasks[i].AppID,
|
||||||
AppName: tasks[i].AppName,
|
AppName: tasks[i].AppName,
|
||||||
Status: tasks[i].Status,
|
Status: tasks[i].Status,
|
||||||
CurrentStep: tasks[i].CurrentStep,
|
CurrentStep: tasks[i].CurrentStep,
|
||||||
RetryCount: tasks[i].RetryCount,
|
RetryCount: tasks[i].RetryCount,
|
||||||
SlurmJobID: tasks[i].SlurmJobID,
|
SlurmJobID: tasks[i].SlurmJobID,
|
||||||
WorkDir: tasks[i].WorkDir,
|
WorkDir: tasks[i].WorkDir,
|
||||||
ErrorMessage: tasks[i].ErrorMessage,
|
ErrorMessage: tasks[i].ErrorMessage,
|
||||||
CreatedAt: tasks[i].CreatedAt,
|
CreatedAt: tasks[i].CreatedAt,
|
||||||
UpdatedAt: tasks[i].UpdatedAt,
|
UpdatedAt: tasks[i].UpdatedAt,
|
||||||
|
Partition: tasks[i].Partition,
|
||||||
|
Cpus: tasks[i].Cpus,
|
||||||
|
MemoryPerNode: tasks[i].MemoryPerNode,
|
||||||
|
MemoryPerCpu: tasks[i].MemoryPerCpu,
|
||||||
|
TimeLimit: tasks[i].TimeLimit,
|
||||||
|
QOS: tasks[i].QOS,
|
||||||
|
JobName: tasks[i].JobName,
|
||||||
|
Nodes: tasks[i].Nodes,
|
||||||
|
Tasks: tasks[i].Tasks,
|
||||||
|
CpusPerTask: tasks[i].CpusPerTask,
|
||||||
|
Constraints: tasks[i].Constraints,
|
||||||
|
Reservation: tasks[i].Reservation,
|
||||||
|
Account: tasks[i].Account,
|
||||||
|
Nice: tasks[i].Nice,
|
||||||
|
MailType: tasks[i].MailType,
|
||||||
|
MailUser: tasks[i].MailUser,
|
||||||
|
StandardOutput: tasks[i].StandardOutput,
|
||||||
|
StandardError: tasks[i].StandardError,
|
||||||
|
StandardInput: tasks[i].StandardInput,
|
||||||
|
RequiredNodes: tasks[i].RequiredNodes,
|
||||||
|
ExcludedNodes: tasks[i].ExcludedNodes,
|
||||||
|
BeginTime: tasks[i].BeginTime,
|
||||||
|
Deadline: tasks[i].Deadline,
|
||||||
|
Array: tasks[i].Array,
|
||||||
|
Dependency: tasks[i].Dependency,
|
||||||
|
Requeue: tasks[i].Requeue,
|
||||||
|
KillOnNodeFail: tasks[i].KillOnNodeFail,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -248,6 +248,148 @@ func TestTaskHandler_ListTasks_StatusFilter(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTaskHandler_CreateTask_WithSchedulingFields(t *testing.T) {
|
||||||
|
slurmSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{"job_id": 12345})
|
||||||
|
}))
|
||||||
|
defer slurmSrv.Close()
|
||||||
|
|
||||||
|
h, db := setupTaskHandler(t, slurmSrv)
|
||||||
|
r := setupTaskRouter(h)
|
||||||
|
|
||||||
|
appID := createTestAppForTask(db)
|
||||||
|
|
||||||
|
taskSvc := h.svc.(*service.TaskService)
|
||||||
|
ctx := context.Background()
|
||||||
|
taskSvc.StartProcessor(ctx)
|
||||||
|
defer taskSvc.StopProcessor()
|
||||||
|
|
||||||
|
cpus := int32(8)
|
||||||
|
memNode := int64(4096)
|
||||||
|
tl := int32(60)
|
||||||
|
qos := "high"
|
||||||
|
body, _ := json.Marshal(model.CreateTaskRequest{
|
||||||
|
AppID: appID,
|
||||||
|
TaskName: "sched-task",
|
||||||
|
Partition: ptrStr("gpu"),
|
||||||
|
Cpus: &cpus,
|
||||||
|
MemoryPerNode: &memNode,
|
||||||
|
TimeLimit: &tl,
|
||||||
|
QOS: &qos,
|
||||||
|
})
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, "/api/v1/tasks", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
if !resp["success"].(bool) {
|
||||||
|
t.Fatal("expected success=true")
|
||||||
|
}
|
||||||
|
data := resp["data"].(map[string]interface{})
|
||||||
|
if _, ok := data["id"]; !ok {
|
||||||
|
t.Fatal("expected id in response data")
|
||||||
|
}
|
||||||
|
taskID := int64(data["id"].(float64))
|
||||||
|
|
||||||
|
// Wait for async processing
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
|
||||||
|
// Verify persisted scheduling fields
|
||||||
|
var task model.Task
|
||||||
|
db.First(&task, taskID)
|
||||||
|
if task.Partition != "gpu" {
|
||||||
|
t.Errorf("expected partition=gpu, got %q", task.Partition)
|
||||||
|
}
|
||||||
|
if task.Cpus == nil || *task.Cpus != 8 {
|
||||||
|
t.Errorf("expected cpus=8, got %v", task.Cpus)
|
||||||
|
}
|
||||||
|
if task.MemoryPerNode == nil || *task.MemoryPerNode != 4096 {
|
||||||
|
t.Errorf("expected memory_per_node=4096, got %v", task.MemoryPerNode)
|
||||||
|
}
|
||||||
|
if task.TimeLimit == nil || *task.TimeLimit != 60 {
|
||||||
|
t.Errorf("expected time_limit=60, got %v", task.TimeLimit)
|
||||||
|
}
|
||||||
|
if task.QOS == nil || *task.QOS != "high" {
|
||||||
|
t.Errorf("expected qos=high, got %v", task.QOS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskHandler_ListTasks_ReturnsSchedulingFields(t *testing.T) {
|
||||||
|
h, db := setupTaskHandler(t, nil)
|
||||||
|
r := setupTaskRouter(h)
|
||||||
|
|
||||||
|
_ = createTestAppForTask(db)
|
||||||
|
|
||||||
|
cpus := int32(16)
|
||||||
|
memNode := int64(8192)
|
||||||
|
tl := int32(120)
|
||||||
|
partition := "gpu"
|
||||||
|
qos := "normal"
|
||||||
|
task := &model.Task{
|
||||||
|
TaskName: "sched-list-task",
|
||||||
|
AppID: 1,
|
||||||
|
AppName: "test-app",
|
||||||
|
Status: model.TaskStatusSubmitted,
|
||||||
|
SubmittedAt: time.Now(),
|
||||||
|
Partition: partition,
|
||||||
|
Cpus: &cpus,
|
||||||
|
MemoryPerNode: &memNode,
|
||||||
|
TimeLimit: &tl,
|
||||||
|
QOS: &qos,
|
||||||
|
}
|
||||||
|
db.Create(task)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, "/api/v1/tasks", nil)
|
||||||
|
r.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{})
|
||||||
|
items := data["items"].([]interface{})
|
||||||
|
if len(items) == 0 {
|
||||||
|
t.Fatal("expected at least 1 item")
|
||||||
|
}
|
||||||
|
item := items[0].(map[string]interface{})
|
||||||
|
|
||||||
|
if item["partition"] != "gpu" {
|
||||||
|
t.Errorf("expected partition=gpu, got %v", item["partition"])
|
||||||
|
}
|
||||||
|
if item["cpus"] == nil {
|
||||||
|
t.Error("expected cpus to be set")
|
||||||
|
} else if item["cpus"].(float64) != 16 {
|
||||||
|
t.Errorf("expected cpus=16, got %v", item["cpus"])
|
||||||
|
}
|
||||||
|
if item["memory_per_node"] == nil {
|
||||||
|
t.Error("expected memory_per_node to be set")
|
||||||
|
} else if item["memory_per_node"].(float64) != 8192 {
|
||||||
|
t.Errorf("expected memory_per_node=8192, got %v", item["memory_per_node"])
|
||||||
|
}
|
||||||
|
if item["time_limit"] == nil {
|
||||||
|
t.Error("expected time_limit to be set")
|
||||||
|
} else if item["time_limit"].(float64) != 120 {
|
||||||
|
t.Errorf("expected time_limit=120, got %v", item["time_limit"])
|
||||||
|
}
|
||||||
|
if item["qos"] == nil {
|
||||||
|
t.Error("expected qos to be set")
|
||||||
|
} else if item["qos"].(string) != "normal" {
|
||||||
|
t.Errorf("expected qos=normal, got %v", item["qos"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptrStr(s string) *string { return &s }
|
||||||
|
|
||||||
func TestTaskHandler_ListTasks_DefaultPagination(t *testing.T) {
|
func TestTaskHandler_ListTasks_DefaultPagination(t *testing.T) {
|
||||||
h, db := setupTaskHandler(t, nil)
|
h, db := setupTaskHandler(t, nil)
|
||||||
r := setupTaskRouter(h)
|
r := setupTaskRouter(h)
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
package mockserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gcy_hpc_server/internal/config"
|
||||||
|
"gcy_hpc_server/internal/handler"
|
||||||
|
"gcy_hpc_server/internal/model"
|
||||||
|
"gcy_hpc_server/internal/server"
|
||||||
|
"gcy_hpc_server/internal/service"
|
||||||
|
"gcy_hpc_server/internal/slurm"
|
||||||
|
"gcy_hpc_server/internal/store"
|
||||||
|
"gcy_hpc_server/internal/testutil/mockminio"
|
||||||
|
"gcy_hpc_server/internal/testutil/mockslurm"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type serverConfig struct {
|
||||||
|
port int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option configures a Server during construction.
|
||||||
|
type Option func(*serverConfig)
|
||||||
|
|
||||||
|
// WithPort sets the listen port (default 8080).
|
||||||
|
func WithPort(port int) Option {
|
||||||
|
return func(c *serverConfig) { c.port = port }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server is a standalone mock server that wires all dependencies using
|
||||||
|
// in-memory SQLite, MockSlurm, and MockMinIO — suitable for integration
|
||||||
|
// testing or development without external infrastructure.
|
||||||
|
type Server struct {
|
||||||
|
httpServer *http.Server
|
||||||
|
mockSlurmSrv *httptest.Server
|
||||||
|
mockSlurm *mockslurm.MockSlurm
|
||||||
|
db *gorm.DB
|
||||||
|
workDir string
|
||||||
|
logger *zap.Logger
|
||||||
|
|
||||||
|
taskSvc *service.TaskService
|
||||||
|
appSvc *service.ApplicationService
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a fully wired mock server. All dependencies are initialized
|
||||||
|
// in-memory. Call Close() to release resources, or Run() to start serving.
|
||||||
|
func New(opts ...Option) (*Server, error) {
|
||||||
|
cfg := &serverConfig{port: 8080}
|
||||||
|
for _, o := range opts {
|
||||||
|
o(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Logger
|
||||||
|
logger := zap.NewExample()
|
||||||
|
|
||||||
|
// 2. SQLite in-memory DB + AutoMigrate
|
||||||
|
dbName := fmt.Sprintf("file:mockserver-%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||||
|
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||||
|
}
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get underlying sql.DB: %w", err)
|
||||||
|
}
|
||||||
|
sqlDB.SetMaxOpenConns(1)
|
||||||
|
if err := db.AutoMigrate(
|
||||||
|
&model.Application{},
|
||||||
|
&model.FileBlob{},
|
||||||
|
&model.File{},
|
||||||
|
&model.Folder{},
|
||||||
|
&model.UploadSession{},
|
||||||
|
&model.UploadChunk{},
|
||||||
|
&model.Task{},
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("auto-migrate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Temp work directory
|
||||||
|
workDir, err := os.MkdirTemp("", "mockserver-*")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create temp dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. MockSlurm
|
||||||
|
mockSlurmSrv, mockSlurm := mockslurm.NewMockSlurmServer()
|
||||||
|
|
||||||
|
// 5. MockMinIO
|
||||||
|
mockMinIO := mockminio.NewInMemoryStorage()
|
||||||
|
|
||||||
|
// 6. Stores
|
||||||
|
appStore := store.NewApplicationStore(db)
|
||||||
|
taskStore := store.NewTaskStore(db)
|
||||||
|
fileStore := store.NewFileStore(db)
|
||||||
|
blobStore := store.NewBlobStore(db)
|
||||||
|
uploadStore := store.NewUploadStore(db)
|
||||||
|
folderStore := store.NewFolderStore(db)
|
||||||
|
|
||||||
|
// 7. Slurm client
|
||||||
|
slurmClient, err := slurm.NewClientWithOpts(mockSlurmSrv.URL, slurm.WithHTTPClient(mockSlurmSrv.Client()))
|
||||||
|
if err != nil {
|
||||||
|
os.RemoveAll(workDir)
|
||||||
|
mockSlurmSrv.Close()
|
||||||
|
return nil, fmt.Errorf("create slurm client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. MinioConfig
|
||||||
|
minioCfg := config.MinioConfig{
|
||||||
|
Bucket: "files",
|
||||||
|
ChunkSize: 16 << 20,
|
||||||
|
MaxFileSize: 50 << 30,
|
||||||
|
MinChunkSize: 5 << 20,
|
||||||
|
SessionTTL: 48,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Services (dependency order)
|
||||||
|
jobSvc := service.NewJobService(slurmClient, logger)
|
||||||
|
clusterSvc := service.NewClusterService(slurmClient, logger)
|
||||||
|
folderSvc := service.NewFolderService(folderStore, fileStore, logger)
|
||||||
|
stagingSvc := service.NewFileStagingService(fileStore, blobStore, mockMinIO, minioCfg.Bucket, logger)
|
||||||
|
taskSvc := service.NewTaskService(taskStore, appStore, fileStore, blobStore, stagingSvc, jobSvc, workDir, logger)
|
||||||
|
appSvc := service.NewApplicationService(appStore, jobSvc, workDir, logger)
|
||||||
|
uploadSvc := service.NewUploadService(mockMinIO, blobStore, fileStore, uploadStore, minioCfg, db, logger)
|
||||||
|
fileSvc := service.NewFileService(mockMinIO, blobStore, fileStore, folderStore, minioCfg.Bucket, db, logger)
|
||||||
|
|
||||||
|
// 10. Handlers
|
||||||
|
jobH := handler.NewJobHandler(jobSvc, logger)
|
||||||
|
clusterH := handler.NewClusterHandler(clusterSvc, logger)
|
||||||
|
appH := handler.NewApplicationHandler(appSvc, logger)
|
||||||
|
uploadH := handler.NewUploadHandler(uploadSvc, logger)
|
||||||
|
fileH := handler.NewFileHandler(fileSvc, logger)
|
||||||
|
folderH := handler.NewFolderHandler(folderSvc, logger)
|
||||||
|
taskH := handler.NewTaskHandler(taskSvc, logger)
|
||||||
|
|
||||||
|
// 11. Router
|
||||||
|
taskSvc.StartProcessor(context.Background())
|
||||||
|
|
||||||
|
// 12. Router
|
||||||
|
router := server.NewRouter(jobH, clusterH, appH, uploadH, fileH, folderH, taskH, logger)
|
||||||
|
|
||||||
|
// 12. HTTP server
|
||||||
|
httpServer := &http.Server{
|
||||||
|
Addr: fmt.Sprintf(":%d", cfg.port),
|
||||||
|
Handler: router,
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Server{
|
||||||
|
httpServer: httpServer,
|
||||||
|
mockSlurmSrv: mockSlurmSrv,
|
||||||
|
mockSlurm: mockSlurm,
|
||||||
|
db: db,
|
||||||
|
workDir: workDir,
|
||||||
|
logger: logger,
|
||||||
|
taskSvc: taskSvc,
|
||||||
|
appSvc: appSvc,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases all resources: HTTP server, mock slurm, database, temp directory.
|
||||||
|
func (s *Server) Close() error {
|
||||||
|
var errs []error
|
||||||
|
|
||||||
|
if s.taskSvc != nil {
|
||||||
|
s.taskSvc.StopProcessor()
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.httpServer != nil {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := s.httpServer.Shutdown(ctx); err != nil && err != http.ErrServerClosed {
|
||||||
|
errs = append(errs, fmt.Errorf("shutdown http server: %w", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.mockSlurmSrv != nil {
|
||||||
|
s.mockSlurmSrv.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.db != nil {
|
||||||
|
sqlDB, err := s.db.DB()
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("get underlying sql.DB: %w", err))
|
||||||
|
} else if err := sqlDB.Close(); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("close database: %w", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.workDir != "" {
|
||||||
|
if err := os.RemoveAll(s.workDir); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("remove work dir: %w", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// simulateJobProgress periodically advances mock Slurm job states
|
||||||
|
// and syncs the corresponding Task statuses.
|
||||||
|
// Transition: PENDING → RUNNING (after 3s) → COMPLETED (after 5s).
|
||||||
|
func (s *Server) simulateJobProgress(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(2 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
for _, job := range s.mockSlurm.GetAllActiveJobs() {
|
||||||
|
switch s.mockSlurm.GetJobState(job.JobID) {
|
||||||
|
case "PENDING":
|
||||||
|
if time.Since(job.SubmitTime) > 3*time.Second {
|
||||||
|
s.mockSlurm.SetJobState(job.JobID, "RUNNING")
|
||||||
|
s.logger.Info("mock: job PENDING → RUNNING", zap.Int32("job_id", job.JobID))
|
||||||
|
_ = s.syncTaskStatusFromSlurm(ctx, job.JobID)
|
||||||
|
}
|
||||||
|
case "RUNNING":
|
||||||
|
if job.StartTime != nil && time.Since(*job.StartTime) > 5*time.Second {
|
||||||
|
s.mockSlurm.SetJobState(job.JobID, "COMPLETED")
|
||||||
|
s.logger.Info("mock: job RUNNING → COMPLETED", zap.Int32("job_id", job.JobID))
|
||||||
|
_ = s.syncTaskStatusFromSlurm(ctx, job.JobID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var slurmStateToTaskStatus = map[string]string{
|
||||||
|
"PENDING": model.TaskStatusQueued,
|
||||||
|
"RUNNING": model.TaskStatusRunning,
|
||||||
|
"COMPLETED": model.TaskStatusCompleted,
|
||||||
|
"FAILED": model.TaskStatusFailed,
|
||||||
|
"CANCELLED": model.TaskStatusFailed,
|
||||||
|
"TIMEOUT": model.TaskStatusFailed,
|
||||||
|
"NODE_FAIL": model.TaskStatusFailed,
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) syncTaskStatusFromSlurm(ctx context.Context, slurmJobID int32) error {
|
||||||
|
var tasks []model.Task
|
||||||
|
if err := s.db.Where("slurm_job_id = ?", slurmJobID).Find(&tasks).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
slurmState := s.mockSlurm.GetJobState(slurmJobID)
|
||||||
|
taskStatus, ok := slurmStateToTaskStatus[slurmState]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, t := range tasks {
|
||||||
|
if t.Status != taskStatus {
|
||||||
|
s.logger.Info("mock: syncing task status",
|
||||||
|
zap.Int64("task_id", t.ID),
|
||||||
|
zap.String("old", t.Status),
|
||||||
|
zap.String("new", taskStatus),
|
||||||
|
)
|
||||||
|
_ = s.db.Model(&model.Task{}).Where("id = ?", t.ID).Update("status", taskStatus).Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the HTTP server and blocks until a shutdown signal (SIGINT/SIGTERM)
|
||||||
|
// or a server error occurs.
|
||||||
|
func (s *Server) Run() error {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
go s.simulateJobProgress(ctx)
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
s.logger.Info("starting server", zap.String("addr", s.httpServer.Addr))
|
||||||
|
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
errCh <- fmt.Errorf("server listen: %w", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
s.logger.Error("server exited unexpectedly", zap.Error(err))
|
||||||
|
_ = s.Close()
|
||||||
|
return err
|
||||||
|
case sig := <-quit:
|
||||||
|
s.logger.Info("received shutdown signal", zap.String("signal", sig.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("shutting down server...")
|
||||||
|
return s.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockSlurm returns the MockSlurm controller for configuring slurm responses.
|
||||||
|
func (s *Server) MockSlurm() *mockslurm.MockSlurm { return s.mockSlurm }
|
||||||
|
|
||||||
|
// MockSlurmURL returns the URL of the mock slurm HTTP server.
|
||||||
|
func (s *Server) MockSlurmURL() string { return s.mockSlurmSrv.URL }
|
||||||
|
|
||||||
|
// MockSlurmHTTPClient returns the HTTP client wired to the mock slurm server.
|
||||||
|
func (s *Server) MockSlurmHTTPClient() *http.Client { return s.mockSlurmSrv.Client() }
|
||||||
|
|
||||||
|
// ApplicationService returns the wired ApplicationService.
|
||||||
|
func (s *Server) ApplicationService() *service.ApplicationService { return s.appSvc }
|
||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Parameter type constants for ParameterSchema.Type.
|
// 参数类型常量
|
||||||
const (
|
const (
|
||||||
ParamTypeString = "string"
|
ParamTypeString = "string"
|
||||||
ParamTypeInteger = "integer"
|
ParamTypeInteger = "integer"
|
||||||
@@ -15,62 +15,59 @@ const (
|
|||||||
ParamTypeBoolean = "boolean"
|
ParamTypeBoolean = "boolean"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Application represents a parameterized application definition for HPC job submission.
|
// Application 表示一个参数化的 HPC 应用定义。
|
||||||
type Application struct {
|
type Application struct {
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` // 主键
|
||||||
Name string `gorm:"uniqueIndex;size:255;not null" json:"name"`
|
Name string `gorm:"uniqueIndex;size:255;not null" json:"name"` // 应用名称(唯一)
|
||||||
Description string `gorm:"type:text" json:"description,omitempty"`
|
Description string `gorm:"type:text" json:"description,omitempty"` // 应用描述
|
||||||
Icon string `gorm:"size:255" json:"icon,omitempty"`
|
Icon string `gorm:"size:255" json:"icon,omitempty"` // 图标
|
||||||
Category string `gorm:"size:255" json:"category,omitempty"`
|
Category string `gorm:"size:255" json:"category,omitempty"` // 分类
|
||||||
ScriptTemplate string `gorm:"type:text;not null" json:"script_template"`
|
ScriptTemplate string `gorm:"type:text;not null" json:"script_template"` // 脚本模板
|
||||||
Parameters json.RawMessage `gorm:"type:json" json:"parameters,omitempty"`
|
Parameters json.RawMessage `gorm:"type:json" json:"parameters,omitempty"` // 参数表单JSON
|
||||||
Scope string `gorm:"size:50;default:'system'" json:"scope,omitempty"`
|
Scope string `gorm:"size:50;default:'system'" json:"scope,omitempty"` // 作用域(system/user)
|
||||||
CreatedBy int64 `json:"created_by,omitempty"`
|
CreatedBy int64 `json:"created_by,omitempty"` // 创建者ID
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"` // 创建时间
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"` // 更新时间
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Application) TableName() string {
|
func (Application) TableName() string {
|
||||||
return "hpc_applications"
|
return "hpc_applications"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParameterSchema defines a single parameter in an application's form schema.
|
// ParameterSchema 定义应用表单中单个参数的格式。
|
||||||
type ParameterSchema struct {
|
type ParameterSchema struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"` // 参数名
|
||||||
Label string `json:"label,omitempty"`
|
Label string `json:"label,omitempty"` // 显示名称
|
||||||
Type string `json:"type"`
|
Type string `json:"type"` // 参数类型
|
||||||
Required bool `json:"required,omitempty"`
|
Required bool `json:"required,omitempty"` // 是否必填
|
||||||
Default string `json:"default,omitempty"`
|
Default string `json:"default,omitempty"` // 默认值
|
||||||
Options []string `json:"options,omitempty"`
|
Options []string `json:"options,omitempty"` // 枚举选项列表
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"` // 参数说明
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateApplicationRequest is the DTO for creating a new application.
|
// CreateApplicationRequest 是创建应用的 API 请求。
|
||||||
type CreateApplicationRequest struct {
|
type CreateApplicationRequest struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"` // 应用名称(必填)
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"` // 应用描述
|
||||||
Icon string `json:"icon,omitempty"`
|
Icon string `json:"icon,omitempty"` // 图标
|
||||||
Category string `json:"category,omitempty"`
|
Category string `json:"category,omitempty"` // 分类
|
||||||
ScriptTemplate string `json:"script_template" binding:"required"`
|
ScriptTemplate string `json:"script_template" binding:"required"` // 脚本模板(必填)
|
||||||
Parameters json.RawMessage `json:"parameters,omitempty"`
|
Parameters json.RawMessage `json:"parameters,omitempty"` // 参数表单JSON
|
||||||
Scope string `json:"scope,omitempty"`
|
Scope string `json:"scope,omitempty"` // 作用域
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateApplicationRequest is the DTO for updating an existing application.
|
// UpdateApplicationRequest 是更新应用的 API 请求。所有字段可选。
|
||||||
// All fields are optional. Parameters uses *json.RawMessage to distinguish
|
|
||||||
// between "not provided" (nil) and "set to empty" (non-nil).
|
|
||||||
type UpdateApplicationRequest struct {
|
type UpdateApplicationRequest struct {
|
||||||
Name *string `json:"name,omitempty"`
|
Name *string `json:"name,omitempty"` // 应用名称
|
||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"` // 应用描述
|
||||||
Icon *string `json:"icon,omitempty"`
|
Icon *string `json:"icon,omitempty"` // 图标
|
||||||
Category *string `json:"category,omitempty"`
|
Category *string `json:"category,omitempty"` // 分类
|
||||||
ScriptTemplate *string `json:"script_template,omitempty"`
|
ScriptTemplate *string `json:"script_template,omitempty"` // 脚本模板
|
||||||
Parameters *json.RawMessage `json:"parameters,omitempty"`
|
Parameters *json.RawMessage `json:"parameters,omitempty"` // 参数表单JSON
|
||||||
Scope *string `json:"scope,omitempty"`
|
Scope *string `json:"scope,omitempty"` // 作用域
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplicationSubmitRequest is the DTO for submitting a job from an application.
|
// ApplicationSubmitRequest 是通过应用提交作业的 API 请求。
|
||||||
// ApplicationID is parsed from the URL :id parameter, not included in the body.
|
|
||||||
type ApplicationSubmitRequest struct {
|
type ApplicationSubmitRequest struct {
|
||||||
Values map[string]string `json:"values" binding:"required"`
|
Values map[string]string `json:"values" binding:"required"` // 脚本业务参数键值对
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
// NodeResponse is the API response for a node.
|
// NodeResponse 是节点查询 API 响应。
|
||||||
type NodeResponse struct {
|
type NodeResponse struct {
|
||||||
// Identity
|
// Identity
|
||||||
Name string `json:"name"` // 节点主机名
|
Name string `json:"name"` // 节点主机名
|
||||||
@@ -37,7 +37,7 @@ type NodeResponse struct {
|
|||||||
ActiveFeatures string `json:"active_features,omitempty"` // 当前生效的特性标签 (只读)
|
ActiveFeatures string `json:"active_features,omitempty"` // 当前生效的特性标签 (只读)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PartitionResponse is the API response for a partition.
|
// PartitionResponse 是分区查询 API 响应。
|
||||||
type PartitionResponse struct {
|
type PartitionResponse struct {
|
||||||
// Identity
|
// Identity
|
||||||
Name string `json:"name"` // 分区名称
|
Name string `json:"name"` // 分区名称
|
||||||
|
|||||||
+100
-98
@@ -9,157 +9,159 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FileBlob represents a physical file stored in MinIO, deduplicated by SHA256.
|
// FileBlob 表示存储在 MinIO 中的物理文件,按 SHA256 去重。
|
||||||
type FileBlob struct {
|
type FileBlob struct {
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` // 主键
|
||||||
SHA256 string `gorm:"uniqueIndex;size:64;not null" json:"sha256"`
|
SHA256 string `gorm:"uniqueIndex;size:64;not null" json:"sha256"` // 文件哈希
|
||||||
MinioKey string `gorm:"size:255;not null" json:"minio_key"`
|
MinioKey string `gorm:"size:255;not null" json:"minio_key"` // MinIO对象键
|
||||||
FileSize int64 `gorm:"not null" json:"file_size"`
|
FileSize int64 `gorm:"not null" json:"file_size"` // 文件大小(字节)
|
||||||
MimeType string `gorm:"size:255" json:"mime_type"`
|
MimeType string `gorm:"size:255" json:"mime_type"` // MIME类型
|
||||||
RefCount int `gorm:"not null;default:0" json:"ref_count"`
|
RefCount int `gorm:"not null;default:0" json:"ref_count"` // 引用计数
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"` // 创建时间
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"` // 更新时间
|
||||||
}
|
}
|
||||||
|
|
||||||
func (FileBlob) TableName() string {
|
func (FileBlob) TableName() string {
|
||||||
return "hpc_file_blobs"
|
return "hpc_file_blobs"
|
||||||
}
|
}
|
||||||
|
|
||||||
// File represents a logical file visible to users, backed by a FileBlob.
|
// File 表示用户可见的逻辑文件,底层由 FileBlob 存储。
|
||||||
type File struct {
|
type File struct {
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` // 主键
|
||||||
Name string `gorm:"size:255;not null" json:"name"`
|
Name string `gorm:"size:255;not null" json:"name"` // 文件名
|
||||||
FolderID *int64 `gorm:"index" json:"folder_id,omitempty"`
|
FolderID *int64 `gorm:"index" json:"folder_id,omitempty"` // 所属文件夹ID
|
||||||
BlobSHA256 string `gorm:"size:64;not null" json:"blob_sha256"`
|
BlobSHA256 string `gorm:"size:64;not null" json:"blob_sha256"` // 关联的文件blob哈希
|
||||||
UserID *int64 `gorm:"index" json:"user_id,omitempty"`
|
UserID *int64 `gorm:"index" json:"user_id,omitempty"` // 所有者ID
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"` // 创建时间
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"` // 更新时间
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"` // 软删除时间
|
||||||
}
|
}
|
||||||
|
|
||||||
func (File) TableName() string {
|
func (File) TableName() string {
|
||||||
return "hpc_files"
|
return "hpc_files"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Folder represents a directory in the virtual file system.
|
// Folder 表示虚拟文件系统中的目录。
|
||||||
type Folder struct {
|
type Folder struct {
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` // 主键
|
||||||
Name string `gorm:"size:255;not null" json:"name"`
|
Name string `gorm:"size:255;not null" json:"name"` // 文件夹名称
|
||||||
ParentID *int64 `gorm:"index" json:"parent_id,omitempty"`
|
ParentID *int64 `gorm:"index" json:"parent_id,omitempty"` // 父文件夹ID
|
||||||
Path string `gorm:"uniqueIndex;size:768;not null" json:"path"`
|
Path string `gorm:"uniqueIndex;size:768;not null" json:"path"` // 完整路径(唯一)
|
||||||
UserID *int64 `gorm:"index" json:"user_id,omitempty"`
|
UserID *int64 `gorm:"index" json:"user_id,omitempty"` // 所有者ID
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"` // 创建时间
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"` // 更新时间
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"` // 软删除时间
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Folder) TableName() string {
|
func (Folder) TableName() string {
|
||||||
return "hpc_folders"
|
return "hpc_folders"
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadSession represents an in-progress chunked upload.
|
// UploadSession 表示一个进行中的分块上传会话。
|
||||||
// State transitions: pending→uploading, pending→completed(zero-byte), uploading→merging,
|
// 状态转换: pending→uploading, pending→completed(零字节), uploading→merging,
|
||||||
// uploading→cancelled, merging→completed, merging→failed, any→expired
|
// uploading→cancelled, merging→completed, merging→failed, any→expired
|
||||||
type UploadSession struct {
|
type UploadSession struct {
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` // 主键
|
||||||
FileName string `gorm:"size:255;not null" json:"file_name"`
|
FileName string `gorm:"size:255;not null" json:"file_name"` // 文件名
|
||||||
FileSize int64 `gorm:"not null" json:"file_size"`
|
FileSize int64 `gorm:"not null" json:"file_size"` // 文件总大小
|
||||||
ChunkSize int64 `gorm:"not null" json:"chunk_size"`
|
ChunkSize int64 `gorm:"not null" json:"chunk_size"` // 分块大小
|
||||||
TotalChunks int `gorm:"not null" json:"total_chunks"`
|
TotalChunks int `gorm:"not null" json:"total_chunks"` // 总分块数
|
||||||
SHA256 string `gorm:"size:64;not null" json:"sha256"`
|
SHA256 string `gorm:"size:64;not null" json:"sha256"` // 文件哈希
|
||||||
FolderID *int64 `gorm:"index" json:"folder_id,omitempty"`
|
FolderID *int64 `gorm:"index" json:"folder_id,omitempty"` // 目标文件夹ID
|
||||||
Status string `gorm:"size:20;not null;default:pending" json:"status"`
|
Status string `gorm:"size:20;not null;default:pending" json:"status"` // 会话状态
|
||||||
MinioPrefix string `gorm:"size:255;not null" json:"minio_prefix"`
|
MinioPrefix string `gorm:"size:255;not null" json:"minio_prefix"` // MinIO存储前缀
|
||||||
MimeType string `gorm:"size:255;default:'application/octet-stream'" json:"mime_type"`
|
MimeType string `gorm:"size:255;default:'application/octet-stream'" json:"mime_type"` // MIME类型
|
||||||
UserID *int64 `gorm:"index" json:"user_id,omitempty"`
|
UserID *int64 `gorm:"index" json:"user_id,omitempty"` // 上传者ID
|
||||||
ExpiresAt time.Time `gorm:"not null" json:"expires_at"`
|
ExpiresAt time.Time `gorm:"not null" json:"expires_at"` // 过期时间
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"` // 创建时间
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"` // 更新时间
|
||||||
}
|
}
|
||||||
|
|
||||||
func (UploadSession) TableName() string {
|
func (UploadSession) TableName() string {
|
||||||
return "hpc_upload_sessions"
|
return "hpc_upload_sessions"
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadChunk represents a single chunk of an upload session.
|
// UploadChunk 表示上传会话中的单个分块。
|
||||||
type UploadChunk struct {
|
type UploadChunk struct {
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` // 主键
|
||||||
SessionID int64 `gorm:"not null;uniqueIndex:idx_session_chunk" json:"session_id"`
|
SessionID int64 `gorm:"not null;uniqueIndex:idx_session_chunk" json:"session_id"` // 所属会话ID
|
||||||
ChunkIndex int `gorm:"not null;uniqueIndex:idx_session_chunk" json:"chunk_index"`
|
ChunkIndex int `gorm:"not null;uniqueIndex:idx_session_chunk" json:"chunk_index"` // 分块序号
|
||||||
MinioKey string `gorm:"size:255;not null" json:"minio_key"`
|
MinioKey string `gorm:"size:255;not null" json:"minio_key"` // MinIO对象键
|
||||||
SHA256 string `gorm:"size:64" json:"sha256,omitempty"`
|
SHA256 string `gorm:"size:64" json:"sha256,omitempty"` // 分块哈希
|
||||||
Size int64 `gorm:"not null" json:"size"`
|
Size int64 `gorm:"not null" json:"size"` // 分块大小
|
||||||
Status string `gorm:"size:20;not null;default:pending" json:"status"`
|
Status string `gorm:"size:20;not null;default:pending" json:"status"` // 分块状态
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"` // 创建时间
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"` // 更新时间
|
||||||
}
|
}
|
||||||
|
|
||||||
func (UploadChunk) TableName() string {
|
func (UploadChunk) TableName() string {
|
||||||
return "hpc_upload_chunks"
|
return "hpc_upload_chunks"
|
||||||
}
|
}
|
||||||
|
|
||||||
// InitUploadRequest is the DTO for initiating a chunked upload.
|
// InitUploadRequest 是初始化分块上传的 API 请求。
|
||||||
type InitUploadRequest struct {
|
type InitUploadRequest struct {
|
||||||
FileName string `json:"file_name" binding:"required"`
|
FileName string `json:"file_name" binding:"required"` // 文件名(必填)
|
||||||
FileSize int64 `json:"file_size" binding:"required"`
|
FileSize int64 `json:"file_size" binding:"required"` // 文件大小(必填)
|
||||||
SHA256 string `json:"sha256" binding:"required"`
|
SHA256 string `json:"sha256" binding:"required"` // 文件哈希(必填)
|
||||||
FolderID *int64 `json:"folder_id,omitempty"`
|
FolderID *int64 `json:"folder_id,omitempty"` // 目标文件夹ID
|
||||||
ChunkSize *int64 `json:"chunk_size,omitempty"`
|
ChunkSize *int64 `json:"chunk_size,omitempty"` // 分块大小
|
||||||
MimeType string `json:"mime_type,omitempty"`
|
MimeType string `json:"mime_type,omitempty"` // MIME类型
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateFolderRequest is the DTO for creating a new folder.
|
// CreateFolderRequest 是创建文件夹的 API 请求。
|
||||||
type CreateFolderRequest struct {
|
type CreateFolderRequest struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"` // 文件夹名称(必填)
|
||||||
ParentID *int64 `json:"parent_id,omitempty"`
|
ParentID *int64 `json:"parent_id,omitempty"` // 父文件夹ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadSessionResponse is the DTO returned when creating/querying an upload session.
|
// UploadSessionResponse 是上传会话的 API 响应。
|
||||||
type UploadSessionResponse struct {
|
type UploadSessionResponse struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"` // 主键
|
||||||
FileName string `json:"file_name"`
|
FileName string `json:"file_name"` // 文件名
|
||||||
FileSize int64 `json:"file_size"`
|
FileSize int64 `json:"file_size"` // 文件大小
|
||||||
ChunkSize int64 `json:"chunk_size"`
|
ChunkSize int64 `json:"chunk_size"` // 分块大小
|
||||||
TotalChunks int `json:"total_chunks"`
|
TotalChunks int `json:"total_chunks"` // 总分块数
|
||||||
SHA256 string `json:"sha256"`
|
SHA256 string `json:"sha256"` // 文件哈希
|
||||||
Status string `json:"status"`
|
Status string `json:"status"` // 会话状态
|
||||||
UploadedChunks []int `json:"uploaded_chunks"`
|
UploadedChunks []int `json:"uploaded_chunks"` // 已上传的分块序号列表
|
||||||
ExpiresAt time.Time `json:"expires_at"`
|
ExpiresAt time.Time `json:"expires_at"` // 过期时间
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"` // 创建时间
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileResponse is the DTO for a file in API responses.
|
// FileResponse 是文件 API 响应。
|
||||||
type FileResponse struct {
|
type FileResponse struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"` // 主键
|
||||||
Name string `json:"name"`
|
Name string `json:"name"` // 文件名
|
||||||
FolderID *int64 `json:"folder_id,omitempty"`
|
FolderID *int64 `json:"folder_id"` // 所属文件夹ID
|
||||||
Size int64 `json:"size"`
|
FolderPath *string `json:"folder_path"` // 所属文件夹路径
|
||||||
MimeType string `json:"mime_type"`
|
UserID *int64 `json:"user_id"` // 所有者ID
|
||||||
SHA256 string `json:"sha256"`
|
Size int64 `json:"size"` // 文件大小
|
||||||
CreatedAt time.Time `json:"created_at"`
|
MimeType string `json:"mime_type"` // MIME类型
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
SHA256 string `json:"sha256"` // 文件哈希
|
||||||
|
CreatedAt time.Time `json:"created_at"` // 创建时间
|
||||||
|
UpdatedAt time.Time `json:"updated_at"` // 更新时间
|
||||||
}
|
}
|
||||||
|
|
||||||
// FolderResponse is the DTO for a folder in API responses.
|
// FolderResponse 是文件夹 API 响应。
|
||||||
type FolderResponse struct {
|
type FolderResponse struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"` // 主键
|
||||||
Name string `json:"name"`
|
Name string `json:"name"` // 文件夹名称
|
||||||
ParentID *int64 `json:"parent_id,omitempty"`
|
ParentID *int64 `json:"parent_id,omitempty"` // 父文件夹ID
|
||||||
Path string `json:"path"`
|
Path string `json:"path"` // 完整路径
|
||||||
FileCount int64 `json:"file_count"`
|
FileCount int64 `json:"file_count"` // 文件数量
|
||||||
SubFolderCount int64 `json:"subfolder_count"`
|
SubFolderCount int64 `json:"subfolder_count"` // 子文件夹数量
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"` // 创建时间
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListFilesResponse is the paginated response for listing files.
|
// ListFilesResponse 是文件列表分页响应。
|
||||||
type ListFilesResponse struct {
|
type ListFilesResponse struct {
|
||||||
Files []FileResponse `json:"files"`
|
Files []FileResponse `json:"files"` // 文件列表
|
||||||
Total int64 `json:"total"`
|
Total int64 `json:"total"` // 总数
|
||||||
Page int `json:"page"`
|
Page int `json:"page"` // 页码
|
||||||
PageSize int `json:"page_size"`
|
PageSize int `json:"page_size"` // 每页条数
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateFileName rejects empty, "..", "/", "\", null bytes, control chars, leading/trailing spaces.
|
// ValidateFileName 校验文件名:禁止空值、"..", "/", "\", null字节、控制字符、首尾空格。
|
||||||
func ValidateFileName(name string) error {
|
func ValidateFileName(name string) error {
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return fmt.Errorf("file name cannot be empty")
|
return fmt.Errorf("file name cannot be empty")
|
||||||
@@ -184,7 +186,7 @@ func ValidateFileName(name string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateFolderName rejects same as ValidateFileName plus ".".
|
// ValidateFolderName 校验文件夹名:在 ValidateFileName 基础上额外禁止 "."。
|
||||||
func ValidateFolderName(name string) error {
|
func ValidateFolderName(name string) error {
|
||||||
if name == "." {
|
if name == "." {
|
||||||
return fmt.Errorf("folder name cannot be '.'")
|
return fmt.Errorf("folder name cannot be '.'")
|
||||||
|
|||||||
+29
-6
@@ -1,19 +1,42 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
// SubmitJobRequest is the API request for submitting a job.
|
// SubmitJobRequest 是提交作业的 API 请求。
|
||||||
type SubmitJobRequest struct {
|
type SubmitJobRequest struct {
|
||||||
Script string `json:"script"` // 作业脚本内容
|
Script string `json:"script"` // 作业脚本内容
|
||||||
Partition string `json:"partition,omitempty"` // 提交到的分区
|
Partition string `json:"partition,omitempty"` // 提交到的分区
|
||||||
QOS string `json:"qos,omitempty"` // 使用的 QOS 策略
|
QOS string `json:"qos,omitempty"` // 使用的 QOS 策略
|
||||||
CPUs int32 `json:"cpus,omitempty"` // 请求的 CPU 核数
|
CPUs int32 `json:"cpus,omitempty"` // 请求的 CPU 核数
|
||||||
Memory string `json:"memory,omitempty"` // 请求的内存大小
|
Memory string `json:"memory,omitempty"` // Deprecated: Use MemoryPerNode or MemoryPerCpu instead
|
||||||
TimeLimit string `json:"time_limit,omitempty"` // 运行时间限制 (分钟)
|
TimeLimit string `json:"time_limit,omitempty"` // 运行时间限制 (分钟)
|
||||||
JobName string `json:"job_name,omitempty"` // 作业名称
|
JobName string `json:"job_name,omitempty"` // 作业名称
|
||||||
Environment map[string]string `json:"environment,omitempty"` // 环境变量键值对
|
Environment map[string]string `json:"environment,omitempty"` // 环境变量键值对
|
||||||
WorkDir string `json:"work_dir,omitempty"` // 作业工作目录
|
WorkDir string `json:"work_dir,omitempty"` // 作业工作目录
|
||||||
|
|
||||||
|
MemoryPerNode *int64 `json:"memory_per_node,omitempty"` // 每节点内存(MB)
|
||||||
|
MemoryPerCpu *int64 `json:"memory_per_cpu,omitempty"` // 每CPU内存(MB)
|
||||||
|
Nodes *string `json:"nodes,omitempty"` // 请求的节点数(支持范围如"2-4")
|
||||||
|
Tasks *int32 `json:"tasks,omitempty"` // 任务数
|
||||||
|
CpusPerTask *int32 `json:"cpus_per_task,omitempty"` // 每任务CPU核数
|
||||||
|
Constraints *string `json:"constraints,omitempty"` // 节点特性约束
|
||||||
|
Reservation *string `json:"reservation,omitempty"` // 预约名称
|
||||||
|
Account *string `json:"account,omitempty"` // 计费账户
|
||||||
|
Nice *int32 `json:"nice,omitempty"` // nice调整值
|
||||||
|
MailType *string `json:"mail_type,omitempty"` // 邮件通知类型(逗号分隔)
|
||||||
|
MailUser *string `json:"mail_user,omitempty"` // 邮件地址
|
||||||
|
StandardOutput *string `json:"standard_output,omitempty"` // 标准输出路径
|
||||||
|
StandardError *string `json:"standard_error,omitempty"` // 标准错误路径
|
||||||
|
StandardInput *string `json:"standard_input,omitempty"` // 标准输入路径
|
||||||
|
RequiredNodes *string `json:"required_nodes,omitempty"` // 指定运行的节点(逗号分隔)
|
||||||
|
ExcludedNodes *string `json:"excluded_nodes,omitempty"` // 排除的节点(逗号分隔)
|
||||||
|
BeginTime *int64 `json:"begin_time,omitempty"` // 最早开始时间(Unix时间戳)
|
||||||
|
Deadline *int64 `json:"deadline,omitempty"` // 截止时间(Unix时间戳)
|
||||||
|
Array *string `json:"array,omitempty"` // 数组作业规格
|
||||||
|
Dependency *string `json:"dependency,omitempty"` // 作业依赖关系
|
||||||
|
Requeue *bool `json:"requeue,omitempty"` // 失败后是否重新排队
|
||||||
|
KillOnNodeFail *bool `json:"kill_on_node_fail,omitempty"` // 节点故障时是否终止作业
|
||||||
}
|
}
|
||||||
|
|
||||||
// JobResponse is the API response for a job.
|
// JobResponse 是作业查询 API 响应。
|
||||||
type JobResponse struct {
|
type JobResponse struct {
|
||||||
// Identity
|
// Identity
|
||||||
JobID int32 `json:"job_id"` // Slurm 作业 ID
|
JobID int32 `json:"job_id"` // Slurm 作业 ID
|
||||||
@@ -59,7 +82,7 @@ type JobResponse struct {
|
|||||||
ArrayTaskID *int32 `json:"array_task_id,omitempty"` // 数组作业中的子任务 ID
|
ArrayTaskID *int32 `json:"array_task_id,omitempty"` // 数组作业中的子任务 ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// JobListResponse is the paginated response for job listings.
|
// JobListResponse 是作业列表分页响应。
|
||||||
type JobListResponse struct {
|
type JobListResponse struct {
|
||||||
Jobs []JobResponse `json:"jobs"` // 作业列表
|
Jobs []JobResponse `json:"jobs"` // 作业列表
|
||||||
Total int `json:"total"` // 符合条件的作业总数
|
Total int `json:"total"` // 符合条件的作业总数
|
||||||
@@ -67,13 +90,13 @@ type JobListResponse struct {
|
|||||||
PageSize int `json:"page_size"` // 每页条数
|
PageSize int `json:"page_size"` // 每页条数
|
||||||
}
|
}
|
||||||
|
|
||||||
// JobListQuery contains pagination parameters for active job listing.
|
// JobListQuery 是活跃作业列表查询参数。
|
||||||
type JobListQuery struct {
|
type JobListQuery struct {
|
||||||
Page int `form:"page,default=1" json:"page,omitempty"` // 页码 (从 1 开始)
|
Page int `form:"page,default=1" json:"page,omitempty"` // 页码 (从 1 开始)
|
||||||
PageSize int `form:"page_size,default=20" json:"page_size,omitempty"` // 每页条数
|
PageSize int `form:"page_size,default=20" json:"page_size,omitempty"` // 每页条数
|
||||||
}
|
}
|
||||||
|
|
||||||
// JobHistoryQuery contains query parameters for job history.
|
// JobHistoryQuery 是作业历史查询参数。
|
||||||
type JobHistoryQuery struct {
|
type JobHistoryQuery struct {
|
||||||
Users string `form:"users" json:"users,omitempty"` // 按用户名过滤 (逗号分隔)
|
Users string `form:"users" json:"users,omitempty"` // 按用户名过滤 (逗号分隔)
|
||||||
StartTime string `form:"start_time" json:"start_time,omitempty"` // 作业开始时间下限 (Unix 时间戳)
|
StartTime string `form:"start_time" json:"start_time,omitempty"` // 作业开始时间下限 (Unix 时间戳)
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSubmitJobRequest_SchedulingFields(t *testing.T) {
|
||||||
|
payload := `{
|
||||||
|
"script": "#!/bin/bash\necho hello",
|
||||||
|
"work_dir": "/tmp/work",
|
||||||
|
"partition": "gpu",
|
||||||
|
"qos": "high",
|
||||||
|
"cpus": 16,
|
||||||
|
"memory": "4GB",
|
||||||
|
"time_limit": "60",
|
||||||
|
"job_name": "test-job",
|
||||||
|
"environment": {"PATH": "/usr/bin"},
|
||||||
|
"memory_per_node": 32768,
|
||||||
|
"memory_per_cpu": 4096,
|
||||||
|
"nodes": "2",
|
||||||
|
"tasks": 4,
|
||||||
|
"cpus_per_task": 8,
|
||||||
|
"constraints": "gpu&a100",
|
||||||
|
"reservation": "res-001",
|
||||||
|
"account": "project-x",
|
||||||
|
"nice": 100,
|
||||||
|
"mail_type": "END,FAIL",
|
||||||
|
"mail_user": "user@example.com",
|
||||||
|
"standard_output": "/tmp/out_%j.log",
|
||||||
|
"standard_error": "/tmp/err_%j.log",
|
||||||
|
"standard_input": "/tmp/input.txt",
|
||||||
|
"required_nodes": "node[01-03]",
|
||||||
|
"excluded_nodes": "node04",
|
||||||
|
"begin_time": 1700000000,
|
||||||
|
"deadline": 1700086400,
|
||||||
|
"array": "1-100%10",
|
||||||
|
"dependency": "afterok:12345",
|
||||||
|
"requeue": true,
|
||||||
|
"kill_on_node_fail": false
|
||||||
|
}`
|
||||||
|
|
||||||
|
var req SubmitJobRequest
|
||||||
|
if err := json.Unmarshal([]byte(payload), &req); err != nil {
|
||||||
|
t.Fatalf("unmarshal SubmitJobRequest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing fields
|
||||||
|
if req.Script != "#!/bin/bash\necho hello" {
|
||||||
|
t.Errorf("Script = %q, want script content", req.Script)
|
||||||
|
}
|
||||||
|
if req.WorkDir != "/tmp/work" {
|
||||||
|
t.Errorf("WorkDir = %q, want /tmp/work", req.WorkDir)
|
||||||
|
}
|
||||||
|
if req.Partition != "gpu" {
|
||||||
|
t.Errorf("Partition = %q, want gpu", req.Partition)
|
||||||
|
}
|
||||||
|
if req.QOS != "high" {
|
||||||
|
t.Errorf("QOS = %q, want high", req.QOS)
|
||||||
|
}
|
||||||
|
if req.CPUs != 16 {
|
||||||
|
t.Errorf("CPUs = %d, want 16", req.CPUs)
|
||||||
|
}
|
||||||
|
if req.Memory != "4GB" {
|
||||||
|
t.Errorf("Memory = %q, want 4GB", req.Memory)
|
||||||
|
}
|
||||||
|
if req.TimeLimit != "60" {
|
||||||
|
t.Errorf("TimeLimit = %q, want 60", req.TimeLimit)
|
||||||
|
}
|
||||||
|
if req.JobName != "test-job" {
|
||||||
|
t.Errorf("JobName = %q, want test-job", req.JobName)
|
||||||
|
}
|
||||||
|
if v, ok := req.Environment["PATH"]; !ok || v != "/usr/bin" {
|
||||||
|
t.Errorf("Environment[PATH] = %q, want /usr/bin", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// New scheduling fields
|
||||||
|
if req.MemoryPerNode == nil || *req.MemoryPerNode != 32768 {
|
||||||
|
t.Errorf("MemoryPerNode = %v, want 32768", req.MemoryPerNode)
|
||||||
|
}
|
||||||
|
if req.MemoryPerCpu == nil || *req.MemoryPerCpu != 4096 {
|
||||||
|
t.Errorf("MemoryPerCpu = %v, want 4096", req.MemoryPerCpu)
|
||||||
|
}
|
||||||
|
if req.Nodes == nil || *req.Nodes != "2" {
|
||||||
|
t.Errorf("Nodes = %v, want 2", req.Nodes)
|
||||||
|
}
|
||||||
|
if req.Tasks == nil || *req.Tasks != 4 {
|
||||||
|
t.Errorf("Tasks = %v, want 4", req.Tasks)
|
||||||
|
}
|
||||||
|
if req.CpusPerTask == nil || *req.CpusPerTask != 8 {
|
||||||
|
t.Errorf("CpusPerTask = %v, want 8", req.CpusPerTask)
|
||||||
|
}
|
||||||
|
if req.Constraints == nil || *req.Constraints != "gpu&a100" {
|
||||||
|
t.Errorf("Constraints = %v, want gpu&a100", req.Constraints)
|
||||||
|
}
|
||||||
|
if req.Reservation == nil || *req.Reservation != "res-001" {
|
||||||
|
t.Errorf("Reservation = %v, want res-001", req.Reservation)
|
||||||
|
}
|
||||||
|
if req.Account == nil || *req.Account != "project-x" {
|
||||||
|
t.Errorf("Account = %v, want project-x", req.Account)
|
||||||
|
}
|
||||||
|
if req.Nice == nil || *req.Nice != 100 {
|
||||||
|
t.Errorf("Nice = %v, want 100", req.Nice)
|
||||||
|
}
|
||||||
|
if req.MailType == nil || *req.MailType != "END,FAIL" {
|
||||||
|
t.Errorf("MailType = %v, want END,FAIL", req.MailType)
|
||||||
|
}
|
||||||
|
if req.MailUser == nil || *req.MailUser != "user@example.com" {
|
||||||
|
t.Errorf("MailUser = %v, want user@example.com", req.MailUser)
|
||||||
|
}
|
||||||
|
if req.StandardOutput == nil || *req.StandardOutput != "/tmp/out_%j.log" {
|
||||||
|
t.Errorf("StandardOutput = %v, want /tmp/out_%%j.log", req.StandardOutput)
|
||||||
|
}
|
||||||
|
if req.StandardError == nil || *req.StandardError != "/tmp/err_%j.log" {
|
||||||
|
t.Errorf("StandardError = %v, want /tmp/err_%%j.log", req.StandardError)
|
||||||
|
}
|
||||||
|
if req.StandardInput == nil || *req.StandardInput != "/tmp/input.txt" {
|
||||||
|
t.Errorf("StandardInput = %v, want /tmp/input.txt", req.StandardInput)
|
||||||
|
}
|
||||||
|
if req.RequiredNodes == nil || *req.RequiredNodes != "node[01-03]" {
|
||||||
|
t.Errorf("RequiredNodes = %v, want node[01-03]", req.RequiredNodes)
|
||||||
|
}
|
||||||
|
if req.ExcludedNodes == nil || *req.ExcludedNodes != "node04" {
|
||||||
|
t.Errorf("ExcludedNodes = %v, want node04", req.ExcludedNodes)
|
||||||
|
}
|
||||||
|
if req.BeginTime == nil || *req.BeginTime != 1700000000 {
|
||||||
|
t.Errorf("BeginTime = %v, want 1700000000", req.BeginTime)
|
||||||
|
}
|
||||||
|
if req.Deadline == nil || *req.Deadline != 1700086400 {
|
||||||
|
t.Errorf("Deadline = %v, want 1700086400", req.Deadline)
|
||||||
|
}
|
||||||
|
if req.Array == nil || *req.Array != "1-100%10" {
|
||||||
|
t.Errorf("Array = %v, want 1-100%%10", req.Array)
|
||||||
|
}
|
||||||
|
if req.Dependency == nil || *req.Dependency != "afterok:12345" {
|
||||||
|
t.Errorf("Dependency = %v, want afterok:12345", req.Dependency)
|
||||||
|
}
|
||||||
|
if req.Requeue == nil || *req.Requeue != true {
|
||||||
|
t.Errorf("Requeue = %v, want true", req.Requeue)
|
||||||
|
}
|
||||||
|
if req.KillOnNodeFail == nil || *req.KillOnNodeFail != false {
|
||||||
|
t.Errorf("KillOnNodeFail = %v, want false", req.KillOnNodeFail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmitJobRequest_BackwardCompat(t *testing.T) {
|
||||||
|
// Minimal JSON — only required fields
|
||||||
|
payload := `{"script": "#!/bin/bash\necho hello", "work_dir": "/tmp"}`
|
||||||
|
|
||||||
|
var req SubmitJobRequest
|
||||||
|
if err := json.Unmarshal([]byte(payload), &req); err != nil {
|
||||||
|
t.Fatalf("unmarshal minimal SubmitJobRequest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Required fields present
|
||||||
|
if req.Script != "#!/bin/bash\necho hello" {
|
||||||
|
t.Errorf("Script = %q, want script content", req.Script)
|
||||||
|
}
|
||||||
|
if req.WorkDir != "/tmp" {
|
||||||
|
t.Errorf("WorkDir = %q, want /tmp", req.WorkDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Old fields exist with zero values
|
||||||
|
if req.Memory != "" {
|
||||||
|
t.Errorf("Memory = %q, want empty", req.Memory)
|
||||||
|
}
|
||||||
|
if req.Environment != nil {
|
||||||
|
t.Errorf("Environment = %v, want nil", req.Environment)
|
||||||
|
}
|
||||||
|
|
||||||
|
// All new scheduling fields are nil
|
||||||
|
assertNil := func(name string, val any) {
|
||||||
|
if !reflect.ValueOf(val).IsNil() {
|
||||||
|
t.Errorf("%s = %v, want nil", name, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertNil("MemoryPerNode", req.MemoryPerNode)
|
||||||
|
assertNil("MemoryPerCpu", req.MemoryPerCpu)
|
||||||
|
assertNil("Nodes", req.Nodes)
|
||||||
|
assertNil("Tasks", req.Tasks)
|
||||||
|
assertNil("CpusPerTask", req.CpusPerTask)
|
||||||
|
assertNil("Constraints", req.Constraints)
|
||||||
|
assertNil("Reservation", req.Reservation)
|
||||||
|
assertNil("Account", req.Account)
|
||||||
|
assertNil("Nice", req.Nice)
|
||||||
|
assertNil("MailType", req.MailType)
|
||||||
|
assertNil("MailUser", req.MailUser)
|
||||||
|
assertNil("StandardOutput", req.StandardOutput)
|
||||||
|
assertNil("StandardError", req.StandardError)
|
||||||
|
assertNil("StandardInput", req.StandardInput)
|
||||||
|
assertNil("RequiredNodes", req.RequiredNodes)
|
||||||
|
assertNil("ExcludedNodes", req.ExcludedNodes)
|
||||||
|
assertNil("BeginTime", req.BeginTime)
|
||||||
|
assertNil("Deadline", req.Deadline)
|
||||||
|
assertNil("Array", req.Array)
|
||||||
|
assertNil("Dependency", req.Dependency)
|
||||||
|
assertNil("Requeue", req.Requeue)
|
||||||
|
assertNil("KillOnNodeFail", req.KillOnNodeFail)
|
||||||
|
}
|
||||||
+129
-49
@@ -7,7 +7,7 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Task status constants.
|
// 任务状态常量
|
||||||
const (
|
const (
|
||||||
TaskStatusSubmitted = "submitted"
|
TaskStatusSubmitted = "submitted"
|
||||||
TaskStatusPreparing = "preparing"
|
TaskStatusPreparing = "preparing"
|
||||||
@@ -19,75 +19,155 @@ const (
|
|||||||
TaskStatusFailed = "failed"
|
TaskStatusFailed = "failed"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Task step constants for step-level retry tracking.
|
// 任务步骤常量,用于步骤级重试追踪
|
||||||
const (
|
const (
|
||||||
TaskStepPreparing = "preparing"
|
TaskStepPreparing = "preparing"
|
||||||
TaskStepDownloading = "downloading"
|
TaskStepDownloading = "downloading"
|
||||||
TaskStepSubmitting = "submitting"
|
TaskStepSubmitting = "submitting"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Task represents an HPC task submitted through the application framework.
|
// Task 表示通过应用框架提交的 HPC 任务记录。
|
||||||
type Task struct {
|
type Task struct {
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` // 主键
|
||||||
TaskName string `gorm:"size:255" json:"task_name"`
|
TaskName string `gorm:"size:255" json:"task_name"` // 任务名称
|
||||||
AppID int64 `json:"app_id"`
|
AppID int64 `json:"app_id"` // 所属应用ID
|
||||||
AppName string `gorm:"size:255" json:"app_name"`
|
AppName string `gorm:"size:255" json:"app_name"` // 应用名称
|
||||||
Status string `json:"status"`
|
Status string `json:"status"` // 任务状态
|
||||||
CurrentStep string `json:"current_step"`
|
CurrentStep string `json:"current_step"` // 当前执行步骤
|
||||||
RetryCount int `json:"retry_count"`
|
RetryCount int `json:"retry_count"` // 重试次数
|
||||||
Values json.RawMessage `gorm:"type:text" json:"values,omitempty"`
|
Values json.RawMessage `gorm:"type:text" json:"values,omitempty"` // 业务参数JSON
|
||||||
InputFileIDs json.RawMessage `json:"input_file_ids" gorm:"column:input_file_ids;type:text"`
|
InputFileIDs json.RawMessage `json:"input_file_ids" gorm:"column:input_file_ids;type:text"` // 输入文件ID列表JSON
|
||||||
Script string `json:"script,omitempty"`
|
Script string `json:"script,omitempty"` // 渲染后的脚本内容
|
||||||
SlurmJobID *int32 `json:"slurm_job_id,omitempty"`
|
SlurmJobID *int32 `json:"slurm_job_id,omitempty"` // Slurm作业ID
|
||||||
WorkDir string `json:"work_dir,omitempty"`
|
WorkDir string `json:"work_dir,omitempty"` // 工作目录
|
||||||
Partition string `json:"partition,omitempty"`
|
Partition string `json:"partition,omitempty"` // 提交到的分区(空字符串表示未设置)
|
||||||
ErrorMessage string `json:"error_message,omitempty"`
|
ErrorMessage string `json:"error_message,omitempty"` // 错误信息
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"` // 提交用户ID
|
||||||
SubmittedAt time.Time `json:"submitted_at"`
|
SubmittedAt time.Time `json:"submitted_at"` // 提交时间
|
||||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
StartedAt *time.Time `json:"started_at,omitempty"` // 开始运行时间
|
||||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
FinishedAt *time.Time `json:"finished_at,omitempty"` // 完成时间
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"` // 创建时间
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"` // 更新时间
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"` // 软删除时间
|
||||||
|
Cpus *int32 // 请求的CPU核数
|
||||||
|
MemoryPerNode *int64 // 每节点内存(MB)
|
||||||
|
MemoryPerCpu *int64 // 每CPU内存(MB)
|
||||||
|
TimeLimit *int32 // 运行时间限制(分钟)
|
||||||
|
QOS *string // 服务质量策略
|
||||||
|
JobName *string // 作业名称
|
||||||
|
Nodes *string // 请求的节点数(支持范围如"2-4")
|
||||||
|
Tasks *int32 // 任务数
|
||||||
|
CpusPerTask *int32 // 每任务CPU核数
|
||||||
|
Constraints *string // 节点特性约束
|
||||||
|
Reservation *string // 预约名称
|
||||||
|
Account *string // 计费账户
|
||||||
|
Nice *int32 // nice调整值
|
||||||
|
MailType *string // 邮件通知类型(逗号分隔)
|
||||||
|
MailUser *string // 邮件地址
|
||||||
|
StandardOutput *string // 标准输出路径
|
||||||
|
StandardError *string // 标准错误路径
|
||||||
|
StandardInput *string // 标准输入路径
|
||||||
|
RequiredNodes *string // 指定运行的节点(逗号分隔)
|
||||||
|
ExcludedNodes *string // 排除的节点(逗号分隔)
|
||||||
|
BeginTime *int64 // 最早开始时间(Unix时间戳)
|
||||||
|
Deadline *int64 // 截止时间(Unix时间戳)
|
||||||
|
Array *string // 数组作业规格
|
||||||
|
Dependency *string // 作业依赖关系
|
||||||
|
Requeue *bool // 失败后是否重新排队
|
||||||
|
KillOnNodeFail *bool // 节点故障时是否终止作业
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Task) TableName() string {
|
func (Task) TableName() string {
|
||||||
return "hpc_tasks"
|
return "hpc_tasks"
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateTaskRequest is the DTO for creating a new task.
|
// CreateTaskRequest 是创建任务的 API 请求。
|
||||||
type CreateTaskRequest struct {
|
type CreateTaskRequest struct {
|
||||||
AppID int64 `json:"app_id" binding:"required"`
|
AppID int64 `json:"app_id" binding:"required"` // 所属应用ID(必填)
|
||||||
TaskName string `json:"task_name"`
|
TaskName string `json:"task_name"` // 任务名称
|
||||||
Values map[string]string `json:"values"`
|
Values map[string]string `json:"values"` // 脚本业务参数键值对
|
||||||
InputFileIDs []int64 `json:"file_ids"`
|
InputFileIDs []int64 `json:"file_ids"` // 输入文件ID列表
|
||||||
|
Partition *string `json:"partition,omitempty"` // 提交到的分区
|
||||||
|
Cpus *int32 `json:"cpus,omitempty"` // 请求的CPU核数
|
||||||
|
MemoryPerNode *int64 `json:"memory_per_node,omitempty"` // 每节点内存(MB)
|
||||||
|
MemoryPerCpu *int64 `json:"memory_per_cpu,omitempty"` // 每CPU内存(MB)
|
||||||
|
TimeLimit *int32 `json:"time_limit,omitempty"` // 运行时间限制(分钟)
|
||||||
|
QOS *string `json:"qos,omitempty"` // 服务质量策略
|
||||||
|
JobName *string `json:"job_name,omitempty"` // 作业名称
|
||||||
|
Nodes *string `json:"nodes,omitempty"` // 请求的节点数(支持范围如"2-4")
|
||||||
|
Tasks *int32 `json:"tasks,omitempty"` // 任务数
|
||||||
|
CpusPerTask *int32 `json:"cpus_per_task,omitempty"` // 每任务CPU核数
|
||||||
|
Constraints *string `json:"constraints,omitempty"` // 节点特性约束
|
||||||
|
Reservation *string `json:"reservation,omitempty"` // 预约名称
|
||||||
|
Account *string `json:"account,omitempty"` // 计费账户
|
||||||
|
Nice *int32 `json:"nice,omitempty"` // nice调整值
|
||||||
|
MailType *string `json:"mail_type,omitempty"` // 邮件通知类型(逗号分隔)
|
||||||
|
MailUser *string `json:"mail_user,omitempty"` // 邮件地址
|
||||||
|
StandardOutput *string `json:"standard_output,omitempty"` // 标准输出路径
|
||||||
|
StandardError *string `json:"standard_error,omitempty"` // 标准错误路径
|
||||||
|
StandardInput *string `json:"standard_input,omitempty"` // 标准输入路径
|
||||||
|
RequiredNodes *string `json:"required_nodes,omitempty"` // 指定运行的节点(逗号分隔)
|
||||||
|
ExcludedNodes *string `json:"excluded_nodes,omitempty"` // 排除的节点(逗号分隔)
|
||||||
|
BeginTime *int64 `json:"begin_time,omitempty"` // 最早开始时间(Unix时间戳)
|
||||||
|
Deadline *int64 `json:"deadline,omitempty"` // 截止时间(Unix时间戳)
|
||||||
|
Array *string `json:"array,omitempty"` // 数组作业规格
|
||||||
|
Dependency *string `json:"dependency,omitempty"` // 作业依赖关系
|
||||||
|
Requeue *bool `json:"requeue,omitempty"` // 失败后是否重新排队
|
||||||
|
KillOnNodeFail *bool `json:"kill_on_node_fail,omitempty"` // 节点故障时是否终止作业
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskResponse is the DTO returned in API responses.
|
// TaskResponse 是任务列表/详情 API 响应中的任务项。
|
||||||
type TaskResponse struct {
|
type TaskResponse struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"` // 主键
|
||||||
TaskName string `json:"task_name"`
|
TaskName string `json:"task_name"` // 任务名称
|
||||||
AppID int64 `json:"app_id"`
|
AppID int64 `json:"app_id"` // 所属应用ID
|
||||||
AppName string `json:"app_name"`
|
AppName string `json:"app_name"` // 应用名称
|
||||||
Status string `json:"status"`
|
Status string `json:"status"` // 任务状态
|
||||||
CurrentStep string `json:"current_step"`
|
CurrentStep string `json:"current_step"` // 当前执行步骤
|
||||||
RetryCount int `json:"retry_count"`
|
RetryCount int `json:"retry_count"` // 重试次数
|
||||||
SlurmJobID *int32 `json:"slurm_job_id"`
|
SlurmJobID *int32 `json:"slurm_job_id"` // Slurm作业ID
|
||||||
WorkDir string `json:"work_dir"`
|
WorkDir string `json:"work_dir"` // 工作目录
|
||||||
ErrorMessage string `json:"error_message"`
|
ErrorMessage string `json:"error_message"` // 错误信息
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"` // 创建时间
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"` // 更新时间
|
||||||
|
Partition string `json:"partition,omitempty"` // 提交到的分区
|
||||||
|
Cpus *int32 `json:"cpus,omitempty"` // 请求的CPU核数
|
||||||
|
MemoryPerNode *int64 `json:"memory_per_node,omitempty"` // 每节点内存(MB)
|
||||||
|
MemoryPerCpu *int64 `json:"memory_per_cpu,omitempty"` // 每CPU内存(MB)
|
||||||
|
TimeLimit *int32 `json:"time_limit,omitempty"` // 运行时间限制(分钟)
|
||||||
|
QOS *string `json:"qos,omitempty"` // 服务质量策略
|
||||||
|
JobName *string `json:"job_name,omitempty"` // 作业名称
|
||||||
|
Nodes *string `json:"nodes,omitempty"` // 请求的节点数(支持范围如"2-4")
|
||||||
|
Tasks *int32 `json:"tasks,omitempty"` // 任务数
|
||||||
|
CpusPerTask *int32 `json:"cpus_per_task,omitempty"` // 每任务CPU核数
|
||||||
|
Constraints *string `json:"constraints,omitempty"` // 节点特性约束
|
||||||
|
Reservation *string `json:"reservation,omitempty"` // 预约名称
|
||||||
|
Account *string `json:"account,omitempty"` // 计费账户
|
||||||
|
Nice *int32 `json:"nice,omitempty"` // nice调整值
|
||||||
|
MailType *string `json:"mail_type,omitempty"` // 邮件通知类型(逗号分隔)
|
||||||
|
MailUser *string `json:"mail_user,omitempty"` // 邮件地址
|
||||||
|
StandardOutput *string `json:"standard_output,omitempty"` // 标准输出路径
|
||||||
|
StandardError *string `json:"standard_error,omitempty"` // 标准错误路径
|
||||||
|
StandardInput *string `json:"standard_input,omitempty"` // 标准输入路径
|
||||||
|
RequiredNodes *string `json:"required_nodes,omitempty"` // 指定运行的节点(逗号分隔)
|
||||||
|
ExcludedNodes *string `json:"excluded_nodes,omitempty"` // 排除的节点(逗号分隔)
|
||||||
|
BeginTime *int64 `json:"begin_time,omitempty"` // 最早开始时间(Unix时间戳)
|
||||||
|
Deadline *int64 `json:"deadline,omitempty"` // 截止时间(Unix时间戳)
|
||||||
|
Array *string `json:"array,omitempty"` // 数组作业规格
|
||||||
|
Dependency *string `json:"dependency,omitempty"` // 作业依赖关系
|
||||||
|
Requeue *bool `json:"requeue,omitempty"` // 失败后是否重新排队
|
||||||
|
KillOnNodeFail *bool `json:"kill_on_node_fail,omitempty"` // 节点故障时是否终止作业
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskListResponse is the paginated response for listing tasks.
|
// TaskListResponse 是任务列表分页响应。
|
||||||
type TaskListResponse struct {
|
type TaskListResponse struct {
|
||||||
Items []TaskResponse `json:"items"`
|
Items []TaskResponse `json:"items"` // 任务列表
|
||||||
Total int64 `json:"total"`
|
Total int64 `json:"total"` // 总数
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskListQuery contains query parameters for listing tasks.
|
// TaskListQuery 是任务列表查询参数。
|
||||||
type TaskListQuery struct {
|
type TaskListQuery struct {
|
||||||
Page int `form:"page" json:"page,omitempty"`
|
Page int `form:"page" json:"page,omitempty"` // 页码
|
||||||
PageSize int `form:"page_size" json:"page_size,omitempty"`
|
PageSize int `form:"page_size" json:"page_size,omitempty"` // 每页条数
|
||||||
Status string `form:"status" json:"status,omitempty"`
|
Status string `form:"status" json:"status,omitempty"` // 按状态过滤
|
||||||
}
|
}
|
||||||
|
|||||||
+338
-20
@@ -17,27 +17,33 @@ func TestTask_JSONRoundTrip(t *testing.T) {
|
|||||||
now := time.Now().UTC().Truncate(time.Second)
|
now := time.Now().UTC().Truncate(time.Second)
|
||||||
jobID := int32(42)
|
jobID := int32(42)
|
||||||
|
|
||||||
|
cpus := int32(16)
|
||||||
|
memPerNode := int64(32768)
|
||||||
|
timeLimit := int32(60)
|
||||||
task := Task{
|
task := Task{
|
||||||
ID: 1,
|
ID: 1,
|
||||||
TaskName: "test task",
|
TaskName: "test task",
|
||||||
AppID: 10,
|
AppID: 10,
|
||||||
AppName: "GROMACS",
|
AppName: "GROMACS",
|
||||||
Status: TaskStatusRunning,
|
Status: TaskStatusRunning,
|
||||||
CurrentStep: TaskStepSubmitting,
|
CurrentStep: TaskStepSubmitting,
|
||||||
RetryCount: 1,
|
RetryCount: 1,
|
||||||
Values: json.RawMessage(`{"np":"4"}`),
|
Values: json.RawMessage(`{"np":"4"}`),
|
||||||
InputFileIDs: json.RawMessage(`[1,2,3]`),
|
InputFileIDs: json.RawMessage(`[1,2,3]`),
|
||||||
Script: "#!/bin/bash",
|
Script: "#!/bin/bash",
|
||||||
SlurmJobID: &jobID,
|
SlurmJobID: &jobID,
|
||||||
WorkDir: "/data/work",
|
WorkDir: "/data/work",
|
||||||
Partition: "gpu",
|
Partition: "gpu",
|
||||||
ErrorMessage: "",
|
ErrorMessage: "",
|
||||||
UserID: "user1",
|
UserID: "user1",
|
||||||
SubmittedAt: now,
|
SubmittedAt: now,
|
||||||
StartedAt: &now,
|
StartedAt: &now,
|
||||||
FinishedAt: nil,
|
FinishedAt: nil,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
|
Cpus: &cpus,
|
||||||
|
MemoryPerNode: &memPerNode,
|
||||||
|
TimeLimit: &timeLimit,
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := json.Marshal(task)
|
data, err := json.Marshal(task)
|
||||||
@@ -80,6 +86,18 @@ func TestTask_JSONRoundTrip(t *testing.T) {
|
|||||||
if got.FinishedAt != nil {
|
if got.FinishedAt != nil {
|
||||||
t.Errorf("FinishedAt = %v, want nil", got.FinishedAt)
|
t.Errorf("FinishedAt = %v, want nil", got.FinishedAt)
|
||||||
}
|
}
|
||||||
|
if got.Partition != task.Partition {
|
||||||
|
t.Errorf("Partition = %q, want %q", got.Partition, task.Partition)
|
||||||
|
}
|
||||||
|
if got.Cpus == nil || *got.Cpus != cpus {
|
||||||
|
t.Errorf("Cpus = %v, want %d", got.Cpus, cpus)
|
||||||
|
}
|
||||||
|
if got.MemoryPerNode == nil || *got.MemoryPerNode != memPerNode {
|
||||||
|
t.Errorf("MemoryPerNode = %v, want %d", got.MemoryPerNode, memPerNode)
|
||||||
|
}
|
||||||
|
if got.TimeLimit == nil || *got.TimeLimit != timeLimit {
|
||||||
|
t.Errorf("TimeLimit = %v, want %d", got.TimeLimit, timeLimit)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateTaskRequest_JSONBinding(t *testing.T) {
|
func TestCreateTaskRequest_JSONBinding(t *testing.T) {
|
||||||
@@ -102,3 +120,303 @@ func TestCreateTaskRequest_JSONBinding(t *testing.T) {
|
|||||||
t.Errorf("InputFileIDs = %v, want [10 20]", req.InputFileIDs)
|
t.Errorf("InputFileIDs = %v, want [10 20]", req.InputFileIDs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTaskResponse_JSONSerialization(t *testing.T) {
|
||||||
|
cpus := int32(8)
|
||||||
|
memPerNode := int64(16384)
|
||||||
|
memPerCpu := int64(4096)
|
||||||
|
timeLimit := int32(120)
|
||||||
|
qos := "high"
|
||||||
|
jobName := "gmx-md"
|
||||||
|
nodes := "2"
|
||||||
|
tasks := int32(4)
|
||||||
|
cpusPerTask := int32(2)
|
||||||
|
constraints := "haswell"
|
||||||
|
reservation := "my-resv"
|
||||||
|
account := "proj-123"
|
||||||
|
nice := int32(100)
|
||||||
|
mailType := "END"
|
||||||
|
mailUser := "user@example.com"
|
||||||
|
stdout := "/tmp/%j.out"
|
||||||
|
stderr := "/tmp/%j.err"
|
||||||
|
stdin := "/dev/null"
|
||||||
|
reqNodes := "node[01-03]"
|
||||||
|
exclNodes := "node04"
|
||||||
|
beginTime := int64(1700000000)
|
||||||
|
deadline := int64(1700086400)
|
||||||
|
array := "1-10"
|
||||||
|
dependency := "afterok:12345"
|
||||||
|
requeue := true
|
||||||
|
killOnNodeFail := true
|
||||||
|
|
||||||
|
resp := TaskResponse{
|
||||||
|
ID: 1,
|
||||||
|
TaskName: "test",
|
||||||
|
AppID: 10,
|
||||||
|
AppName: "GROMACS",
|
||||||
|
Status: "running",
|
||||||
|
CurrentStep: "submitting",
|
||||||
|
RetryCount: 0,
|
||||||
|
SlurmJobID: nil,
|
||||||
|
WorkDir: "/data",
|
||||||
|
ErrorMessage: "",
|
||||||
|
CreatedAt: time.Now().UTC().Truncate(time.Second),
|
||||||
|
UpdatedAt: time.Now().UTC().Truncate(time.Second),
|
||||||
|
Partition: "gpu",
|
||||||
|
Cpus: &cpus,
|
||||||
|
MemoryPerNode: &memPerNode,
|
||||||
|
MemoryPerCpu: &memPerCpu,
|
||||||
|
TimeLimit: &timeLimit,
|
||||||
|
QOS: &qos,
|
||||||
|
JobName: &jobName,
|
||||||
|
Nodes: &nodes,
|
||||||
|
Tasks: &tasks,
|
||||||
|
CpusPerTask: &cpusPerTask,
|
||||||
|
Constraints: &constraints,
|
||||||
|
Reservation: &reservation,
|
||||||
|
Account: &account,
|
||||||
|
Nice: &nice,
|
||||||
|
MailType: &mailType,
|
||||||
|
MailUser: &mailUser,
|
||||||
|
StandardOutput: &stdout,
|
||||||
|
StandardError: &stderr,
|
||||||
|
StandardInput: &stdin,
|
||||||
|
RequiredNodes: &reqNodes,
|
||||||
|
ExcludedNodes: &exclNodes,
|
||||||
|
BeginTime: &beginTime,
|
||||||
|
Deadline: &deadline,
|
||||||
|
Array: &array,
|
||||||
|
Dependency: &dependency,
|
||||||
|
Requeue: &requeue,
|
||||||
|
KillOnNodeFail: &killOnNodeFail,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(resp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal TaskResponse: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var m map[string]interface{}
|
||||||
|
if err := json.Unmarshal(data, &m); err != nil {
|
||||||
|
t.Fatalf("unmarshal to map: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertString(t, m, "partition", "gpu")
|
||||||
|
assertFloat64(t, m, "cpus", float64(cpus))
|
||||||
|
assertFloat64(t, m, "memory_per_node", float64(memPerNode))
|
||||||
|
assertFloat64(t, m, "memory_per_cpu", float64(memPerCpu))
|
||||||
|
assertFloat64(t, m, "time_limit", float64(timeLimit))
|
||||||
|
assertString(t, m, "qos", qos)
|
||||||
|
assertString(t, m, "job_name", jobName)
|
||||||
|
assertString(t, m, "nodes", nodes)
|
||||||
|
assertFloat64(t, m, "tasks", float64(tasks))
|
||||||
|
assertFloat64(t, m, "cpus_per_task", float64(cpusPerTask))
|
||||||
|
assertString(t, m, "constraints", constraints)
|
||||||
|
assertString(t, m, "reservation", reservation)
|
||||||
|
assertString(t, m, "account", account)
|
||||||
|
assertFloat64(t, m, "nice", float64(nice))
|
||||||
|
assertString(t, m, "mail_type", mailType)
|
||||||
|
assertString(t, m, "mail_user", mailUser)
|
||||||
|
assertString(t, m, "standard_output", stdout)
|
||||||
|
assertString(t, m, "standard_error", stderr)
|
||||||
|
assertString(t, m, "standard_input", stdin)
|
||||||
|
assertString(t, m, "required_nodes", reqNodes)
|
||||||
|
assertString(t, m, "excluded_nodes", exclNodes)
|
||||||
|
assertFloat64(t, m, "begin_time", float64(beginTime))
|
||||||
|
assertFloat64(t, m, "deadline", float64(deadline))
|
||||||
|
assertString(t, m, "array", array)
|
||||||
|
assertString(t, m, "dependency", dependency)
|
||||||
|
assertBool(t, m, "requeue", requeue)
|
||||||
|
assertBool(t, m, "kill_on_node_fail", killOnNodeFail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTaskRequest_SchedulingFields(t *testing.T) {
|
||||||
|
payload := `{
|
||||||
|
"app_id": 5,
|
||||||
|
"partition": "gpu",
|
||||||
|
"cpus": 16,
|
||||||
|
"memory_per_node": 32768,
|
||||||
|
"memory_per_cpu": 4096,
|
||||||
|
"time_limit": 120,
|
||||||
|
"qos": "high",
|
||||||
|
"job_name": "gmx-sim",
|
||||||
|
"nodes": "2",
|
||||||
|
"tasks": 4,
|
||||||
|
"cpus_per_task": 2,
|
||||||
|
"constraints": "haswell",
|
||||||
|
"reservation": "my-resv",
|
||||||
|
"account": "proj-123",
|
||||||
|
"nice": 50,
|
||||||
|
"mail_type": "ALL",
|
||||||
|
"mail_user": "user@example.com",
|
||||||
|
"standard_output": "/tmp/%j.out",
|
||||||
|
"standard_error": "/tmp/%j.err",
|
||||||
|
"standard_input": "/dev/null",
|
||||||
|
"required_nodes": "node[01-03]",
|
||||||
|
"excluded_nodes": "node04",
|
||||||
|
"begin_time": 1700000000,
|
||||||
|
"deadline": 1700086400,
|
||||||
|
"array": "1-10",
|
||||||
|
"dependency": "afterok:12345",
|
||||||
|
"requeue": true,
|
||||||
|
"kill_on_node_fail": false
|
||||||
|
}`
|
||||||
|
|
||||||
|
var req CreateTaskRequest
|
||||||
|
if err := json.Unmarshal([]byte(payload), &req); err != nil {
|
||||||
|
t.Fatalf("unmarshal CreateTaskRequest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.AppID != 5 {
|
||||||
|
t.Errorf("AppID = %d, want 5", req.AppID)
|
||||||
|
}
|
||||||
|
assertPtrString(t, req.Partition, "gpu")
|
||||||
|
assertPtrInt32(t, req.Cpus, 16)
|
||||||
|
assertPtrInt64(t, req.MemoryPerNode, 32768)
|
||||||
|
assertPtrInt64(t, req.MemoryPerCpu, 4096)
|
||||||
|
assertPtrInt32(t, req.TimeLimit, 120)
|
||||||
|
assertPtrString(t, req.QOS, "high")
|
||||||
|
assertPtrString(t, req.JobName, "gmx-sim")
|
||||||
|
assertPtrString(t, req.Nodes, "2")
|
||||||
|
assertPtrInt32(t, req.Tasks, 4)
|
||||||
|
assertPtrInt32(t, req.CpusPerTask, 2)
|
||||||
|
assertPtrString(t, req.Constraints, "haswell")
|
||||||
|
assertPtrString(t, req.Reservation, "my-resv")
|
||||||
|
assertPtrString(t, req.Account, "proj-123")
|
||||||
|
assertPtrInt32(t, req.Nice, 50)
|
||||||
|
assertPtrString(t, req.MailType, "ALL")
|
||||||
|
assertPtrString(t, req.MailUser, "user@example.com")
|
||||||
|
assertPtrString(t, req.StandardOutput, "/tmp/%j.out")
|
||||||
|
assertPtrString(t, req.StandardError, "/tmp/%j.err")
|
||||||
|
assertPtrString(t, req.StandardInput, "/dev/null")
|
||||||
|
assertPtrString(t, req.RequiredNodes, "node[01-03]")
|
||||||
|
assertPtrString(t, req.ExcludedNodes, "node04")
|
||||||
|
assertPtrInt64(t, req.BeginTime, 1700000000)
|
||||||
|
assertPtrInt64(t, req.Deadline, 1700086400)
|
||||||
|
assertPtrString(t, req.Array, "1-10")
|
||||||
|
assertPtrString(t, req.Dependency, "afterok:12345")
|
||||||
|
assertPtrBool(t, req.Requeue, true)
|
||||||
|
assertPtrBool(t, req.KillOnNodeFail, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTaskRequest_BackwardCompat(t *testing.T) {
|
||||||
|
payload := `{"app_id": 1}`
|
||||||
|
var req CreateTaskRequest
|
||||||
|
if err := json.Unmarshal([]byte(payload), &req); err != nil {
|
||||||
|
t.Fatalf("unmarshal minimal CreateTaskRequest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.AppID != 1 {
|
||||||
|
t.Errorf("AppID = %d, want 1", req.AppID)
|
||||||
|
}
|
||||||
|
if req.Partition != nil {
|
||||||
|
t.Errorf("Partition = %v, want nil", req.Partition)
|
||||||
|
}
|
||||||
|
if req.Cpus != nil {
|
||||||
|
t.Errorf("Cpus = %v, want nil", req.Cpus)
|
||||||
|
}
|
||||||
|
if req.MemoryPerNode != nil {
|
||||||
|
t.Errorf("MemoryPerNode = %v, want nil", req.MemoryPerNode)
|
||||||
|
}
|
||||||
|
if req.MemoryPerCpu != nil {
|
||||||
|
t.Errorf("MemoryPerCpu = %v, want nil", req.MemoryPerCpu)
|
||||||
|
}
|
||||||
|
if req.TimeLimit != nil {
|
||||||
|
t.Errorf("TimeLimit = %v, want nil", req.TimeLimit)
|
||||||
|
}
|
||||||
|
if req.QOS != nil {
|
||||||
|
t.Errorf("QOS = %v, want nil", req.QOS)
|
||||||
|
}
|
||||||
|
if req.Nodes != nil {
|
||||||
|
t.Errorf("Nodes = %v, want nil", req.Nodes)
|
||||||
|
}
|
||||||
|
if req.Tasks != nil {
|
||||||
|
t.Errorf("Tasks = %v, want nil", req.Tasks)
|
||||||
|
}
|
||||||
|
if req.Requeue != nil {
|
||||||
|
t.Errorf("Requeue = %v, want nil", req.Requeue)
|
||||||
|
}
|
||||||
|
if req.KillOnNodeFail != nil {
|
||||||
|
t.Errorf("KillOnNodeFail = %v, want nil", req.KillOnNodeFail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertString(t *testing.T, m map[string]interface{}, key, want string) {
|
||||||
|
t.Helper()
|
||||||
|
got, ok := m[key].(string)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("%s: not a string, got %T", key, m[key])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("%s = %q, want %q", key, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertFloat64(t *testing.T, m map[string]interface{}, key string, want float64) {
|
||||||
|
t.Helper()
|
||||||
|
got, ok := m[key].(float64)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("%s: not a float64, got %T", key, m[key])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("%s = %v, want %v", key, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertBool(t *testing.T, m map[string]interface{}, key string, want bool) {
|
||||||
|
t.Helper()
|
||||||
|
got, ok := m[key].(bool)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("%s: not a bool, got %T", key, m[key])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("%s = %v, want %v", key, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertPtrString(t *testing.T, got *string, want string) {
|
||||||
|
t.Helper()
|
||||||
|
if got == nil {
|
||||||
|
t.Errorf("got nil, want %q", want)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if *got != want {
|
||||||
|
t.Errorf("got %q, want %q", *got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertPtrInt32(t *testing.T, got *int32, want int32) {
|
||||||
|
t.Helper()
|
||||||
|
if got == nil {
|
||||||
|
t.Errorf("got nil, want %d", want)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if *got != want {
|
||||||
|
t.Errorf("got %d, want %d", *got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertPtrInt64(t *testing.T, got *int64, want int64) {
|
||||||
|
t.Helper()
|
||||||
|
if got == nil {
|
||||||
|
t.Errorf("got nil, want %d", want)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if *got != want {
|
||||||
|
t.Errorf("got %d, want %d", *got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertPtrBool(t *testing.T, got *bool, want bool) {
|
||||||
|
t.Helper()
|
||||||
|
if got == nil {
|
||||||
|
t.Errorf("got nil, want %v", want)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if *got != want {
|
||||||
|
t.Errorf("got %v, want %v", *got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -104,6 +104,89 @@ func TestValidateParams_BooleanValues(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateParams_FileTypeValid(t *testing.T) {
|
||||||
|
params := []model.ParameterSchema{
|
||||||
|
{Name: "MODEL", Type: model.ParamTypeFile, Required: true},
|
||||||
|
}
|
||||||
|
values := map[string]string{"MODEL": "12345"}
|
||||||
|
if err := ValidateParams(params, values); err != nil {
|
||||||
|
t.Errorf("expected no error for valid file ID, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateParams_FileTypeInvalid(t *testing.T) {
|
||||||
|
params := []model.ParameterSchema{
|
||||||
|
{Name: "MODEL", Type: model.ParamTypeFile, Required: true},
|
||||||
|
}
|
||||||
|
values := map[string]string{"MODEL": "not_a_number"}
|
||||||
|
err := ValidateParams(params, values)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-numeric file ID")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "file ID") {
|
||||||
|
t.Errorf("error should mention 'file ID', got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateParams_DirectoryTypeValid(t *testing.T) {
|
||||||
|
params := []model.ParameterSchema{
|
||||||
|
{Name: "DATA_DIR", Type: model.ParamTypeDirectory, Required: true},
|
||||||
|
}
|
||||||
|
values := map[string]string{"DATA_DIR": "99"}
|
||||||
|
if err := ValidateParams(params, values); err != nil {
|
||||||
|
t.Errorf("expected no error for valid directory ID, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateParams_DirectoryTypeInvalid(t *testing.T) {
|
||||||
|
params := []model.ParameterSchema{
|
||||||
|
{Name: "DATA_DIR", Type: model.ParamTypeDirectory, Required: true},
|
||||||
|
}
|
||||||
|
values := map[string]string{"DATA_DIR": "abc"}
|
||||||
|
err := ValidateParams(params, values)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-numeric directory ID")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "file ID") {
|
||||||
|
t.Errorf("error should mention 'file ID', got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderScript_FileTypeNotEscaped(t *testing.T) {
|
||||||
|
params := []model.ParameterSchema{{Name: "MODEL_PATH", Type: model.ParamTypeFile}}
|
||||||
|
values := map[string]string{"MODEL_PATH": "model_v2.bin"}
|
||||||
|
result := RenderScript("python train.py --model $MODEL_PATH", params, values)
|
||||||
|
expected := "python train.py --model model_v2.bin"
|
||||||
|
if result != expected {
|
||||||
|
t.Errorf("got %q, want %q", result, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderScript_WorkDirInjected(t *testing.T) {
|
||||||
|
params := []model.ParameterSchema{{Name: "INPUT", Type: model.ParamTypeString}}
|
||||||
|
values := map[string]string{
|
||||||
|
"INPUT": "data.txt",
|
||||||
|
"WORK_DIR": "/data/work/myapp_20260101_abcd",
|
||||||
|
}
|
||||||
|
result := RenderScript("#SBATCH --chdir=$WORK_DIR\necho $INPUT", params, values)
|
||||||
|
if !strings.Contains(result, "#SBATCH --chdir=/data/work/myapp_20260101_abcd") {
|
||||||
|
t.Errorf("WORK_DIR should be replaced raw, got: %s", result)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result, "'data.txt'") {
|
||||||
|
t.Errorf("INPUT should still be shell-escaped, got: %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderScript_DirectoryTypeNotEscaped(t *testing.T) {
|
||||||
|
params := []model.ParameterSchema{{Name: "DATA_DIR", Type: model.ParamTypeDirectory}}
|
||||||
|
values := map[string]string{"DATA_DIR": "input_folder"}
|
||||||
|
result := RenderScript("ls $DATA_DIR", params, values)
|
||||||
|
expected := "ls input_folder"
|
||||||
|
if result != expected {
|
||||||
|
t.Errorf("got %q, want %q", result, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRenderScript_SimpleReplacement(t *testing.T) {
|
func TestRenderScript_SimpleReplacement(t *testing.T) {
|
||||||
params := []model.ParameterSchema{{Name: "INPUT", Type: model.ParamTypeString}}
|
params := []model.ParameterSchema{{Name: "INPUT", Type: model.ParamTypeString}}
|
||||||
values := map[string]string{"INPUT": "data.txt"}
|
values := map[string]string{"INPUT": "data.txt"}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ func derefInt32(i *int32) int32 {
|
|||||||
return *i
|
return *i
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func int32Ptr(i int32) *int32 { return &i }
|
||||||
|
|
||||||
func derefInt64(i *int64) int64 {
|
func derefInt64(i *int64) int64 {
|
||||||
if i == nil {
|
if i == nil {
|
||||||
return 0
|
return 0
|
||||||
@@ -33,6 +35,13 @@ func derefInt64(i *int64) int64 {
|
|||||||
return *i
|
return *i
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func derefInt32ToStr(i *int32) string {
|
||||||
|
if i == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strconv.FormatInt(int64(*i), 10)
|
||||||
|
}
|
||||||
|
|
||||||
func uint32NoValString(v *slurm.Uint32NoVal) string {
|
func uint32NoValString(v *slurm.Uint32NoVal) string {
|
||||||
if v == nil {
|
if v == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -16,28 +16,59 @@ import (
|
|||||||
|
|
||||||
// FileService handles file listing, metadata, download, and deletion operations.
|
// FileService handles file listing, metadata, download, and deletion operations.
|
||||||
type FileService struct {
|
type FileService struct {
|
||||||
storage storage.ObjectStorage
|
storage storage.ObjectStorage
|
||||||
blobStore *store.BlobStore
|
blobStore *store.BlobStore
|
||||||
fileStore *store.FileStore
|
fileStore *store.FileStore
|
||||||
bucket string
|
folderStore *store.FolderStore
|
||||||
db *gorm.DB
|
bucket string
|
||||||
logger *zap.Logger
|
db *gorm.DB
|
||||||
|
logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFileService creates a new FileService.
|
// NewFileService creates a new FileService.
|
||||||
func NewFileService(storage storage.ObjectStorage, blobStore *store.BlobStore, fileStore *store.FileStore, bucket string, db *gorm.DB, logger *zap.Logger) *FileService {
|
func NewFileService(storage storage.ObjectStorage, blobStore *store.BlobStore, fileStore *store.FileStore, folderStore *store.FolderStore, bucket string, db *gorm.DB, logger *zap.Logger) *FileService {
|
||||||
return &FileService{
|
return &FileService{
|
||||||
storage: storage,
|
storage: storage,
|
||||||
blobStore: blobStore,
|
blobStore: blobStore,
|
||||||
fileStore: fileStore,
|
fileStore: fileStore,
|
||||||
bucket: bucket,
|
folderStore: folderStore,
|
||||||
db: db,
|
bucket: bucket,
|
||||||
logger: logger,
|
db: db,
|
||||||
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListFiles returns a paginated list of files, optionally filtered by folder or search query.
|
// buildFileResponse creates a FileResponse from a File and FileBlob, including folder_path and user_id.
|
||||||
func (s *FileService) ListFiles(ctx context.Context, folderID *int64, page, pageSize int, search string) ([]model.FileResponse, int64, error) {
|
func (s *FileService) buildFileResponse(ctx context.Context, f model.File, blob model.FileBlob) model.FileResponse {
|
||||||
|
resp := model.FileResponse{
|
||||||
|
ID: f.ID,
|
||||||
|
Name: f.Name,
|
||||||
|
FolderID: f.FolderID,
|
||||||
|
UserID: f.UserID,
|
||||||
|
Size: blob.FileSize,
|
||||||
|
MimeType: blob.MimeType,
|
||||||
|
SHA256: f.BlobSHA256,
|
||||||
|
CreatedAt: f.CreatedAt,
|
||||||
|
UpdatedAt: f.UpdatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine folder_path
|
||||||
|
if f.FolderID != nil && s.folderStore != nil {
|
||||||
|
folder, err := s.folderStore.GetByID(ctx, *f.FolderID)
|
||||||
|
if err == nil && folder != nil {
|
||||||
|
resp.FolderPath = &folder.Path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if resp.FolderPath == nil {
|
||||||
|
rootPath := "/"
|
||||||
|
resp.FolderPath = &rootPath
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFiles returns a paginated list of files, optionally filtered by folder, user, or search query.
|
||||||
|
func (s *FileService) ListFiles(ctx context.Context, folderID *int64, userID *int64, page, pageSize int, search string) ([]model.FileResponse, int64, error) {
|
||||||
var files []model.File
|
var files []model.File
|
||||||
var total int64
|
var total int64
|
||||||
var err error
|
var err error
|
||||||
@@ -51,6 +82,17 @@ func (s *FileService) ListFiles(ctx context.Context, folderID *int64, page, page
|
|||||||
return nil, 0, fmt.Errorf("list files: %w", err)
|
return nil, 0, fmt.Errorf("list files: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply user_id filtering in service layer
|
||||||
|
if userID != nil {
|
||||||
|
filtered := make([]model.File, 0, len(files))
|
||||||
|
for _, f := range files {
|
||||||
|
if f.UserID != nil && *f.UserID == *userID {
|
||||||
|
filtered = append(filtered, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
files = filtered
|
||||||
|
}
|
||||||
|
|
||||||
responses := make([]model.FileResponse, 0, len(files))
|
responses := make([]model.FileResponse, 0, len(files))
|
||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
blob, err := s.blobStore.GetBySHA256(ctx, f.BlobSHA256)
|
blob, err := s.blobStore.GetBySHA256(ctx, f.BlobSHA256)
|
||||||
@@ -61,16 +103,7 @@ func (s *FileService) ListFiles(ctx context.Context, folderID *int64, page, page
|
|||||||
return nil, 0, fmt.Errorf("blob not found for file %d", f.ID)
|
return nil, 0, fmt.Errorf("blob not found for file %d", f.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
responses = append(responses, model.FileResponse{
|
responses = append(responses, s.buildFileResponse(ctx, f, *blob))
|
||||||
ID: f.ID,
|
|
||||||
Name: f.Name,
|
|
||||||
FolderID: f.FolderID,
|
|
||||||
Size: blob.FileSize,
|
|
||||||
MimeType: blob.MimeType,
|
|
||||||
SHA256: f.BlobSHA256,
|
|
||||||
CreatedAt: f.CreatedAt,
|
|
||||||
UpdatedAt: f.UpdatedAt,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return responses, total, nil
|
return responses, total, nil
|
||||||
@@ -97,6 +130,16 @@ func (s *FileService) GetFileMetadata(ctx context.Context, fileID int64) (*model
|
|||||||
return file, blob, nil
|
return file, blob, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetFileResponse returns a fully populated FileResponse for a given file ID.
|
||||||
|
func (s *FileService) GetFileResponse(ctx context.Context, fileID int64) (*model.FileResponse, error) {
|
||||||
|
file, blob, err := s.GetFileMetadata(ctx, fileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp := s.buildFileResponse(ctx, *file, *blob)
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
// DownloadFile returns a reader for the file content, along with file and blob metadata.
|
// DownloadFile returns a reader for the file content, along with file and blob metadata.
|
||||||
// If rangeHeader is non-empty, it parses the range and returns partial content.
|
// If rangeHeader is non-empty, it parses the range and returns partial content.
|
||||||
func (s *FileService) DownloadFile(ctx context.Context, fileID int64, rangeHeader string) (io.ReadCloser, *model.File, *model.FileBlob, int64, int64, error) {
|
func (s *FileService) DownloadFile(ctx context.Context, fileID int64, rangeHeader string) (io.ReadCloser, *model.File, *model.FileBlob, int64, int64, error) {
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ func setupFileTestDB(t *testing.T) *gorm.DB {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open sqlite: %v", err)
|
t.Fatalf("open sqlite: %v", err)
|
||||||
}
|
}
|
||||||
if err := db.AutoMigrate(&model.File{}, &model.FileBlob{}); err != nil {
|
if err := db.AutoMigrate(&model.File{}, &model.FileBlob{}, &model.Folder{}); err != nil {
|
||||||
t.Fatalf("migrate: %v", err)
|
t.Fatalf("migrate: %v", err)
|
||||||
}
|
}
|
||||||
return db
|
return db
|
||||||
@@ -88,7 +88,7 @@ func setupFileService(t *testing.T) (*FileService, *mockFileStorage, *gorm.DB) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
db := setupFileTestDB(t)
|
db := setupFileTestDB(t)
|
||||||
ms := &mockFileStorage{}
|
ms := &mockFileStorage{}
|
||||||
svc := NewFileService(ms, store.NewBlobStore(db), store.NewFileStore(db), "test-bucket", db, zap.NewNop())
|
svc := NewFileService(ms, store.NewBlobStore(db), store.NewFileStore(db), store.NewFolderStore(db), "test-bucket", db, zap.NewNop())
|
||||||
return svc, ms, db
|
return svc, ms, db
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ func createTestFile(t *testing.T, db *gorm.DB, name, blobSHA256 string, folderID
|
|||||||
func TestListFiles_Empty(t *testing.T) {
|
func TestListFiles_Empty(t *testing.T) {
|
||||||
svc, _, _ := setupFileService(t)
|
svc, _, _ := setupFileService(t)
|
||||||
|
|
||||||
files, total, err := svc.ListFiles(context.Background(), nil, 1, 10, "")
|
files, total, err := svc.ListFiles(context.Background(), nil, nil, 1, 10, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListFiles: %v", err)
|
t.Fatalf("ListFiles: %v", err)
|
||||||
}
|
}
|
||||||
@@ -142,7 +142,7 @@ func TestListFiles_WithFiles(t *testing.T) {
|
|||||||
createTestFile(t, db, "file1.txt", blob.SHA256, nil)
|
createTestFile(t, db, "file1.txt", blob.SHA256, nil)
|
||||||
createTestFile(t, db, "file2.txt", blob.SHA256, nil)
|
createTestFile(t, db, "file2.txt", blob.SHA256, nil)
|
||||||
|
|
||||||
files, total, err := svc.ListFiles(context.Background(), nil, 1, 10, "")
|
files, total, err := svc.ListFiles(context.Background(), nil, nil, 1, 10, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListFiles: %v", err)
|
t.Fatalf("ListFiles: %v", err)
|
||||||
}
|
}
|
||||||
@@ -173,7 +173,7 @@ func TestListFiles_Search(t *testing.T) {
|
|||||||
createTestFile(t, db, "document.pdf", "sha256other", nil)
|
createTestFile(t, db, "document.pdf", "sha256other", nil)
|
||||||
createTestBlob(t, db, "sha256other", "blobs/other", "application/pdf", 512, 1)
|
createTestBlob(t, db, "sha256other", "blobs/other", "application/pdf", 512, 1)
|
||||||
|
|
||||||
files, total, err := svc.ListFiles(context.Background(), nil, 1, 10, "photo")
|
files, total, err := svc.ListFiles(context.Background(), nil, nil, 1, 10, "photo")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListFiles: %v", err)
|
t.Fatalf("ListFiles: %v", err)
|
||||||
}
|
}
|
||||||
@@ -446,7 +446,7 @@ func TestListFiles_WithFolderFilter(t *testing.T) {
|
|||||||
createTestFile(t, db, "in_folder.txt", blob.SHA256, &folderID)
|
createTestFile(t, db, "in_folder.txt", blob.SHA256, &folderID)
|
||||||
createTestFile(t, db, "root.txt", blob.SHA256, nil)
|
createTestFile(t, db, "root.txt", blob.SHA256, nil)
|
||||||
|
|
||||||
files, total, err := svc.ListFiles(context.Background(), &folderID, 1, 10, "")
|
files, total, err := svc.ListFiles(context.Background(), &folderID, nil, 1, 10, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListFiles: %v", err)
|
t.Fatalf("ListFiles: %v", err)
|
||||||
}
|
}
|
||||||
@@ -460,7 +460,7 @@ func TestListFiles_WithFolderFilter(t *testing.T) {
|
|||||||
t.Errorf("expected in_folder.txt, got %s", files[0].Name)
|
t.Errorf("expected in_folder.txt, got %s", files[0].Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
rootFiles, rootTotal, err := svc.ListFiles(context.Background(), nil, 1, 10, "")
|
rootFiles, rootTotal, err := svc.ListFiles(context.Background(), nil, nil, 1, 10, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListFiles root: %v", err)
|
t.Fatalf("ListFiles root: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gcy_hpc_server/internal/model"
|
"gcy_hpc_server/internal/model"
|
||||||
@@ -49,6 +50,73 @@ func (s *JobService) SubmitJob(ctx context.Context, req *model.SubmitJobRequest)
|
|||||||
"HOME=/root",
|
"HOME=/root",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.MemoryPerNode != nil {
|
||||||
|
jobDesc.MemoryPerNode = &slurm.Uint64NoVal{Number: req.MemoryPerNode}
|
||||||
|
}
|
||||||
|
if req.MemoryPerCpu != nil {
|
||||||
|
jobDesc.MemoryPerCpu = &slurm.Uint64NoVal{Number: req.MemoryPerCpu}
|
||||||
|
}
|
||||||
|
if req.Nodes != nil {
|
||||||
|
jobDesc.Nodes = req.Nodes
|
||||||
|
}
|
||||||
|
if req.Tasks != nil {
|
||||||
|
jobDesc.Tasks = req.Tasks
|
||||||
|
}
|
||||||
|
if req.CpusPerTask != nil {
|
||||||
|
jobDesc.CpusPerTask = req.CpusPerTask
|
||||||
|
}
|
||||||
|
if req.Constraints != nil {
|
||||||
|
jobDesc.Constraints = req.Constraints
|
||||||
|
}
|
||||||
|
if req.Reservation != nil {
|
||||||
|
jobDesc.Reservation = req.Reservation
|
||||||
|
}
|
||||||
|
if req.Account != nil {
|
||||||
|
jobDesc.Account = req.Account
|
||||||
|
}
|
||||||
|
if req.Nice != nil {
|
||||||
|
jobDesc.Nice = req.Nice
|
||||||
|
}
|
||||||
|
if req.MailType != nil {
|
||||||
|
jobDesc.MailType = strings.Split(*req.MailType, ",")
|
||||||
|
}
|
||||||
|
if req.MailUser != nil {
|
||||||
|
jobDesc.MailUser = req.MailUser
|
||||||
|
}
|
||||||
|
if req.StandardOutput != nil {
|
||||||
|
jobDesc.StandardOutput = req.StandardOutput
|
||||||
|
}
|
||||||
|
if req.StandardError != nil {
|
||||||
|
jobDesc.StandardError = req.StandardError
|
||||||
|
}
|
||||||
|
if req.StandardInput != nil {
|
||||||
|
jobDesc.StandardInput = req.StandardInput
|
||||||
|
}
|
||||||
|
if req.RequiredNodes != nil {
|
||||||
|
jobDesc.RequiredNodes = strings.Split(*req.RequiredNodes, ",")
|
||||||
|
}
|
||||||
|
if req.ExcludedNodes != nil {
|
||||||
|
jobDesc.ExcludedNodes = strings.Split(*req.ExcludedNodes, ",")
|
||||||
|
}
|
||||||
|
if req.BeginTime != nil {
|
||||||
|
jobDesc.BeginTime = &slurm.Uint64NoVal{Number: req.BeginTime}
|
||||||
|
}
|
||||||
|
if req.Deadline != nil {
|
||||||
|
jobDesc.Deadline = req.Deadline
|
||||||
|
}
|
||||||
|
if req.Array != nil {
|
||||||
|
jobDesc.Array = req.Array
|
||||||
|
}
|
||||||
|
if req.Dependency != nil {
|
||||||
|
jobDesc.Dependency = req.Dependency
|
||||||
|
}
|
||||||
|
if req.Requeue != nil {
|
||||||
|
jobDesc.Requeue = req.Requeue
|
||||||
|
}
|
||||||
|
if req.KillOnNodeFail != nil {
|
||||||
|
jobDesc.KillOnNodeFail = req.KillOnNodeFail
|
||||||
|
}
|
||||||
|
|
||||||
submitReq := &slurm.JobSubmitReq{
|
submitReq := &slurm.JobSubmitReq{
|
||||||
Script: &script,
|
Script: &script,
|
||||||
Job: jobDesc,
|
Job: jobDesc,
|
||||||
|
|||||||
@@ -829,6 +829,356 @@ func TestGetJob_FallbackToHistory_HistoryError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// New scheduling field mapping tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestSubmitJob_AllSchedulingFields(t *testing.T) {
|
||||||
|
jobID := int32(999)
|
||||||
|
|
||||||
|
// Prepare all 22 new scheduling field values
|
||||||
|
var (
|
||||||
|
memoryPerNode = int64(4096)
|
||||||
|
memoryPerCpu = int64(1024)
|
||||||
|
nodes = "2"
|
||||||
|
tasks = int32(4)
|
||||||
|
cpusPerTask = int32(2)
|
||||||
|
constraints = "gpu&fast"
|
||||||
|
reservation = "resv01"
|
||||||
|
account = "proj-alpha"
|
||||||
|
nice = int32(100)
|
||||||
|
mailType = "BEGIN,END,FAIL"
|
||||||
|
mailUser = "admin@example.com"
|
||||||
|
stdOut = "/tmp/job_%j.out"
|
||||||
|
stdErr = "/tmp/job_%j.err"
|
||||||
|
stdIn = "/dev/null"
|
||||||
|
reqNodes = "node01,node02"
|
||||||
|
exclNodes = "node03,node04"
|
||||||
|
beginTime = int64(1700000000)
|
||||||
|
deadline = int64(1700099999)
|
||||||
|
array = "1-10"
|
||||||
|
dependency = "afterok:123"
|
||||||
|
requeue = true
|
||||||
|
killOnNodeFail = true
|
||||||
|
)
|
||||||
|
|
||||||
|
client, cleanup := mockJobServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body slurm.JobSubmitReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatalf("decode body: %v", err)
|
||||||
|
}
|
||||||
|
if body.Job == nil {
|
||||||
|
t.Fatal("job desc is nil")
|
||||||
|
}
|
||||||
|
j := body.Job
|
||||||
|
|
||||||
|
// --- Existing fields still work ---
|
||||||
|
if j.Script == nil || *j.Script != "#!/bin/bash\necho test" {
|
||||||
|
t.Errorf("Script mismatch: %v", j.Script)
|
||||||
|
}
|
||||||
|
if j.Partition == nil || *j.Partition != "normal" {
|
||||||
|
t.Errorf("Partition mismatch: %v", j.Partition)
|
||||||
|
}
|
||||||
|
if j.Qos == nil || *j.Qos != "high" {
|
||||||
|
t.Errorf("QOS mismatch: %v", j.Qos)
|
||||||
|
}
|
||||||
|
if j.Name == nil || *j.Name != "full-test" {
|
||||||
|
t.Errorf("Name mismatch: %v", j.Name)
|
||||||
|
}
|
||||||
|
if j.MinimumCpus == nil || *j.MinimumCpus != int32(8) {
|
||||||
|
t.Errorf("MinimumCpus mismatch: %v", j.MinimumCpus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 22 new scheduling fields ---
|
||||||
|
|
||||||
|
// MemoryPerNode → *Uint64NoVal
|
||||||
|
if j.MemoryPerNode == nil || j.MemoryPerNode.Number == nil || *j.MemoryPerNode.Number != memoryPerNode {
|
||||||
|
t.Errorf("MemoryPerNode mismatch: %v", j.MemoryPerNode)
|
||||||
|
}
|
||||||
|
// MemoryPerCpu → *Uint64NoVal
|
||||||
|
if j.MemoryPerCpu == nil || j.MemoryPerCpu.Number == nil || *j.MemoryPerCpu.Number != memoryPerCpu {
|
||||||
|
t.Errorf("MemoryPerCpu mismatch: %v", j.MemoryPerCpu)
|
||||||
|
}
|
||||||
|
// Nodes → *string
|
||||||
|
if j.Nodes == nil || *j.Nodes != nodes {
|
||||||
|
t.Errorf("Nodes mismatch: %v", j.Nodes)
|
||||||
|
}
|
||||||
|
// Tasks → *int32
|
||||||
|
if j.Tasks == nil || *j.Tasks != tasks {
|
||||||
|
t.Errorf("Tasks mismatch: got %v, want %d", j.Tasks, tasks)
|
||||||
|
}
|
||||||
|
// CpusPerTask → *int32
|
||||||
|
if j.CpusPerTask == nil || *j.CpusPerTask != cpusPerTask {
|
||||||
|
t.Errorf("CpusPerTask mismatch: got %v, want %d", j.CpusPerTask, cpusPerTask)
|
||||||
|
}
|
||||||
|
// Constraints → *string
|
||||||
|
if j.Constraints == nil || *j.Constraints != constraints {
|
||||||
|
t.Errorf("Constraints mismatch: %v", j.Constraints)
|
||||||
|
}
|
||||||
|
// Reservation → *string
|
||||||
|
if j.Reservation == nil || *j.Reservation != reservation {
|
||||||
|
t.Errorf("Reservation mismatch: %v", j.Reservation)
|
||||||
|
}
|
||||||
|
// Account → *string
|
||||||
|
if j.Account == nil || *j.Account != account {
|
||||||
|
t.Errorf("Account mismatch: %v", j.Account)
|
||||||
|
}
|
||||||
|
// Nice → *int32
|
||||||
|
if j.Nice == nil || *j.Nice != nice {
|
||||||
|
t.Errorf("Nice mismatch: got %v, want %d", j.Nice, nice)
|
||||||
|
}
|
||||||
|
// MailType → []string (comma-split)
|
||||||
|
if len(j.MailType) != 3 || j.MailType[0] != "BEGIN" || j.MailType[1] != "END" || j.MailType[2] != "FAIL" {
|
||||||
|
t.Errorf("MailType mismatch: %v", j.MailType)
|
||||||
|
}
|
||||||
|
// MailUser → *string
|
||||||
|
if j.MailUser == nil || *j.MailUser != mailUser {
|
||||||
|
t.Errorf("MailUser mismatch: %v", j.MailUser)
|
||||||
|
}
|
||||||
|
// StandardOutput → *string
|
||||||
|
if j.StandardOutput == nil || *j.StandardOutput != stdOut {
|
||||||
|
t.Errorf("StandardOutput mismatch: %v", j.StandardOutput)
|
||||||
|
}
|
||||||
|
// StandardError → *string
|
||||||
|
if j.StandardError == nil || *j.StandardError != stdErr {
|
||||||
|
t.Errorf("StandardError mismatch: %v", j.StandardError)
|
||||||
|
}
|
||||||
|
// StandardInput → *string
|
||||||
|
if j.StandardInput == nil || *j.StandardInput != stdIn {
|
||||||
|
t.Errorf("StandardInput mismatch: %v", j.StandardInput)
|
||||||
|
}
|
||||||
|
// RequiredNodes → CSVString ([]string)
|
||||||
|
if len(j.RequiredNodes) != 2 || j.RequiredNodes[0] != "node01" || j.RequiredNodes[1] != "node02" {
|
||||||
|
t.Errorf("RequiredNodes mismatch: %v", j.RequiredNodes)
|
||||||
|
}
|
||||||
|
// ExcludedNodes → CSVString ([]string)
|
||||||
|
if len(j.ExcludedNodes) != 2 || j.ExcludedNodes[0] != "node03" || j.ExcludedNodes[1] != "node04" {
|
||||||
|
t.Errorf("ExcludedNodes mismatch: %v", j.ExcludedNodes)
|
||||||
|
}
|
||||||
|
// BeginTime → *Uint64NoVal
|
||||||
|
if j.BeginTime == nil || j.BeginTime.Number == nil || *j.BeginTime.Number != beginTime {
|
||||||
|
t.Errorf("BeginTime mismatch: %v", j.BeginTime)
|
||||||
|
}
|
||||||
|
// Deadline → *int64 (NO wrapper)
|
||||||
|
if j.Deadline == nil || *j.Deadline != deadline {
|
||||||
|
t.Errorf("Deadline mismatch: %v", j.Deadline)
|
||||||
|
}
|
||||||
|
// Array → *string
|
||||||
|
if j.Array == nil || *j.Array != array {
|
||||||
|
t.Errorf("Array mismatch: %v", j.Array)
|
||||||
|
}
|
||||||
|
// Dependency → *string
|
||||||
|
if j.Dependency == nil || *j.Dependency != dependency {
|
||||||
|
t.Errorf("Dependency mismatch: %v", j.Dependency)
|
||||||
|
}
|
||||||
|
// Requeue → *bool
|
||||||
|
if j.Requeue == nil || *j.Requeue != requeue {
|
||||||
|
t.Errorf("Requeue mismatch: %v", j.Requeue)
|
||||||
|
}
|
||||||
|
// KillOnNodeFail → *bool
|
||||||
|
if j.KillOnNodeFail == nil || *j.KillOnNodeFail != killOnNodeFail {
|
||||||
|
t.Errorf("KillOnNodeFail mismatch: %v", j.KillOnNodeFail)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := slurm.OpenapiJobSubmitResponse{
|
||||||
|
Result: &slurm.JobSubmitResponseMsg{JobID: &jobID},
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
svc := NewJobService(client, zap.NewNop())
|
||||||
|
resp, err := svc.SubmitJob(context.Background(), &model.SubmitJobRequest{
|
||||||
|
Script: "#!/bin/bash\necho test",
|
||||||
|
Partition: "normal",
|
||||||
|
QOS: "high",
|
||||||
|
JobName: "full-test",
|
||||||
|
CPUs: 8,
|
||||||
|
TimeLimit: "60",
|
||||||
|
MemoryPerNode: &memoryPerNode,
|
||||||
|
MemoryPerCpu: &memoryPerCpu,
|
||||||
|
Nodes: &nodes,
|
||||||
|
Tasks: &tasks,
|
||||||
|
CpusPerTask: &cpusPerTask,
|
||||||
|
Constraints: &constraints,
|
||||||
|
Reservation: &reservation,
|
||||||
|
Account: &account,
|
||||||
|
Nice: &nice,
|
||||||
|
MailType: &mailType,
|
||||||
|
MailUser: &mailUser,
|
||||||
|
StandardOutput: &stdOut,
|
||||||
|
StandardError: &stdErr,
|
||||||
|
StandardInput: &stdIn,
|
||||||
|
RequiredNodes: &reqNodes,
|
||||||
|
ExcludedNodes: &exclNodes,
|
||||||
|
BeginTime: &beginTime,
|
||||||
|
Deadline: &deadline,
|
||||||
|
Array: &array,
|
||||||
|
Dependency: &dependency,
|
||||||
|
Requeue: &requeue,
|
||||||
|
KillOnNodeFail: &killOnNodeFail,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SubmitJob: %v", err)
|
||||||
|
}
|
||||||
|
if resp.JobID != 999 {
|
||||||
|
t.Errorf("expected JobID 999, got %d", resp.JobID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmitJob_BackwardCompat(t *testing.T) {
|
||||||
|
jobID := int32(555)
|
||||||
|
|
||||||
|
client, cleanup := mockJobServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body slurm.JobSubmitReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatalf("decode body: %v", err)
|
||||||
|
}
|
||||||
|
if body.Job == nil {
|
||||||
|
t.Fatal("job desc is nil")
|
||||||
|
}
|
||||||
|
j := body.Job
|
||||||
|
|
||||||
|
// Existing fields: Script and WorkDir should be set
|
||||||
|
if j.Script == nil || *j.Script != "echo hi" {
|
||||||
|
t.Errorf("Script mismatch: %v", j.Script)
|
||||||
|
}
|
||||||
|
if j.CurrentWorkingDirectory == nil || *j.CurrentWorkingDirectory != "/home/user" {
|
||||||
|
t.Errorf("CurrentWorkingDirectory mismatch: %v", j.CurrentWorkingDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
// All new scheduling fields should be nil/empty
|
||||||
|
if j.MemoryPerNode != nil {
|
||||||
|
t.Errorf("MemoryPerNode should be nil, got %v", j.MemoryPerNode)
|
||||||
|
}
|
||||||
|
if j.MemoryPerCpu != nil {
|
||||||
|
t.Errorf("MemoryPerCpu should be nil, got %v", j.MemoryPerCpu)
|
||||||
|
}
|
||||||
|
if j.Nodes != nil {
|
||||||
|
t.Errorf("Nodes should be nil, got %v", j.Nodes)
|
||||||
|
}
|
||||||
|
if j.Tasks != nil {
|
||||||
|
t.Errorf("Tasks should be nil, got %v", j.Tasks)
|
||||||
|
}
|
||||||
|
if j.CpusPerTask != nil {
|
||||||
|
t.Errorf("CpusPerTask should be nil, got %v", j.CpusPerTask)
|
||||||
|
}
|
||||||
|
if j.Constraints != nil {
|
||||||
|
t.Errorf("Constraints should be nil, got %v", j.Constraints)
|
||||||
|
}
|
||||||
|
if j.Reservation != nil {
|
||||||
|
t.Errorf("Reservation should be nil, got %v", j.Reservation)
|
||||||
|
}
|
||||||
|
if j.Account != nil {
|
||||||
|
t.Errorf("Account should be nil, got %v", j.Account)
|
||||||
|
}
|
||||||
|
if j.Nice != nil {
|
||||||
|
t.Errorf("Nice should be nil, got %v", j.Nice)
|
||||||
|
}
|
||||||
|
if len(j.MailType) != 0 {
|
||||||
|
t.Errorf("MailType should be empty, got %v", j.MailType)
|
||||||
|
}
|
||||||
|
if j.MailUser != nil {
|
||||||
|
t.Errorf("MailUser should be nil, got %v", j.MailUser)
|
||||||
|
}
|
||||||
|
if j.StandardOutput != nil {
|
||||||
|
t.Errorf("StandardOutput should be nil, got %v", j.StandardOutput)
|
||||||
|
}
|
||||||
|
if j.StandardError != nil {
|
||||||
|
t.Errorf("StandardError should be nil, got %v", j.StandardError)
|
||||||
|
}
|
||||||
|
if j.StandardInput != nil {
|
||||||
|
t.Errorf("StandardInput should be nil, got %v", j.StandardInput)
|
||||||
|
}
|
||||||
|
if len(j.RequiredNodes) != 0 {
|
||||||
|
t.Errorf("RequiredNodes should be empty, got %v", j.RequiredNodes)
|
||||||
|
}
|
||||||
|
if len(j.ExcludedNodes) != 0 {
|
||||||
|
t.Errorf("ExcludedNodes should be empty, got %v", j.ExcludedNodes)
|
||||||
|
}
|
||||||
|
if j.BeginTime != nil {
|
||||||
|
t.Errorf("BeginTime should be nil, got %v", j.BeginTime)
|
||||||
|
}
|
||||||
|
if j.Deadline != nil {
|
||||||
|
t.Errorf("Deadline should be nil, got %v", j.Deadline)
|
||||||
|
}
|
||||||
|
if j.Array != nil {
|
||||||
|
t.Errorf("Array should be nil, got %v", j.Array)
|
||||||
|
}
|
||||||
|
if j.Dependency != nil {
|
||||||
|
t.Errorf("Dependency should be nil, got %v", j.Dependency)
|
||||||
|
}
|
||||||
|
if j.Requeue != nil {
|
||||||
|
t.Errorf("Requeue should be nil, got %v", j.Requeue)
|
||||||
|
}
|
||||||
|
if j.KillOnNodeFail != nil {
|
||||||
|
t.Errorf("KillOnNodeFail should be nil, got %v", j.KillOnNodeFail)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := slurm.OpenapiJobSubmitResponse{
|
||||||
|
Result: &slurm.JobSubmitResponseMsg{JobID: &jobID},
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
svc := NewJobService(client, zap.NewNop())
|
||||||
|
resp, err := svc.SubmitJob(context.Background(), &model.SubmitJobRequest{
|
||||||
|
Script: "echo hi",
|
||||||
|
WorkDir: "/home/user",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SubmitJob: %v", err)
|
||||||
|
}
|
||||||
|
if resp.JobID != 555 {
|
||||||
|
t.Errorf("expected JobID 555, got %d", resp.JobID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmitJob_MemoryBothSet(t *testing.T) {
|
||||||
|
jobID := int32(777)
|
||||||
|
memoryPerNode := int64(4096)
|
||||||
|
memoryPerCpu := int64(1024)
|
||||||
|
|
||||||
|
client, cleanup := mockJobServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body slurm.JobSubmitReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatalf("decode body: %v", err)
|
||||||
|
}
|
||||||
|
if body.Job == nil {
|
||||||
|
t.Fatal("job desc is nil")
|
||||||
|
}
|
||||||
|
j := body.Job
|
||||||
|
|
||||||
|
// Both memory fields should be mapped independently
|
||||||
|
if j.MemoryPerNode == nil || j.MemoryPerNode.Number == nil || *j.MemoryPerNode.Number != memoryPerNode {
|
||||||
|
t.Errorf("MemoryPerNode mismatch: %v", j.MemoryPerNode)
|
||||||
|
}
|
||||||
|
if j.MemoryPerCpu == nil || j.MemoryPerCpu.Number == nil || *j.MemoryPerCpu.Number != memoryPerCpu {
|
||||||
|
t.Errorf("MemoryPerCpu mismatch: %v", j.MemoryPerCpu)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := slurm.OpenapiJobSubmitResponse{
|
||||||
|
Result: &slurm.JobSubmitResponseMsg{JobID: &jobID},
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
svc := NewJobService(client, zap.NewNop())
|
||||||
|
resp, err := svc.SubmitJob(context.Background(), &model.SubmitJobRequest{
|
||||||
|
Script: "echo mem",
|
||||||
|
MemoryPerNode: &memoryPerNode,
|
||||||
|
MemoryPerCpu: &memoryPerCpu,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SubmitJob: %v", err)
|
||||||
|
}
|
||||||
|
if resp.JobID != 777 {
|
||||||
|
t.Errorf("expected JobID 777, got %d", resp.JobID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetJob_FallbackToHistory_EmptyHistory(t *testing.T) {
|
func TestGetJob_FallbackToHistory_EmptyHistory(t *testing.T) {
|
||||||
client, cleanup := mockJobServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
client, cleanup := mockJobServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ func ValidateParams(params []model.ParameterSchema, values map[string]string) er
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case model.ParamTypeFile, model.ParamTypeDirectory:
|
case model.ParamTypeFile, model.ParamTypeDirectory:
|
||||||
|
if _, err := strconv.ParseInt(val, 10, 64); err != nil {
|
||||||
|
errs = append(errs, fmt.Sprintf("parameter %q must be a valid file ID (integer), got %q", p.Name, val))
|
||||||
|
}
|
||||||
case model.ParamTypeString:
|
case model.ParamTypeString:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,7 +74,9 @@ func ValidateParams(params []model.ParameterSchema, values map[string]string) er
|
|||||||
// RenderScript replaces $PARAM tokens in the template with user-provided values.
|
// RenderScript replaces $PARAM tokens in the template with user-provided values.
|
||||||
// Only tokens defined in the schema are replaced. Replacement is done longest-name-first
|
// Only tokens defined in the schema are replaced. Replacement is done longest-name-first
|
||||||
// to avoid partial matches (e.g., $JOB_NAME before $JOB).
|
// to avoid partial matches (e.g., $JOB_NAME before $JOB).
|
||||||
// All values are shell-escaped using single-quote wrapping.
|
// String, integer, boolean, and enum values are shell-escaped using single-quote wrapping.
|
||||||
|
// File and directory values (including WORK_DIR) are inserted raw (no escaping) because
|
||||||
|
// they are paths used in SBATCH directives and command arguments.
|
||||||
func RenderScript(template string, params []model.ParameterSchema, values map[string]string) string {
|
func RenderScript(template string, params []model.ParameterSchema, values map[string]string) string {
|
||||||
sorted := make([]model.ParameterSchema, len(params))
|
sorted := make([]model.ParameterSchema, len(params))
|
||||||
copy(sorted, params)
|
copy(sorted, params)
|
||||||
@@ -79,6 +84,9 @@ func RenderScript(template string, params []model.ParameterSchema, values map[st
|
|||||||
return len(sorted[i].Name) > len(sorted[j].Name)
|
return len(sorted[i].Name) > len(sorted[j].Name)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Add virtual "WORK_DIR" entry so $WORK_DIR is replaced without escaping.
|
||||||
|
sorted = append(sorted, model.ParameterSchema{Name: "WORK_DIR", Type: model.ParamTypeFile})
|
||||||
|
|
||||||
result := template
|
result := template
|
||||||
for _, p := range sorted {
|
for _, p := range sorted {
|
||||||
val, ok := values[p.Name]
|
val, ok := values[p.Name]
|
||||||
@@ -89,8 +97,13 @@ func RenderScript(template string, params []model.ParameterSchema, values map[st
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
escaped := "'" + strings.ReplaceAll(val, "'", "'\\''") + "'"
|
|
||||||
result = strings.ReplaceAll(result, "$"+p.Name, escaped)
|
if p.Type == model.ParamTypeFile || p.Type == model.ParamTypeDirectory {
|
||||||
|
result = strings.ReplaceAll(result, "$"+p.Name, val)
|
||||||
|
} else {
|
||||||
|
escaped := "'" + strings.ReplaceAll(val, "'", "'\\''") + "'"
|
||||||
|
result = strings.ReplaceAll(result, "$"+p.Name, escaped)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,13 +122,40 @@ func (s *TaskService) CreateTask(ctx context.Context, req *model.CreateTaskReque
|
|||||||
|
|
||||||
// 8. Create task record
|
// 8. Create task record
|
||||||
task := &model.Task{
|
task := &model.Task{
|
||||||
TaskName: taskName,
|
TaskName: taskName,
|
||||||
AppID: app.ID,
|
AppID: app.ID,
|
||||||
AppName: app.Name,
|
AppName: app.Name,
|
||||||
Status: model.TaskStatusSubmitted,
|
Status: model.TaskStatusSubmitted,
|
||||||
Values: valuesJSON,
|
Values: valuesJSON,
|
||||||
InputFileIDs: fileIDsJSON,
|
InputFileIDs: fileIDsJSON,
|
||||||
SubmittedAt: time.Now(),
|
SubmittedAt: time.Now(),
|
||||||
|
Partition: derefStr(req.Partition),
|
||||||
|
Cpus: req.Cpus,
|
||||||
|
MemoryPerNode: req.MemoryPerNode,
|
||||||
|
MemoryPerCpu: req.MemoryPerCpu,
|
||||||
|
TimeLimit: req.TimeLimit,
|
||||||
|
QOS: req.QOS,
|
||||||
|
JobName: req.JobName,
|
||||||
|
Nodes: req.Nodes,
|
||||||
|
Tasks: req.Tasks,
|
||||||
|
CpusPerTask: req.CpusPerTask,
|
||||||
|
Constraints: req.Constraints,
|
||||||
|
Reservation: req.Reservation,
|
||||||
|
Account: req.Account,
|
||||||
|
Nice: req.Nice,
|
||||||
|
MailType: req.MailType,
|
||||||
|
MailUser: req.MailUser,
|
||||||
|
StandardOutput: req.StandardOutput,
|
||||||
|
StandardError: req.StandardError,
|
||||||
|
StandardInput: req.StandardInput,
|
||||||
|
RequiredNodes: req.RequiredNodes,
|
||||||
|
ExcludedNodes: req.ExcludedNodes,
|
||||||
|
BeginTime: req.BeginTime,
|
||||||
|
Deadline: req.Deadline,
|
||||||
|
Array: req.Array,
|
||||||
|
Dependency: req.Dependency,
|
||||||
|
Requeue: req.Requeue,
|
||||||
|
KillOnNodeFail: req.KillOnNodeFail,
|
||||||
}
|
}
|
||||||
|
|
||||||
taskID, err := s.taskStore.Create(ctx, task)
|
taskID, err := s.taskStore.Create(ctx, task)
|
||||||
@@ -261,13 +288,104 @@ func (s *TaskService) ProcessTask(ctx context.Context, taskID int64) error {
|
|||||||
return fail(model.TaskStepSubmitting, err.Error())
|
return fail(model.TaskStepSubmitting, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.Contains(app.ScriptTemplate, "$WORK_DIR") {
|
||||||
|
values["WORK_DIR"] = workDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve file-type parameters: user sends file_id, we replace with filename.
|
||||||
|
// Only query the database if there are file/directory-type parameters with values.
|
||||||
|
var fileLookupIDs []int64
|
||||||
|
for _, p := range params {
|
||||||
|
if p.Type != model.ParamTypeFile && p.Type != model.ParamTypeDirectory {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val, ok := values[p.Name]
|
||||||
|
if !ok || val == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fileID, err := strconv.ParseInt(val, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fail(model.TaskStepSubmitting, fmt.Sprintf("parameter %q: invalid file_id %q, expected numeric file ID", p.Name, val))
|
||||||
|
}
|
||||||
|
fileLookupIDs = append(fileLookupIDs, fileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(fileLookupIDs) > 0 && s.fileStore != nil {
|
||||||
|
fetchedFiles, err := s.fileStore.GetByIDs(ctx, fileLookupIDs)
|
||||||
|
if err != nil {
|
||||||
|
return fail(model.TaskStepSubmitting, fmt.Sprintf("fetch file names for parameter resolution: %v", err))
|
||||||
|
}
|
||||||
|
fileMap := make(map[int64]string, len(fetchedFiles))
|
||||||
|
for _, f := range fetchedFiles {
|
||||||
|
fileMap[f.ID] = f.Name
|
||||||
|
}
|
||||||
|
for _, p := range params {
|
||||||
|
if p.Type != model.ParamTypeFile && p.Type != model.ParamTypeDirectory {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val, ok := values[p.Name]
|
||||||
|
if !ok || val == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fileID, _ := strconv.ParseInt(val, 10, 64)
|
||||||
|
filename, found := fileMap[fileID]
|
||||||
|
if !found {
|
||||||
|
return fail(model.TaskStepSubmitting, fmt.Sprintf("parameter %q: file_id %d not found", p.Name, fileID))
|
||||||
|
}
|
||||||
|
values[p.Name] = filename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注入默认调度参数(仅在内存中,不持久化到数据库)
|
||||||
|
if task.TimeLimit == nil {
|
||||||
|
task.TimeLimit = int32Ptr(10080) // 168 小时
|
||||||
|
}
|
||||||
|
if task.StandardOutput == nil {
|
||||||
|
task.StandardOutput = strToPtrOrNil(filepath.Join(workDir, "slurm-%j.out"))
|
||||||
|
}
|
||||||
|
if task.StandardError == nil {
|
||||||
|
task.StandardError = strToPtrOrNil(filepath.Join(workDir, "slurm-%j.err"))
|
||||||
|
}
|
||||||
|
|
||||||
// 17. Render script
|
// 17. Render script
|
||||||
rendered := RenderScript(app.ScriptTemplate, params, values)
|
rendered := RenderScript(app.ScriptTemplate, params, values)
|
||||||
|
s.logger.Info("rendered script",
|
||||||
|
zap.Int64("task_id", taskID),
|
||||||
|
zap.String("work_dir", workDir),
|
||||||
|
zap.String("script", rendered),
|
||||||
|
)
|
||||||
|
|
||||||
// 18. Submit to Slurm
|
// 18. Submit to Slurm
|
||||||
jobResp, err := s.jobSvc.SubmitJob(ctx, &model.SubmitJobRequest{
|
jobResp, err := s.jobSvc.SubmitJob(ctx, &model.SubmitJobRequest{
|
||||||
Script: rendered,
|
Script: rendered,
|
||||||
WorkDir: workDir,
|
WorkDir: workDir,
|
||||||
|
Partition: task.Partition,
|
||||||
|
CPUs: derefInt32(task.Cpus),
|
||||||
|
TimeLimit: derefInt32ToStr(task.TimeLimit),
|
||||||
|
QOS: derefStr(task.QOS),
|
||||||
|
JobName: derefStr(task.JobName),
|
||||||
|
MemoryPerNode: task.MemoryPerNode,
|
||||||
|
MemoryPerCpu: task.MemoryPerCpu,
|
||||||
|
Nodes: task.Nodes,
|
||||||
|
Tasks: task.Tasks,
|
||||||
|
CpusPerTask: task.CpusPerTask,
|
||||||
|
Constraints: task.Constraints,
|
||||||
|
Reservation: task.Reservation,
|
||||||
|
Account: task.Account,
|
||||||
|
Nice: task.Nice,
|
||||||
|
MailType: task.MailType,
|
||||||
|
MailUser: task.MailUser,
|
||||||
|
StandardOutput: task.StandardOutput,
|
||||||
|
StandardError: task.StandardError,
|
||||||
|
StandardInput: task.StandardInput,
|
||||||
|
RequiredNodes: task.RequiredNodes,
|
||||||
|
ExcludedNodes: task.ExcludedNodes,
|
||||||
|
BeginTime: task.BeginTime,
|
||||||
|
Deadline: task.Deadline,
|
||||||
|
Array: task.Array,
|
||||||
|
Dependency: task.Dependency,
|
||||||
|
Requeue: task.Requeue,
|
||||||
|
KillOnNodeFail: task.KillOnNodeFail,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fail(model.TaskStepSubmitting, fmt.Sprintf("submit job: %v", err))
|
return fail(model.TaskStepSubmitting, fmt.Sprintf("submit job: %v", err))
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gcy_hpc_server/internal/model"
|
||||||
|
"gcy_hpc_server/internal/slurm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProcessTask_DefaultTimeLimit(t *testing.T) {
|
||||||
|
jobID := int32(42)
|
||||||
|
|
||||||
|
var capturedReq slurm.JobSubmitReq
|
||||||
|
|
||||||
|
env := newTaskTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&capturedReq); err != nil {
|
||||||
|
t.Fatalf("decode request body: %v", err)
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(slurm.OpenapiJobSubmitResponse{
|
||||||
|
Result: &slurm.JobSubmitResponseMsg{JobID: &jobID},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer env.close()
|
||||||
|
|
||||||
|
appID := env.createApp(t, "default-tl-app", "#!/bin/bash\necho hello", json.RawMessage(`[]`))
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
task := &model.Task{
|
||||||
|
AppName: "default-tl-app",
|
||||||
|
AppID: appID,
|
||||||
|
Status: model.TaskStatusSubmitted,
|
||||||
|
SubmittedAt: time.Now(),
|
||||||
|
Partition: "debug",
|
||||||
|
}
|
||||||
|
taskID, err := env.taskStore.Create(ctx, task)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create task in DB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = env.svc.ProcessTask(ctx, taskID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProcessTask: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
j := capturedReq.Job
|
||||||
|
if j == nil {
|
||||||
|
t.Fatal("Job desc is nil in captured request")
|
||||||
|
}
|
||||||
|
|
||||||
|
if j.TimeLimit == nil {
|
||||||
|
t.Fatal("TimeLimit should not be nil, got nil")
|
||||||
|
}
|
||||||
|
if j.TimeLimit.Number == nil {
|
||||||
|
t.Fatal("TimeLimit.Number should not be nil, got nil")
|
||||||
|
}
|
||||||
|
if *j.TimeLimit.Number != int64(10080) {
|
||||||
|
t.Errorf("TimeLimit.Number = %d, want %d", *j.TimeLimit.Number, int64(10080))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessTask_DefaultStdoutStderr(t *testing.T) {
|
||||||
|
jobID := int32(42)
|
||||||
|
|
||||||
|
var capturedReq slurm.JobSubmitReq
|
||||||
|
|
||||||
|
env := newTaskTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&capturedReq); err != nil {
|
||||||
|
t.Fatalf("decode request body: %v", err)
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(slurm.OpenapiJobSubmitResponse{
|
||||||
|
Result: &slurm.JobSubmitResponseMsg{JobID: &jobID},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer env.close()
|
||||||
|
|
||||||
|
appID := env.createApp(t, "default-std-app", "#!/bin/bash\necho hello", json.RawMessage(`[]`))
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
task := &model.Task{
|
||||||
|
AppName: "default-std-app",
|
||||||
|
AppID: appID,
|
||||||
|
Status: model.TaskStatusSubmitted,
|
||||||
|
SubmittedAt: time.Now(),
|
||||||
|
Partition: "debug",
|
||||||
|
}
|
||||||
|
taskID, err := env.taskStore.Create(ctx, task)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create task in DB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = env.svc.ProcessTask(ctx, taskID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProcessTask: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
j := capturedReq.Job
|
||||||
|
if j == nil {
|
||||||
|
t.Fatal("Job desc is nil in captured request")
|
||||||
|
}
|
||||||
|
|
||||||
|
if j.StandardOutput == nil {
|
||||||
|
t.Fatal("StandardOutput should not be nil, got nil")
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(*j.StandardOutput, "/slurm-%j.out") {
|
||||||
|
t.Errorf("StandardOutput = %q, want suffix /slurm-%%j.out", *j.StandardOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
if j.StandardError == nil {
|
||||||
|
t.Fatal("StandardError should not be nil, got nil")
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(*j.StandardError, "/slurm-%j.err") {
|
||||||
|
t.Errorf("StandardError = %q, want suffix /slurm-%%j.err", *j.StandardError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessTask_NoOverrideWhenSet(t *testing.T) {
|
||||||
|
jobID := int32(42)
|
||||||
|
|
||||||
|
var capturedReq slurm.JobSubmitReq
|
||||||
|
|
||||||
|
env := newTaskTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&capturedReq); err != nil {
|
||||||
|
t.Fatalf("decode request body: %v", err)
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(slurm.OpenapiJobSubmitResponse{
|
||||||
|
Result: &slurm.JobSubmitResponseMsg{JobID: &jobID},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer env.close()
|
||||||
|
|
||||||
|
appID := env.createApp(t, "no-override-app", "#!/bin/bash\necho hello", json.RawMessage(`[]`))
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
customTL := int32(60)
|
||||||
|
customOut := "/custom/path.out"
|
||||||
|
task := &model.Task{
|
||||||
|
AppName: "no-override-app",
|
||||||
|
AppID: appID,
|
||||||
|
Status: model.TaskStatusSubmitted,
|
||||||
|
SubmittedAt: time.Now(),
|
||||||
|
Partition: "debug",
|
||||||
|
TimeLimit: &customTL,
|
||||||
|
StandardOutput: &customOut,
|
||||||
|
}
|
||||||
|
taskID, err := env.taskStore.Create(ctx, task)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create task in DB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = env.svc.ProcessTask(ctx, taskID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProcessTask: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
j := capturedReq.Job
|
||||||
|
if j == nil {
|
||||||
|
t.Fatal("Job desc is nil in captured request")
|
||||||
|
}
|
||||||
|
|
||||||
|
if j.TimeLimit == nil {
|
||||||
|
t.Fatal("TimeLimit should not be nil, got nil")
|
||||||
|
}
|
||||||
|
if j.TimeLimit.Number == nil {
|
||||||
|
t.Fatal("TimeLimit.Number should not be nil, got nil")
|
||||||
|
}
|
||||||
|
if *j.TimeLimit.Number != int64(60) {
|
||||||
|
t.Errorf("TimeLimit.Number = %d, want %d (user value should be preserved)", *j.TimeLimit.Number, int64(60))
|
||||||
|
}
|
||||||
|
|
||||||
|
if j.StandardOutput == nil {
|
||||||
|
t.Fatal("StandardOutput should not be nil, got nil")
|
||||||
|
}
|
||||||
|
if *j.StandardOutput != "/custom/path.out" {
|
||||||
|
t.Errorf("StandardOutput = %q, want %q (user value should be preserved)", *j.StandardOutput, "/custom/path.out")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,14 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gcy_hpc_server/internal/model"
|
"gcy_hpc_server/internal/model"
|
||||||
"gcy_hpc_server/internal/slurm"
|
"gcy_hpc_server/internal/slurm"
|
||||||
@@ -536,3 +538,727 @@ func TestTaskService_ProcessTask_ValidateParams_ValidParamsSucceed(t *testing.T)
|
|||||||
t.Errorf("SlurmJobID = %v, want 99", updated.SlurmJobID)
|
t.Errorf("SlurmJobID = %v, want 99", updated.SlurmJobID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTaskService_ProcessTask_FileParamResolution(t *testing.T) {
|
||||||
|
jobID := int32(88)
|
||||||
|
env := newTaskTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
json.NewEncoder(w).Encode(slurm.OpenapiJobSubmitResponse{
|
||||||
|
Result: &slurm.JobSubmitResponseMsg{JobID: &jobID},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer env.close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
blob := &model.FileBlob{SHA256: "deadbeef", MinioKey: "test/file.bin", FileSize: 1024}
|
||||||
|
if err := env.db.Create(blob).Error; err != nil {
|
||||||
|
t.Fatalf("create blob: %v", err)
|
||||||
|
}
|
||||||
|
file := &model.File{Name: "model_weights.bin", BlobSHA256: blob.SHA256}
|
||||||
|
if err := env.fileStore.Create(ctx, file); err != nil {
|
||||||
|
t.Fatalf("create file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
appID := env.createApp(t, "file-param-app",
|
||||||
|
"#!/bin/bash\n#SBATCH --chdir=$WORK_DIR\npython train.py --model $MODEL_PATH",
|
||||||
|
json.RawMessage(`[{"name":"MODEL_PATH","type":"file","required":true}]`))
|
||||||
|
|
||||||
|
// No InputFileIDs because stagingSvc is nil in tests; file is still resolvable via fileStore.
|
||||||
|
task, err := env.svc.CreateTask(ctx, &model.CreateTaskRequest{
|
||||||
|
AppID: appID,
|
||||||
|
Values: map[string]string{"MODEL_PATH": fmt.Sprintf("%d", file.ID)},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateTask: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = env.svc.ProcessTask(ctx, task.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProcessTask with file param: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, _ := env.taskStore.GetByID(ctx, task.ID)
|
||||||
|
if updated.Status != model.TaskStatusQueued {
|
||||||
|
t.Errorf("Status = %q, want %q", updated.Status, model.TaskStatusQueued)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskService_ProcessTask_WorkDirInScript(t *testing.T) {
|
||||||
|
jobID := int32(77)
|
||||||
|
env := newTaskTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
json.NewEncoder(w).Encode(slurm.OpenapiJobSubmitResponse{
|
||||||
|
Result: &slurm.JobSubmitResponseMsg{JobID: &jobID},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer env.close()
|
||||||
|
|
||||||
|
appID := env.createApp(t, "workdir-app",
|
||||||
|
"#!/bin/bash\n#SBATCH --chdir=$WORK_DIR\necho hello",
|
||||||
|
json.RawMessage(`[]`))
|
||||||
|
|
||||||
|
task, err := env.svc.CreateTask(context.Background(), &model.CreateTaskRequest{
|
||||||
|
AppID: appID,
|
||||||
|
Values: map[string]string{},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateTask: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = env.svc.ProcessTask(context.Background(), task.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProcessTask: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, _ := env.taskStore.GetByID(context.Background(), task.ID)
|
||||||
|
if updated.WorkDir == "" {
|
||||||
|
t.Fatal("WorkDir should be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskService_ProcessTask_FileParamInvalidID(t *testing.T) {
|
||||||
|
jobID := int32(42)
|
||||||
|
env := newTaskTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
json.NewEncoder(w).Encode(slurm.OpenapiJobSubmitResponse{
|
||||||
|
Result: &slurm.JobSubmitResponseMsg{JobID: &jobID},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer env.close()
|
||||||
|
|
||||||
|
appID := env.createApp(t, "bad-file-app",
|
||||||
|
"#!/bin/bash\npython train.py --model $MODEL",
|
||||||
|
json.RawMessage(`[{"name":"MODEL","type":"file","required":true}]`))
|
||||||
|
|
||||||
|
task, err := env.svc.CreateTask(context.Background(), &model.CreateTaskRequest{
|
||||||
|
AppID: appID,
|
||||||
|
Values: map[string]string{"MODEL": "not_a_number"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateTask: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = env.svc.ProcessTask(context.Background(), task.ID)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-numeric file ID in file-type param")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "file ID") {
|
||||||
|
t.Errorf("error should mention 'file ID', got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskService_ProcessTask_FileParamNotInInputFiles(t *testing.T) {
|
||||||
|
jobID := int32(42)
|
||||||
|
env := newTaskTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
json.NewEncoder(w).Encode(slurm.OpenapiJobSubmitResponse{
|
||||||
|
Result: &slurm.JobSubmitResponseMsg{JobID: &jobID},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer env.close()
|
||||||
|
|
||||||
|
appID := env.createApp(t, "missing-file-app",
|
||||||
|
"#!/bin/bash\npython train.py --model $MODEL",
|
||||||
|
json.RawMessage(`[{"name":"MODEL","type":"file","required":true}]`))
|
||||||
|
|
||||||
|
task, err := env.svc.CreateTask(context.Background(), &model.CreateTaskRequest{
|
||||||
|
AppID: appID,
|
||||||
|
Values: map[string]string{"MODEL": "999"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateTask: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = env.svc.ProcessTask(context.Background(), task.ID)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for file_id not in input_file_ids")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "not found") {
|
||||||
|
t.Errorf("error should mention 'not found', got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Pointer helpers for scheduling field tests ---
|
||||||
|
|
||||||
|
func int64Ptr(i int64) *int64 { return &i }
|
||||||
|
func boolPtr(b bool) *bool { return &b }
|
||||||
|
|
||||||
|
func TestDerefInt32ToStr(t *testing.T) {
|
||||||
|
t.Run("nil_returns_empty", func(t *testing.T) {
|
||||||
|
if got := derefInt32ToStr(nil); got != "" {
|
||||||
|
t.Errorf("derefInt32ToStr(nil) = %q, want %q", got, "")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("zero_returns_zero_string", func(t *testing.T) {
|
||||||
|
if got := derefInt32ToStr(int32Ptr(0)); got != "0" {
|
||||||
|
t.Errorf("derefInt32ToStr(0) = %q, want %q", got, "0")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("positive_returns_string", func(t *testing.T) {
|
||||||
|
if got := derefInt32ToStr(int32Ptr(60)); got != "60" {
|
||||||
|
t.Errorf("derefInt32ToStr(60) = %q, want %q", got, "60")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTask_SchedulingFields(t *testing.T) {
|
||||||
|
env := newTaskTestEnv(t, nil)
|
||||||
|
defer env.close()
|
||||||
|
|
||||||
|
appID := env.createApp(t, "sched-app", "#!/bin/bash\necho hello", json.RawMessage(`[]`))
|
||||||
|
|
||||||
|
var (
|
||||||
|
partition = "gpu"
|
||||||
|
cpus = int32(8)
|
||||||
|
memoryPerNode = int64(4096)
|
||||||
|
memoryPerCpu = int64(1024)
|
||||||
|
timeLimit = int32(60)
|
||||||
|
qos = "high"
|
||||||
|
jobName = "test-job"
|
||||||
|
nodes = "2"
|
||||||
|
tasks = int32(4)
|
||||||
|
cpusPerTask = int32(2)
|
||||||
|
constraints = "gpu&fast"
|
||||||
|
reservation = "resv01"
|
||||||
|
account = "proj-alpha"
|
||||||
|
nice = int32(100)
|
||||||
|
mailType = "BEGIN,END"
|
||||||
|
mailUser = "admin@example.com"
|
||||||
|
stdOut = "/tmp/job_%j.out"
|
||||||
|
stdErr = "/tmp/job_%j.err"
|
||||||
|
stdIn = "/dev/null"
|
||||||
|
reqNodes = "node01,node02"
|
||||||
|
exclNodes = "node03,node04"
|
||||||
|
beginTime = int64(1700000000)
|
||||||
|
deadline = int64(1700099999)
|
||||||
|
array = "1-10"
|
||||||
|
dependency = "afterok:123"
|
||||||
|
requeue = true
|
||||||
|
killOnNodeFail = true
|
||||||
|
)
|
||||||
|
|
||||||
|
task, err := env.svc.CreateTask(context.Background(), &model.CreateTaskRequest{
|
||||||
|
AppID: appID,
|
||||||
|
TaskName: "sched-task",
|
||||||
|
Partition: &partition,
|
||||||
|
Cpus: &cpus,
|
||||||
|
MemoryPerNode: &memoryPerNode,
|
||||||
|
MemoryPerCpu: &memoryPerCpu,
|
||||||
|
TimeLimit: &timeLimit,
|
||||||
|
QOS: &qos,
|
||||||
|
JobName: &jobName,
|
||||||
|
Nodes: &nodes,
|
||||||
|
Tasks: &tasks,
|
||||||
|
CpusPerTask: &cpusPerTask,
|
||||||
|
Constraints: &constraints,
|
||||||
|
Reservation: &reservation,
|
||||||
|
Account: &account,
|
||||||
|
Nice: &nice,
|
||||||
|
MailType: &mailType,
|
||||||
|
MailUser: &mailUser,
|
||||||
|
StandardOutput: &stdOut,
|
||||||
|
StandardError: &stdErr,
|
||||||
|
StandardInput: &stdIn,
|
||||||
|
RequiredNodes: &reqNodes,
|
||||||
|
ExcludedNodes: &exclNodes,
|
||||||
|
BeginTime: &beginTime,
|
||||||
|
Deadline: &deadline,
|
||||||
|
Array: &array,
|
||||||
|
Dependency: &dependency,
|
||||||
|
Requeue: &requeue,
|
||||||
|
KillOnNodeFail: &killOnNodeFail,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateTask: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if task.Partition != partition {
|
||||||
|
t.Errorf("Partition = %q, want %q", task.Partition, partition)
|
||||||
|
}
|
||||||
|
if task.Cpus == nil || *task.Cpus != cpus {
|
||||||
|
t.Errorf("Cpus = %v, want %d", task.Cpus, cpus)
|
||||||
|
}
|
||||||
|
if task.MemoryPerNode == nil || *task.MemoryPerNode != memoryPerNode {
|
||||||
|
t.Errorf("MemoryPerNode = %v, want %d", task.MemoryPerNode, memoryPerNode)
|
||||||
|
}
|
||||||
|
if task.MemoryPerCpu == nil || *task.MemoryPerCpu != memoryPerCpu {
|
||||||
|
t.Errorf("MemoryPerCpu = %v, want %d", task.MemoryPerCpu, memoryPerCpu)
|
||||||
|
}
|
||||||
|
if task.TimeLimit == nil || *task.TimeLimit != timeLimit {
|
||||||
|
t.Errorf("TimeLimit = %v, want %d", task.TimeLimit, timeLimit)
|
||||||
|
}
|
||||||
|
if task.QOS == nil || *task.QOS != qos {
|
||||||
|
t.Errorf("QOS = %v, want %q", task.QOS, qos)
|
||||||
|
}
|
||||||
|
if task.JobName == nil || *task.JobName != jobName {
|
||||||
|
t.Errorf("JobName = %v, want %q", task.JobName, jobName)
|
||||||
|
}
|
||||||
|
if task.Nodes == nil || *task.Nodes != nodes {
|
||||||
|
t.Errorf("Nodes = %v, want %q", task.Nodes, nodes)
|
||||||
|
}
|
||||||
|
if task.Tasks == nil || *task.Tasks != tasks {
|
||||||
|
t.Errorf("Tasks = %v, want %d", task.Tasks, tasks)
|
||||||
|
}
|
||||||
|
if task.CpusPerTask == nil || *task.CpusPerTask != cpusPerTask {
|
||||||
|
t.Errorf("CpusPerTask = %v, want %d", task.CpusPerTask, cpusPerTask)
|
||||||
|
}
|
||||||
|
if task.Constraints == nil || *task.Constraints != constraints {
|
||||||
|
t.Errorf("Constraints = %v, want %q", task.Constraints, constraints)
|
||||||
|
}
|
||||||
|
if task.Reservation == nil || *task.Reservation != reservation {
|
||||||
|
t.Errorf("Reservation = %v, want %q", task.Reservation, reservation)
|
||||||
|
}
|
||||||
|
if task.Account == nil || *task.Account != account {
|
||||||
|
t.Errorf("Account = %v, want %q", task.Account, account)
|
||||||
|
}
|
||||||
|
if task.Nice == nil || *task.Nice != nice {
|
||||||
|
t.Errorf("Nice = %v, want %d", task.Nice, nice)
|
||||||
|
}
|
||||||
|
if task.MailType == nil || *task.MailType != mailType {
|
||||||
|
t.Errorf("MailType = %v, want %q", task.MailType, mailType)
|
||||||
|
}
|
||||||
|
if task.MailUser == nil || *task.MailUser != mailUser {
|
||||||
|
t.Errorf("MailUser = %v, want %q", task.MailUser, mailUser)
|
||||||
|
}
|
||||||
|
if task.StandardOutput == nil || *task.StandardOutput != stdOut {
|
||||||
|
t.Errorf("StandardOutput = %v, want %q", task.StandardOutput, stdOut)
|
||||||
|
}
|
||||||
|
if task.StandardError == nil || *task.StandardError != stdErr {
|
||||||
|
t.Errorf("StandardError = %v, want %q", task.StandardError, stdErr)
|
||||||
|
}
|
||||||
|
if task.StandardInput == nil || *task.StandardInput != stdIn {
|
||||||
|
t.Errorf("StandardInput = %v, want %q", task.StandardInput, stdIn)
|
||||||
|
}
|
||||||
|
if task.RequiredNodes == nil || *task.RequiredNodes != reqNodes {
|
||||||
|
t.Errorf("RequiredNodes = %v, want %q", task.RequiredNodes, reqNodes)
|
||||||
|
}
|
||||||
|
if task.ExcludedNodes == nil || *task.ExcludedNodes != exclNodes {
|
||||||
|
t.Errorf("ExcludedNodes = %v, want %q", task.ExcludedNodes, exclNodes)
|
||||||
|
}
|
||||||
|
if task.BeginTime == nil || *task.BeginTime != beginTime {
|
||||||
|
t.Errorf("BeginTime = %v, want %d", task.BeginTime, beginTime)
|
||||||
|
}
|
||||||
|
if task.Deadline == nil || *task.Deadline != deadline {
|
||||||
|
t.Errorf("Deadline = %v, want %d", task.Deadline, deadline)
|
||||||
|
}
|
||||||
|
if task.Array == nil || *task.Array != array {
|
||||||
|
t.Errorf("Array = %v, want %q", task.Array, array)
|
||||||
|
}
|
||||||
|
if task.Dependency == nil || *task.Dependency != dependency {
|
||||||
|
t.Errorf("Dependency = %v, want %q", task.Dependency, dependency)
|
||||||
|
}
|
||||||
|
if task.Requeue == nil || *task.Requeue != requeue {
|
||||||
|
t.Errorf("Requeue = %v, want %v", task.Requeue, requeue)
|
||||||
|
}
|
||||||
|
if task.KillOnNodeFail == nil || *task.KillOnNodeFail != killOnNodeFail {
|
||||||
|
t.Errorf("KillOnNodeFail = %v, want %v", task.KillOnNodeFail, killOnNodeFail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTask_BackwardCompat(t *testing.T) {
|
||||||
|
env := newTaskTestEnv(t, nil)
|
||||||
|
defer env.close()
|
||||||
|
|
||||||
|
appID := env.createApp(t, "compat-app", "#!/bin/bash\necho hello", json.RawMessage(`[]`))
|
||||||
|
|
||||||
|
task, err := env.svc.CreateTask(context.Background(), &model.CreateTaskRequest{
|
||||||
|
AppID: appID,
|
||||||
|
TaskName: "compat-task",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateTask: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if task.Partition != "" {
|
||||||
|
t.Errorf("Partition = %q, want empty", task.Partition)
|
||||||
|
}
|
||||||
|
if task.Cpus != nil {
|
||||||
|
t.Errorf("Cpus = %v, want nil", task.Cpus)
|
||||||
|
}
|
||||||
|
if task.MemoryPerNode != nil {
|
||||||
|
t.Errorf("MemoryPerNode = %v, want nil", task.MemoryPerNode)
|
||||||
|
}
|
||||||
|
if task.MemoryPerCpu != nil {
|
||||||
|
t.Errorf("MemoryPerCpu = %v, want nil", task.MemoryPerCpu)
|
||||||
|
}
|
||||||
|
if task.TimeLimit != nil {
|
||||||
|
t.Errorf("TimeLimit = %v, want nil", task.TimeLimit)
|
||||||
|
}
|
||||||
|
if task.QOS != nil {
|
||||||
|
t.Errorf("QOS = %v, want nil", task.QOS)
|
||||||
|
}
|
||||||
|
if task.JobName != nil {
|
||||||
|
t.Errorf("JobName = %v, want nil", task.JobName)
|
||||||
|
}
|
||||||
|
if task.Nodes != nil {
|
||||||
|
t.Errorf("Nodes = %v, want nil", task.Nodes)
|
||||||
|
}
|
||||||
|
if task.Tasks != nil {
|
||||||
|
t.Errorf("Tasks = %v, want nil", task.Tasks)
|
||||||
|
}
|
||||||
|
if task.CpusPerTask != nil {
|
||||||
|
t.Errorf("CpusPerTask = %v, want nil", task.CpusPerTask)
|
||||||
|
}
|
||||||
|
if task.Constraints != nil {
|
||||||
|
t.Errorf("Constraints = %v, want nil", task.Constraints)
|
||||||
|
}
|
||||||
|
if task.Reservation != nil {
|
||||||
|
t.Errorf("Reservation = %v, want nil", task.Reservation)
|
||||||
|
}
|
||||||
|
if task.Account != nil {
|
||||||
|
t.Errorf("Account = %v, want nil", task.Account)
|
||||||
|
}
|
||||||
|
if task.Nice != nil {
|
||||||
|
t.Errorf("Nice = %v, want nil", task.Nice)
|
||||||
|
}
|
||||||
|
if task.MailType != nil {
|
||||||
|
t.Errorf("MailType = %v, want nil", task.MailType)
|
||||||
|
}
|
||||||
|
if task.MailUser != nil {
|
||||||
|
t.Errorf("MailUser = %v, want nil", task.MailUser)
|
||||||
|
}
|
||||||
|
if task.StandardOutput != nil {
|
||||||
|
t.Errorf("StandardOutput = %v, want nil", task.StandardOutput)
|
||||||
|
}
|
||||||
|
if task.StandardError != nil {
|
||||||
|
t.Errorf("StandardError = %v, want nil", task.StandardError)
|
||||||
|
}
|
||||||
|
if task.StandardInput != nil {
|
||||||
|
t.Errorf("StandardInput = %v, want nil", task.StandardInput)
|
||||||
|
}
|
||||||
|
if task.RequiredNodes != nil {
|
||||||
|
t.Errorf("RequiredNodes = %v, want nil", task.RequiredNodes)
|
||||||
|
}
|
||||||
|
if task.ExcludedNodes != nil {
|
||||||
|
t.Errorf("ExcludedNodes = %v, want nil", task.ExcludedNodes)
|
||||||
|
}
|
||||||
|
if task.BeginTime != nil {
|
||||||
|
t.Errorf("BeginTime = %v, want nil", task.BeginTime)
|
||||||
|
}
|
||||||
|
if task.Deadline != nil {
|
||||||
|
t.Errorf("Deadline = %v, want nil", task.Deadline)
|
||||||
|
}
|
||||||
|
if task.Array != nil {
|
||||||
|
t.Errorf("Array = %v, want nil", task.Array)
|
||||||
|
}
|
||||||
|
if task.Dependency != nil {
|
||||||
|
t.Errorf("Dependency = %v, want nil", task.Dependency)
|
||||||
|
}
|
||||||
|
if task.Requeue != nil {
|
||||||
|
t.Errorf("Requeue = %v, want nil", task.Requeue)
|
||||||
|
}
|
||||||
|
if task.KillOnNodeFail != nil {
|
||||||
|
t.Errorf("KillOnNodeFail = %v, want nil", task.KillOnNodeFail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessTask_SchedulingParams(t *testing.T) {
|
||||||
|
jobID := int32(42)
|
||||||
|
|
||||||
|
var (
|
||||||
|
cpus = int32(8)
|
||||||
|
memoryPerNode = int64(4096)
|
||||||
|
memoryPerCpu = int64(1024)
|
||||||
|
timeLimit = int32(60)
|
||||||
|
qos = "high"
|
||||||
|
jobName = "test-job"
|
||||||
|
nodes = "2"
|
||||||
|
tasks = int32(4)
|
||||||
|
cpusPerTask = int32(2)
|
||||||
|
constraints = "gpu&fast"
|
||||||
|
reservation = "resv01"
|
||||||
|
account = "proj-alpha"
|
||||||
|
nice = int32(100)
|
||||||
|
mailType = "BEGIN,END"
|
||||||
|
mailUser = "admin@example.com"
|
||||||
|
stdOut = "/tmp/job_%j.out"
|
||||||
|
stdErr = "/tmp/job_%j.err"
|
||||||
|
stdIn = "/dev/null"
|
||||||
|
reqNodes = "node01,node02"
|
||||||
|
exclNodes = "node03,node04"
|
||||||
|
beginTime = int64(1700000000)
|
||||||
|
deadline = int64(1700099999)
|
||||||
|
array = "1-10"
|
||||||
|
dependency = "afterok:123"
|
||||||
|
requeue = true
|
||||||
|
killOnNodeFail = true
|
||||||
|
)
|
||||||
|
|
||||||
|
var capturedReq slurm.JobSubmitReq
|
||||||
|
|
||||||
|
env := newTaskTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&capturedReq); err != nil {
|
||||||
|
t.Fatalf("decode request body: %v", err)
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(slurm.OpenapiJobSubmitResponse{
|
||||||
|
Result: &slurm.JobSubmitResponseMsg{JobID: &jobID},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer env.close()
|
||||||
|
|
||||||
|
appID := env.createApp(t, "sched-app", "#!/bin/bash\necho hello", json.RawMessage(`[]`))
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
task := &model.Task{
|
||||||
|
AppName: "sched-app",
|
||||||
|
AppID: appID,
|
||||||
|
Status: model.TaskStatusSubmitted,
|
||||||
|
SubmittedAt: time.Now(),
|
||||||
|
Partition: "gpu",
|
||||||
|
Cpus: &cpus,
|
||||||
|
MemoryPerNode: &memoryPerNode,
|
||||||
|
MemoryPerCpu: &memoryPerCpu,
|
||||||
|
TimeLimit: &timeLimit,
|
||||||
|
QOS: &qos,
|
||||||
|
JobName: &jobName,
|
||||||
|
Nodes: &nodes,
|
||||||
|
Tasks: &tasks,
|
||||||
|
CpusPerTask: &cpusPerTask,
|
||||||
|
Constraints: &constraints,
|
||||||
|
Reservation: &reservation,
|
||||||
|
Account: &account,
|
||||||
|
Nice: &nice,
|
||||||
|
MailType: &mailType,
|
||||||
|
MailUser: &mailUser,
|
||||||
|
StandardOutput: &stdOut,
|
||||||
|
StandardError: &stdErr,
|
||||||
|
StandardInput: &stdIn,
|
||||||
|
RequiredNodes: &reqNodes,
|
||||||
|
ExcludedNodes: &exclNodes,
|
||||||
|
BeginTime: &beginTime,
|
||||||
|
Deadline: &deadline,
|
||||||
|
Array: &array,
|
||||||
|
Dependency: &dependency,
|
||||||
|
Requeue: &requeue,
|
||||||
|
KillOnNodeFail: &killOnNodeFail,
|
||||||
|
}
|
||||||
|
taskID, err := env.taskStore.Create(ctx, task)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create task in DB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = env.svc.ProcessTask(ctx, taskID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProcessTask: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
j := capturedReq.Job
|
||||||
|
if j == nil {
|
||||||
|
t.Fatal("Job desc is nil in captured request")
|
||||||
|
}
|
||||||
|
|
||||||
|
if j.Partition == nil || *j.Partition != "gpu" {
|
||||||
|
t.Errorf("Partition = %v, want %q", j.Partition, "gpu")
|
||||||
|
}
|
||||||
|
if j.MinimumCpus == nil || *j.MinimumCpus != int32(8) {
|
||||||
|
t.Errorf("MinimumCpus = %v, want 8", j.MinimumCpus)
|
||||||
|
}
|
||||||
|
if j.TimeLimit == nil || j.TimeLimit.Number == nil || *j.TimeLimit.Number != int64(60) {
|
||||||
|
t.Errorf("TimeLimit = %v, want 60", j.TimeLimit)
|
||||||
|
}
|
||||||
|
if j.Qos == nil || *j.Qos != "high" {
|
||||||
|
t.Errorf("Qos = %v, want %q", j.Qos, "high")
|
||||||
|
}
|
||||||
|
if j.Name == nil || *j.Name != "test-job" {
|
||||||
|
t.Errorf("Name = %v, want %q", j.Name, "test-job")
|
||||||
|
}
|
||||||
|
if j.MemoryPerNode == nil || j.MemoryPerNode.Number == nil || *j.MemoryPerNode.Number != int64(4096) {
|
||||||
|
t.Errorf("MemoryPerNode = %v, want 4096", j.MemoryPerNode)
|
||||||
|
}
|
||||||
|
if j.MemoryPerCpu == nil || j.MemoryPerCpu.Number == nil || *j.MemoryPerCpu.Number != int64(1024) {
|
||||||
|
t.Errorf("MemoryPerCpu = %v, want 1024", j.MemoryPerCpu)
|
||||||
|
}
|
||||||
|
if j.Nodes == nil || *j.Nodes != "2" {
|
||||||
|
t.Errorf("Nodes = %v, want %q", j.Nodes, "2")
|
||||||
|
}
|
||||||
|
if j.Tasks == nil || *j.Tasks != int32(4) {
|
||||||
|
t.Errorf("Tasks = %v, want 4", j.Tasks)
|
||||||
|
}
|
||||||
|
if j.CpusPerTask == nil || *j.CpusPerTask != int32(2) {
|
||||||
|
t.Errorf("CpusPerTask = %v, want 2", j.CpusPerTask)
|
||||||
|
}
|
||||||
|
if j.Constraints == nil || *j.Constraints != "gpu&fast" {
|
||||||
|
t.Errorf("Constraints = %v, want %q", j.Constraints, "gpu&fast")
|
||||||
|
}
|
||||||
|
if j.Reservation == nil || *j.Reservation != "resv01" {
|
||||||
|
t.Errorf("Reservation = %v, want %q", j.Reservation, "resv01")
|
||||||
|
}
|
||||||
|
if j.Account == nil || *j.Account != "proj-alpha" {
|
||||||
|
t.Errorf("Account = %v, want %q", j.Account, "proj-alpha")
|
||||||
|
}
|
||||||
|
if j.Nice == nil || *j.Nice != int32(100) {
|
||||||
|
t.Errorf("Nice = %v, want 100", j.Nice)
|
||||||
|
}
|
||||||
|
// MailType is split by comma in job_service.go
|
||||||
|
if len(j.MailType) != 2 || j.MailType[0] != "BEGIN" || j.MailType[1] != "END" {
|
||||||
|
t.Errorf("MailType = %v, want [BEGIN, END]", j.MailType)
|
||||||
|
}
|
||||||
|
if j.MailUser == nil || *j.MailUser != "admin@example.com" {
|
||||||
|
t.Errorf("MailUser = %v, want %q", j.MailUser, "admin@example.com")
|
||||||
|
}
|
||||||
|
if j.StandardOutput == nil || *j.StandardOutput != "/tmp/job_%j.out" {
|
||||||
|
t.Errorf("StandardOutput = %v, want %q", j.StandardOutput, "/tmp/job_%j.out")
|
||||||
|
}
|
||||||
|
if j.StandardError == nil || *j.StandardError != "/tmp/job_%j.err" {
|
||||||
|
t.Errorf("StandardError = %v, want %q", j.StandardError, "/tmp/job_%j.err")
|
||||||
|
}
|
||||||
|
if j.StandardInput == nil || *j.StandardInput != "/dev/null" {
|
||||||
|
t.Errorf("StandardInput = %v, want %q", j.StandardInput, "/dev/null")
|
||||||
|
}
|
||||||
|
// RequiredNodes/ExcludedNodes are split by comma in job_service.go
|
||||||
|
if len(j.RequiredNodes) != 2 || j.RequiredNodes[0] != "node01" || j.RequiredNodes[1] != "node02" {
|
||||||
|
t.Errorf("RequiredNodes = %v, want [node01, node02]", j.RequiredNodes)
|
||||||
|
}
|
||||||
|
if len(j.ExcludedNodes) != 2 || j.ExcludedNodes[0] != "node03" || j.ExcludedNodes[1] != "node04" {
|
||||||
|
t.Errorf("ExcludedNodes = %v, want [node03, node04]", j.ExcludedNodes)
|
||||||
|
}
|
||||||
|
if j.BeginTime == nil || j.BeginTime.Number == nil || *j.BeginTime.Number != int64(1700000000) {
|
||||||
|
t.Errorf("BeginTime = %v, want 1700000000", j.BeginTime)
|
||||||
|
}
|
||||||
|
if j.Deadline == nil || *j.Deadline != int64(1700099999) {
|
||||||
|
t.Errorf("Deadline = %v, want 1700099999", j.Deadline)
|
||||||
|
}
|
||||||
|
if j.Array == nil || *j.Array != "1-10" {
|
||||||
|
t.Errorf("Array = %v, want %q", j.Array, "1-10")
|
||||||
|
}
|
||||||
|
if j.Dependency == nil || *j.Dependency != "afterok:123" {
|
||||||
|
t.Errorf("Dependency = %v, want %q", j.Dependency, "afterok:123")
|
||||||
|
}
|
||||||
|
if j.Requeue == nil || *j.Requeue != true {
|
||||||
|
t.Errorf("Requeue = %v, want true", j.Requeue)
|
||||||
|
}
|
||||||
|
if j.KillOnNodeFail == nil || *j.KillOnNodeFail != true {
|
||||||
|
t.Errorf("KillOnNodeFail = %v, want true", j.KillOnNodeFail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessTask_PartialSchedulingParams(t *testing.T) {
|
||||||
|
jobID := int32(42)
|
||||||
|
|
||||||
|
var capturedReq slurm.JobSubmitReq
|
||||||
|
|
||||||
|
env := newTaskTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&capturedReq); err != nil {
|
||||||
|
t.Fatalf("decode request body: %v", err)
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(slurm.OpenapiJobSubmitResponse{
|
||||||
|
Result: &slurm.JobSubmitResponseMsg{JobID: &jobID},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer env.close()
|
||||||
|
|
||||||
|
appID := env.createApp(t, "partial-app", "#!/bin/bash\necho hello", json.RawMessage(`[]`))
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
task := &model.Task{
|
||||||
|
AppName: "partial-app",
|
||||||
|
AppID: appID,
|
||||||
|
Status: model.TaskStatusSubmitted,
|
||||||
|
SubmittedAt: time.Now(),
|
||||||
|
Partition: "debug",
|
||||||
|
}
|
||||||
|
taskID, err := env.taskStore.Create(ctx, task)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create task in DB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = env.svc.ProcessTask(ctx, taskID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProcessTask: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
j := capturedReq.Job
|
||||||
|
if j == nil {
|
||||||
|
t.Fatal("Job desc is nil in captured request")
|
||||||
|
}
|
||||||
|
|
||||||
|
if j.Partition == nil || *j.Partition != "debug" {
|
||||||
|
t.Errorf("Partition = %v, want %q", j.Partition, "debug")
|
||||||
|
}
|
||||||
|
|
||||||
|
if j.MinimumCpus != nil {
|
||||||
|
t.Errorf("MinimumCpus = %v, want nil (no cpus set)", j.MinimumCpus)
|
||||||
|
}
|
||||||
|
if j.TimeLimit == nil {
|
||||||
|
t.Errorf("TimeLimit = nil, want non-nil (default should be injected)")
|
||||||
|
} else if j.TimeLimit.Number == nil {
|
||||||
|
t.Errorf("TimeLimit.Number = nil, want non-nil")
|
||||||
|
} else if *j.TimeLimit.Number != int64(10080) {
|
||||||
|
t.Errorf("TimeLimit.Number = %d, want %d (default)", *j.TimeLimit.Number, int64(10080))
|
||||||
|
}
|
||||||
|
if j.Qos != nil {
|
||||||
|
t.Errorf("Qos = %v, want nil (no qos set)", j.Qos)
|
||||||
|
}
|
||||||
|
if j.Name != nil {
|
||||||
|
t.Errorf("Name = %v, want nil (no job_name set)", j.Name)
|
||||||
|
}
|
||||||
|
if j.MemoryPerNode != nil {
|
||||||
|
t.Errorf("MemoryPerNode = %v, want nil", j.MemoryPerNode)
|
||||||
|
}
|
||||||
|
if j.MemoryPerCpu != nil {
|
||||||
|
t.Errorf("MemoryPerCpu = %v, want nil", j.MemoryPerCpu)
|
||||||
|
}
|
||||||
|
if j.Nodes != nil {
|
||||||
|
t.Errorf("Nodes = %v, want nil", j.Nodes)
|
||||||
|
}
|
||||||
|
if j.Tasks != nil {
|
||||||
|
t.Errorf("Tasks = %v, want nil", j.Tasks)
|
||||||
|
}
|
||||||
|
if j.CpusPerTask != nil {
|
||||||
|
t.Errorf("CpusPerTask = %v, want nil", j.CpusPerTask)
|
||||||
|
}
|
||||||
|
if j.Constraints != nil {
|
||||||
|
t.Errorf("Constraints = %v, want nil", j.Constraints)
|
||||||
|
}
|
||||||
|
if j.Reservation != nil {
|
||||||
|
t.Errorf("Reservation = %v, want nil", j.Reservation)
|
||||||
|
}
|
||||||
|
if j.Account != nil {
|
||||||
|
t.Errorf("Account = %v, want nil", j.Account)
|
||||||
|
}
|
||||||
|
if j.Nice != nil {
|
||||||
|
t.Errorf("Nice = %v, want nil", j.Nice)
|
||||||
|
}
|
||||||
|
if len(j.MailType) != 0 {
|
||||||
|
t.Errorf("MailType = %v, want empty", j.MailType)
|
||||||
|
}
|
||||||
|
if j.MailUser != nil {
|
||||||
|
t.Errorf("MailUser = %v, want nil", j.MailUser)
|
||||||
|
}
|
||||||
|
if j.StandardOutput == nil {
|
||||||
|
t.Errorf("StandardOutput = nil, want non-nil (default should be injected)")
|
||||||
|
} else if !strings.HasSuffix(*j.StandardOutput, "/slurm-%j.out") {
|
||||||
|
t.Errorf("StandardOutput = %q, want suffix /slurm-%%j.out (default)", *j.StandardOutput)
|
||||||
|
}
|
||||||
|
if j.StandardError == nil {
|
||||||
|
t.Errorf("StandardError = nil, want non-nil (default should be injected)")
|
||||||
|
} else if !strings.HasSuffix(*j.StandardError, "/slurm-%j.err") {
|
||||||
|
t.Errorf("StandardError = %q, want suffix /slurm-%%j.err (default)", *j.StandardError)
|
||||||
|
}
|
||||||
|
if j.StandardInput != nil {
|
||||||
|
t.Errorf("StandardInput = %v, want nil", j.StandardInput)
|
||||||
|
}
|
||||||
|
if len(j.RequiredNodes) != 0 {
|
||||||
|
t.Errorf("RequiredNodes = %v, want empty", j.RequiredNodes)
|
||||||
|
}
|
||||||
|
if len(j.ExcludedNodes) != 0 {
|
||||||
|
t.Errorf("ExcludedNodes = %v, want empty", j.ExcludedNodes)
|
||||||
|
}
|
||||||
|
if j.BeginTime != nil {
|
||||||
|
t.Errorf("BeginTime = %v, want nil", j.BeginTime)
|
||||||
|
}
|
||||||
|
if j.Deadline != nil {
|
||||||
|
t.Errorf("Deadline = %v, want nil", j.Deadline)
|
||||||
|
}
|
||||||
|
if j.Array != nil {
|
||||||
|
t.Errorf("Array = %v, want nil", j.Array)
|
||||||
|
}
|
||||||
|
if j.Dependency != nil {
|
||||||
|
t.Errorf("Dependency = %v, want nil", j.Dependency)
|
||||||
|
}
|
||||||
|
if j.Requeue != nil {
|
||||||
|
t.Errorf("Requeue = %v, want nil", j.Requeue)
|
||||||
|
}
|
||||||
|
if j.KillOnNodeFail != nil {
|
||||||
|
t.Errorf("KillOnNodeFail = %v, want nil", j.KillOnNodeFail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ func NewTestEnv(t interface {
|
|||||||
taskSvc := service.NewTaskService(taskStore, appStore, fileStore, blobStore, stagingSvc, jobSvc, workDir, logger)
|
taskSvc := service.NewTaskService(taskStore, appStore, fileStore, blobStore, stagingSvc, jobSvc, workDir, logger)
|
||||||
appSvc := service.NewApplicationService(appStore, jobSvc, workDir, logger, taskSvc)
|
appSvc := service.NewApplicationService(appStore, jobSvc, workDir, logger, taskSvc)
|
||||||
uploadSvc := service.NewUploadService(mockMinIO, blobStore, fileStore, uploadStore, minioCfg, db, logger)
|
uploadSvc := service.NewUploadService(mockMinIO, blobStore, fileStore, uploadStore, minioCfg, db, logger)
|
||||||
fileSvc := service.NewFileService(mockMinIO, blobStore, fileStore, minioCfg.Bucket, db, logger)
|
fileSvc := service.NewFileService(mockMinIO, blobStore, fileStore, folderStore, minioCfg.Bucket, db, logger)
|
||||||
|
|
||||||
// 9. All 7 Handler instances
|
// 9. All 7 Handler instances
|
||||||
jobH := handler.NewJobHandler(jobSvc, logger)
|
jobH := handler.NewJobHandler(jobSvc, logger)
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Vue 3 + TypeScript + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>HPC 集群管理</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2239
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
|
"axios": "^1.15.0",
|
||||||
|
"element-plus": "^2.13.7",
|
||||||
|
"vue": "^3.5.32",
|
||||||
|
"vue-router": "^5.0.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.12.2",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.5",
|
||||||
|
"@vue/tsconfig": "^0.9.1",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"unplugin-auto-import": "^21.0.0",
|
||||||
|
"unplugin-vue-components": "^32.0.0",
|
||||||
|
"vite": "^8.0.4",
|
||||||
|
"vue-tsc": "^3.2.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from '@playwright/test'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests',
|
||||||
|
timeout: 30000,
|
||||||
|
retries: 0,
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:5173',
|
||||||
|
headless: true,
|
||||||
|
launchOptions: {
|
||||||
|
executablePath: process.env.CHROME_PATH || '/opt/google/chrome/chrome',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,39 @@
|
|||||||
|
<template>
|
||||||
|
<el-config-provider :locale="zhCn">
|
||||||
|
<el-container style="height: 100vh">
|
||||||
|
<el-aside width="200px" style="border-right: 1px solid #e6e6e6">
|
||||||
|
<div style="padding: 16px; font-size: 16px; font-weight: 600; text-align: center">
|
||||||
|
HPC 集群管理
|
||||||
|
</div>
|
||||||
|
<el-menu :router="true" :default-active="route.path">
|
||||||
|
<el-menu-item index="/tasks/create">
|
||||||
|
<el-icon><CirclePlus /></el-icon>
|
||||||
|
<span>提交任务</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/tasks">
|
||||||
|
<el-icon><List /></el-icon>
|
||||||
|
<span>任务管理</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/files">
|
||||||
|
<el-icon><FolderOpened /></el-icon>
|
||||||
|
<span>文件管理</span>
|
||||||
|
</el-menu-item>
|
||||||
|
</el-menu>
|
||||||
|
</el-aside>
|
||||||
|
<el-main>
|
||||||
|
<router-view />
|
||||||
|
</el-main>
|
||||||
|
</el-container>
|
||||||
|
</el-config-provider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
|
import { List, CirclePlus, FolderOpened } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.title = 'HPC 集群管理'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import type { ApiResponse } from '@/types/jobs'
|
||||||
|
|
||||||
|
const apiClient = axios.create({
|
||||||
|
baseURL: '/api/v1',
|
||||||
|
})
|
||||||
|
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.data) {
|
||||||
|
const apiError = error.response.data as ApiResponse<unknown>
|
||||||
|
ElMessage.error(apiError.error || '请求失败')
|
||||||
|
} else {
|
||||||
|
ElMessage.error('无法连接到后端服务')
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default apiClient
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { ApiResponse } from '@/types/jobs'
|
||||||
|
import apiClient from '@/api/client'
|
||||||
|
import type { FileResponse, ListFilesResponse, UploadSessionResponse, InitUploadRequest, FolderResponse, CreateFolderRequest } from '@/types/files'
|
||||||
|
|
||||||
|
export function listFiles(params?: { folder_id?: number; page?: number; page_size?: number; search?: string }): Promise<ApiResponse<ListFilesResponse>> {
|
||||||
|
return apiClient.get('/files', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFile(id: number): Promise<ApiResponse<FileResponse>> {
|
||||||
|
return apiClient.get(`/files/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteFile(id: number): Promise<ApiResponse<{ message: string }>> {
|
||||||
|
return apiClient.delete(`/files/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadFileUrl(id: number): string {
|
||||||
|
return `/api/v1/files/${id}/download`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initUpload(data: InitUploadRequest): Promise<ApiResponse<FileResponse | UploadSessionResponse>> {
|
||||||
|
return apiClient.post('/files/uploads', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadChunk(sessionId: number, chunkIndex: number, chunk: Blob): Promise<ApiResponse<{ message: string }>> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('chunk', chunk)
|
||||||
|
return apiClient.put(`/files/uploads/${sessionId}/chunks/${chunkIndex}`, formData)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function completeUpload(sessionId: number): Promise<ApiResponse<FileResponse>> {
|
||||||
|
return apiClient.post(`/files/uploads/${sessionId}/complete`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelUpload(sessionId: number): Promise<ApiResponse<{ message: string }>> {
|
||||||
|
return apiClient.post(`/files/uploads/${sessionId}/cancel`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listFolders(params?: { parent_id?: number | null }): Promise<ApiResponse<FolderResponse[]>> {
|
||||||
|
return apiClient.get('/files/folders', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFolder(data: CreateFolderRequest): Promise<ApiResponse<FolderResponse>> {
|
||||||
|
return apiClient.post('/files/folders', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteFolder(id: number): Promise<ApiResponse<{ message: string }>> {
|
||||||
|
return apiClient.delete(`/files/folders/${id}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import apiClient from './client'
|
||||||
|
import type { ApiResponse, SubmitJobRequest, JobResponse, JobListResponse, JobHistoryParams } from '@/types/jobs'
|
||||||
|
|
||||||
|
export function submitJob(data: SubmitJobRequest): Promise<ApiResponse<JobResponse>> {
|
||||||
|
return apiClient.post('/jobs/submit', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getJobs(params?: { page?: number; page_size?: number }): Promise<ApiResponse<JobListResponse>> {
|
||||||
|
return apiClient.get('/jobs', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getJobHistory(params?: JobHistoryParams): Promise<ApiResponse<JobListResponse>> {
|
||||||
|
return apiClient.get('/jobs/history', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getJob(id: string): Promise<ApiResponse<JobResponse>> {
|
||||||
|
return apiClient.get(`/jobs/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelJob(id: string): Promise<ApiResponse<{ message: string }>> {
|
||||||
|
return apiClient.delete(`/jobs/${id}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { ApiResponse } from '@/types/jobs'
|
||||||
|
import apiClient from '@/api/client'
|
||||||
|
import type { ApplicationListResponse, CreateTaskRequest, TaskListResponse } from '@/types/tasks'
|
||||||
|
|
||||||
|
export function getApplications(params?: { page?: number; page_size?: number }): Promise<ApiResponse<ApplicationListResponse>> {
|
||||||
|
return apiClient.get('/applications', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTask(data: CreateTaskRequest): Promise<ApiResponse<{ id: number }>> {
|
||||||
|
return apiClient.post('/tasks', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listTasks(params?: { page?: number; page_size?: number; status?: string }): Promise<ApiResponse<TaskListResponse>> {
|
||||||
|
return apiClient.get('/tasks', { params })
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,6 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
createApp(App).use(router).mount('#app')
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHashHistory(),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
redirect: '/tasks',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/jobs',
|
||||||
|
name: 'JobsList',
|
||||||
|
component: () => import('@/views/Jobs/List.vue'),
|
||||||
|
meta: { title: '任务列表' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/jobs/history',
|
||||||
|
name: 'JobsHistory',
|
||||||
|
component: () => import('@/views/Jobs/History.vue'),
|
||||||
|
meta: { title: '任务历史' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/jobs/:id',
|
||||||
|
name: 'JobsDetail',
|
||||||
|
component: () => import('@/views/Jobs/Detail.vue'),
|
||||||
|
meta: { title: '任务详情' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/tasks',
|
||||||
|
name: 'TasksList',
|
||||||
|
component: () => import('@/views/Tasks/List.vue'),
|
||||||
|
meta: { title: '任务列表' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/tasks/create',
|
||||||
|
name: 'TasksCreate',
|
||||||
|
component: () => import('@/views/Tasks/Submit.vue'),
|
||||||
|
meta: { title: '提交任务' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/files',
|
||||||
|
name: 'FilesList',
|
||||||
|
component: () => import('@/views/Files/List.vue'),
|
||||||
|
meta: { title: '文件管理' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
redirect: '/tasks',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
export interface FileResponse {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
folder_id?: number | null
|
||||||
|
sha256: string
|
||||||
|
size: number
|
||||||
|
mime_type: string
|
||||||
|
folder_path?: string
|
||||||
|
user_id?: number | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListFilesResponse {
|
||||||
|
files?: FileResponse[]
|
||||||
|
total?: number
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FolderResponse {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
parent_id?: number | null
|
||||||
|
path: string
|
||||||
|
file_count: number
|
||||||
|
subfolder_count: number
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateFolderRequest {
|
||||||
|
name: string
|
||||||
|
parent_id?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadSessionResponse {
|
||||||
|
id: number
|
||||||
|
file_name: string
|
||||||
|
file_size: number
|
||||||
|
sha256: string
|
||||||
|
chunk_size: number
|
||||||
|
total_chunks: number
|
||||||
|
status: string
|
||||||
|
uploaded_chunks?: number[]
|
||||||
|
created_at?: string
|
||||||
|
expires_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InitUploadRequest {
|
||||||
|
file_name: string
|
||||||
|
file_size: number
|
||||||
|
sha256: string
|
||||||
|
chunk_size?: number
|
||||||
|
mime_type?: string
|
||||||
|
folder_id?: number | null
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
export interface ApiResponse<T> {
|
||||||
|
success: boolean
|
||||||
|
data?: T
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmitJobRequest {
|
||||||
|
script: string
|
||||||
|
partition?: string
|
||||||
|
qos?: string
|
||||||
|
cpus?: number
|
||||||
|
memory?: string
|
||||||
|
time_limit?: string
|
||||||
|
job_name?: string
|
||||||
|
environment?: Record<string, string>
|
||||||
|
work_dir?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobResponse {
|
||||||
|
job_id?: number
|
||||||
|
name?: string
|
||||||
|
job_state?: string[]
|
||||||
|
state_reason?: string
|
||||||
|
partition?: string
|
||||||
|
qos?: string
|
||||||
|
priority?: number | null
|
||||||
|
time_limit?: string
|
||||||
|
account?: string
|
||||||
|
user?: string
|
||||||
|
cluster?: string
|
||||||
|
cpus?: number | null
|
||||||
|
tasks?: number | null
|
||||||
|
node_count?: number | null
|
||||||
|
nodes?: string
|
||||||
|
batch_host?: string
|
||||||
|
submit_time?: number | null
|
||||||
|
start_time?: number | null
|
||||||
|
end_time?: number | null
|
||||||
|
exit_code?: number | null
|
||||||
|
standard_output?: string
|
||||||
|
standard_error?: string
|
||||||
|
standard_input?: string
|
||||||
|
working_directory?: string
|
||||||
|
command?: string
|
||||||
|
array_job_id?: number | null
|
||||||
|
array_task_id?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobListResponse {
|
||||||
|
jobs?: JobResponse[]
|
||||||
|
total?: number
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobHistoryParams {
|
||||||
|
users?: string
|
||||||
|
start_time?: string
|
||||||
|
end_time?: string
|
||||||
|
account?: string
|
||||||
|
partition?: string
|
||||||
|
state?: string
|
||||||
|
job_name?: string
|
||||||
|
submit_time?: string
|
||||||
|
cluster?: string
|
||||||
|
qos?: string
|
||||||
|
constraints?: string
|
||||||
|
exit_code?: string
|
||||||
|
node?: string
|
||||||
|
reservation?: string
|
||||||
|
groups?: string
|
||||||
|
wckey?: string
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
export interface ParameterSchema {
|
||||||
|
name: string
|
||||||
|
label?: string
|
||||||
|
type: string
|
||||||
|
required?: boolean
|
||||||
|
default?: string
|
||||||
|
options?: string[]
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Application {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
icon?: string
|
||||||
|
category?: string
|
||||||
|
script_template: string
|
||||||
|
parameters?: ParameterSchema[] | null
|
||||||
|
scope?: string
|
||||||
|
created_by?: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplicationListResponse {
|
||||||
|
applications?: Application[]
|
||||||
|
total?: number
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTaskRequest {
|
||||||
|
app_id: number
|
||||||
|
task_name?: string
|
||||||
|
values?: Record<string, string>
|
||||||
|
partition?: string
|
||||||
|
cpus?: number
|
||||||
|
job_name?: string
|
||||||
|
memory_per_node?: number
|
||||||
|
nodes?: string
|
||||||
|
tasks?: number
|
||||||
|
cpus_per_task?: number
|
||||||
|
file_ids?: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskResponse {
|
||||||
|
id: number
|
||||||
|
task_name: string
|
||||||
|
app_id: number
|
||||||
|
app_name: string
|
||||||
|
status: string
|
||||||
|
current_step: string
|
||||||
|
retry_count: number
|
||||||
|
slurm_job_id: number | null
|
||||||
|
work_dir: string
|
||||||
|
error_message: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
partition?: string
|
||||||
|
cpus?: number | null
|
||||||
|
memory_per_node?: number | null
|
||||||
|
memory_per_cpu?: number | null
|
||||||
|
time_limit?: number | null
|
||||||
|
qos?: string
|
||||||
|
job_name?: string
|
||||||
|
nodes?: string
|
||||||
|
tasks?: number | null
|
||||||
|
cpus_per_task?: number | null
|
||||||
|
constraints?: string
|
||||||
|
reservation?: string
|
||||||
|
account?: string
|
||||||
|
nice?: number | null
|
||||||
|
mail_type?: string
|
||||||
|
mail_user?: string
|
||||||
|
standard_output?: string
|
||||||
|
standard_error?: string
|
||||||
|
standard_input?: string
|
||||||
|
required_nodes?: string
|
||||||
|
excluded_nodes?: string
|
||||||
|
begin_time?: number | null
|
||||||
|
deadline?: number | null
|
||||||
|
array?: string
|
||||||
|
dependency?: string
|
||||||
|
requeue?: boolean
|
||||||
|
kill_on_node_fail?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskListResponse {
|
||||||
|
items?: TaskResponse[]
|
||||||
|
total?: number
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { listFiles, listFolders } from '@/api/files'
|
||||||
|
import type { FileResponse, FolderResponse } from '@/types/files'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
multiple?: boolean
|
||||||
|
max?: number
|
||||||
|
}>(), {
|
||||||
|
multiple: true,
|
||||||
|
max: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
'select': [files: FileResponse[]]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const currentFolderId = ref<number | null>(null)
|
||||||
|
const breadcrumbs = ref<Array<{ id: number | null; name: string }>>([{ id: null, name: '全部文件' }])
|
||||||
|
const folders = ref<FolderResponse[]>([])
|
||||||
|
const files = ref<FileResponse[]>([])
|
||||||
|
const selectedFileIds = ref<number[]>([])
|
||||||
|
|
||||||
|
const dialogVisible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (val: boolean) => emit('update:modelValue', val),
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (val) => {
|
||||||
|
if (val) {
|
||||||
|
currentFolderId.value = null
|
||||||
|
breadcrumbs.value = [{ id: null, name: '全部文件' }]
|
||||||
|
selectedFileIds.value = []
|
||||||
|
fetchContent()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchContent = async () => {
|
||||||
|
try {
|
||||||
|
const [foldersResp, filesResp] = await Promise.all([
|
||||||
|
listFolders({ parent_id: currentFolderId.value ?? undefined }),
|
||||||
|
listFiles({ folder_id: currentFolderId.value ?? undefined, page_size: 100 }),
|
||||||
|
])
|
||||||
|
folders.value = foldersResp.data || []
|
||||||
|
files.value = filesResp.data?.files || []
|
||||||
|
} catch {
|
||||||
|
// Error handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigateToFolder = (folder: FolderResponse) => {
|
||||||
|
currentFolderId.value = folder.id
|
||||||
|
breadcrumbs.value.push({ id: folder.id, name: folder.name })
|
||||||
|
fetchContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigateToBreadcrumb = (index: number) => {
|
||||||
|
const target = breadcrumbs.value[index]
|
||||||
|
currentFolderId.value = target.id
|
||||||
|
breadcrumbs.value = breadcrumbs.value.slice(0, index + 1)
|
||||||
|
fetchContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
const selected = files.value.filter(f => selectedFileIds.value.includes(f.id))
|
||||||
|
emit('select', selected)
|
||||||
|
dialogVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
dialogVisible.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog v-model="dialogVisible" title="选择文件" width="700px" @close="dialogVisible = false">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<el-breadcrumb separator="/">
|
||||||
|
<el-breadcrumb-item
|
||||||
|
v-for="(crumb, index) in breadcrumbs"
|
||||||
|
:key="index"
|
||||||
|
@click="navigateToBreadcrumb(index)"
|
||||||
|
>
|
||||||
|
<span style="cursor: pointer">{{ crumb.name }}</span>
|
||||||
|
</el-breadcrumb-item>
|
||||||
|
</el-breadcrumb>
|
||||||
|
|
||||||
|
<!-- Folders -->
|
||||||
|
<div v-if="folders.length" style="margin-top: 12px">
|
||||||
|
<div
|
||||||
|
v-for="folder in folders"
|
||||||
|
:key="folder.id"
|
||||||
|
style="padding: 8px; cursor: pointer; border-bottom: 1px solid #f0f0f0"
|
||||||
|
@click="navigateToFolder(folder)"
|
||||||
|
>
|
||||||
|
📁 {{ folder.name }} ({{ folder.file_count }} 文件)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Files with checkboxes -->
|
||||||
|
<div v-if="files.length" style="margin-top: 12px; max-height: 400px; overflow-y: auto">
|
||||||
|
<el-checkbox-group v-model="selectedFileIds">
|
||||||
|
<div v-for="file in files" :key="file.id" style="padding: 6px 0">
|
||||||
|
<el-checkbox :label="file.id" :value="file.id">
|
||||||
|
{{ file.name }}
|
||||||
|
</el-checkbox>
|
||||||
|
</div>
|
||||||
|
</el-checkbox-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-empty v-else-if="!folders.length" description="暂无文件" />
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<template #footer>
|
||||||
|
<span>
|
||||||
|
<el-button @click="handleCancel">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleConfirm">
|
||||||
|
确认 ({{ selectedFileIds.length }})
|
||||||
|
</el-button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { listFiles, deleteFile, downloadFileUrl, listFolders, createFolder, deleteFolder } from '@/api/files'
|
||||||
|
import UploadButton from './UploadButton.vue'
|
||||||
|
import type { FileResponse, FolderResponse } from '@/types/files'
|
||||||
|
|
||||||
|
const files = ref<FileResponse[]>([])
|
||||||
|
const folders = ref<FolderResponse[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const loading = ref(false)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
|
||||||
|
const breadcrumbs = ref<Array<{ id: number | null; name: string }>>([{ id: null, name: '全部文件' }])
|
||||||
|
const currentFolderId = computed(() => {
|
||||||
|
const last = breadcrumbs.value[breadcrumbs.value.length - 1]
|
||||||
|
return last?.id ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const showCreateFolderDialog = ref(false)
|
||||||
|
const newFolderName = ref('')
|
||||||
|
|
||||||
|
const formatSize = (bytes: number): string => {
|
||||||
|
if (bytes < 1024) return bytes + ' B'
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB'
|
||||||
|
return (bytes / 1024 / 1024 / 1024).toFixed(2) + ' GB'
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchFiles = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const isSearching = searchQuery.value.trim() !== ''
|
||||||
|
const params: { folder_id?: number; page?: number; page_size?: number; search?: string } = {
|
||||||
|
page: currentPage.value,
|
||||||
|
page_size: pageSize.value,
|
||||||
|
}
|
||||||
|
if (isSearching) {
|
||||||
|
params.search = searchQuery.value.trim()
|
||||||
|
} else {
|
||||||
|
params.folder_id = currentFolderId.value ?? undefined
|
||||||
|
}
|
||||||
|
const resp = await listFiles(params)
|
||||||
|
files.value = resp.data?.files || []
|
||||||
|
total.value = resp.data?.total || 0
|
||||||
|
} catch {
|
||||||
|
// Error handled by interceptor
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchFolders = async () => {
|
||||||
|
if (searchQuery.value.trim()) {
|
||||||
|
folders.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await listFolders({ parent_id: currentFolderId.value ?? undefined })
|
||||||
|
folders.value = resp.data || []
|
||||||
|
} catch {
|
||||||
|
// Error handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchData = () => {
|
||||||
|
fetchFiles()
|
||||||
|
fetchFolders()
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigateToFolder = (folder: FolderResponse) => {
|
||||||
|
breadcrumbs.value.push({ id: folder.id, name: folder.name })
|
||||||
|
currentPage.value = 1
|
||||||
|
searchQuery.value = ''
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigateToBreadcrumb = (index: number) => {
|
||||||
|
breadcrumbs.value = breadcrumbs.value.slice(0, index + 1)
|
||||||
|
currentPage.value = 1
|
||||||
|
searchQuery.value = ''
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearchClear = () => {
|
||||||
|
searchQuery.value = ''
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
currentPage.value = page
|
||||||
|
fetchFiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSizeChange = (size: number) => {
|
||||||
|
pageSize.value = size
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchFiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDownload = (file: FileResponse) => {
|
||||||
|
window.open(downloadFileUrl(file.id), '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteFile = async (file: FileResponse) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定要删除文件 "${file.name}" 吗?`, '删除确认', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
await deleteFile(file.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
fetchData()
|
||||||
|
} catch {
|
||||||
|
// User cancelled or error handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteFolder = async (folder: FolderResponse) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定要删除文件夹 "${folder.name}" 吗?只能删除空文件夹。`, '删除确认', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
await deleteFolder(folder.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
fetchData()
|
||||||
|
} catch {
|
||||||
|
// User cancelled or error handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateFolder = async () => {
|
||||||
|
const name = newFolderName.value.trim()
|
||||||
|
if (!name) {
|
||||||
|
ElMessage.warning('请输入文件夹名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await createFolder({ name, parent_id: currentFolderId.value ?? undefined })
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
showCreateFolderDialog.value = false
|
||||||
|
newFolderName.value = ''
|
||||||
|
fetchData()
|
||||||
|
} catch {
|
||||||
|
// Error handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div style="padding: 20px">
|
||||||
|
<h2>文件管理</h2>
|
||||||
|
|
||||||
|
<!-- Breadcrumb + Actions -->
|
||||||
|
<div style="display: flex; align-items: center; margin: 16px 0; gap: 12px; flex-wrap: wrap">
|
||||||
|
<el-breadcrumb separator="/">
|
||||||
|
<el-breadcrumb-item
|
||||||
|
v-for="(crumb, index) in breadcrumbs"
|
||||||
|
:key="index"
|
||||||
|
@click="navigateToBreadcrumb(index)"
|
||||||
|
>
|
||||||
|
<span style="cursor: pointer">{{ crumb.name }}</span>
|
||||||
|
</el-breadcrumb-item>
|
||||||
|
</el-breadcrumb>
|
||||||
|
|
||||||
|
<div style="flex: 1" />
|
||||||
|
|
||||||
|
<UploadButton :folder-id="currentFolderId ?? undefined" @uploaded="fetchData" />
|
||||||
|
|
||||||
|
<el-button @click="showCreateFolderDialog = true">新建文件夹</el-button>
|
||||||
|
|
||||||
|
<el-input
|
||||||
|
v-model="searchQuery"
|
||||||
|
placeholder="搜索文件"
|
||||||
|
clearable
|
||||||
|
style="width: 200px"
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
@clear="handleSearchClear"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Folders -->
|
||||||
|
<div v-if="folders.length" style="margin-bottom: 12px">
|
||||||
|
<div
|
||||||
|
v-for="folder in folders"
|
||||||
|
:key="folder.id"
|
||||||
|
style="display: flex; align-items: center; padding: 8px 12px; cursor: pointer; border-bottom: 1px solid #f5f5f5"
|
||||||
|
>
|
||||||
|
<span style="flex: 1" @click="navigateToFolder(folder)">
|
||||||
|
📁 {{ folder.name }} ({{ folder.file_count }} 文件, {{ folder.subfolder_count }} 子文件夹)
|
||||||
|
</span>
|
||||||
|
<el-button text type="danger" size="small" @click.stop="handleDeleteFolder(folder)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Files Table -->
|
||||||
|
<el-table :data="files" v-loading="loading" stripe>
|
||||||
|
<el-table-column prop="name" label="文件名" min-width="200" />
|
||||||
|
<el-table-column label="大小" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatSize(row.size) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="mime_type" label="类型" width="150" />
|
||||||
|
<el-table-column label="创建时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ new Date(row.created_at).toLocaleString('zh-CN') }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="160" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button text type="primary" size="small" @click="handleDownload(row)">下载</el-button>
|
||||||
|
<el-button text type="danger" size="small" @click="handleDeleteFile(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div style="margin-top: 16px; display: flex; justify-content: flex-end">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Folder Dialog -->
|
||||||
|
<el-dialog v-model="showCreateFolderDialog" title="新建文件夹" width="400px">
|
||||||
|
<el-input v-model="newFolderName" placeholder="请输入文件夹名称" @keyup.enter="handleCreateFolder" />
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showCreateFolderDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleCreateFolder">创建</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { initUpload, uploadChunk, completeUpload, cancelUpload } from '@/api/files'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
folderId?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
uploaded: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const stage = ref<'idle' | 'hashing' | 'uploading' | 'done'>('idle')
|
||||||
|
const hashingProgress = ref(0)
|
||||||
|
const uploadProgress = ref(0)
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const sessionId = ref<number | null>(null)
|
||||||
|
|
||||||
|
let currentWorker: Worker | null = null
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (currentWorker) {
|
||||||
|
currentWorker.terminate()
|
||||||
|
currentWorker = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const triggerUpload = () => {
|
||||||
|
fileInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFileSelected = async (event: Event) => {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
input.value = ''
|
||||||
|
|
||||||
|
if (file.size > 500 * 1024 * 1024) {
|
||||||
|
ElMessage.warning('文件过大,当前仅支持 500MB 以内的文件')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
stage.value = 'hashing'
|
||||||
|
hashingProgress.value = 0
|
||||||
|
|
||||||
|
const sha256 = await computeSHA256(file)
|
||||||
|
|
||||||
|
stage.value = 'uploading'
|
||||||
|
uploadProgress.value = 0
|
||||||
|
|
||||||
|
const resp = await initUpload({
|
||||||
|
file_name: file.name,
|
||||||
|
file_size: file.size,
|
||||||
|
sha256,
|
||||||
|
chunk_size: 16777216,
|
||||||
|
folder_id: props.folderId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const inner = resp.data
|
||||||
|
if (inner && 'chunk_size' in inner && 'total_chunks' in inner) {
|
||||||
|
sessionId.value = inner.id
|
||||||
|
const chunkSize = inner.chunk_size
|
||||||
|
const totalChunks = inner.total_chunks
|
||||||
|
|
||||||
|
for (let i = 0; i < totalChunks; i++) {
|
||||||
|
const start = i * chunkSize
|
||||||
|
const end = Math.min(start + chunkSize, file.size)
|
||||||
|
const chunk = file.slice(start, end)
|
||||||
|
await uploadChunk(inner.id, i, chunk)
|
||||||
|
uploadProgress.value = (i + 1) / totalChunks
|
||||||
|
}
|
||||||
|
|
||||||
|
await completeUpload(inner.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
stage.value = 'done'
|
||||||
|
emit('uploaded')
|
||||||
|
} catch {
|
||||||
|
if (sessionId.value) {
|
||||||
|
try { await cancelUpload(sessionId.value) } catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
ElMessage.error('上传失败')
|
||||||
|
} finally {
|
||||||
|
stage.value = 'idle'
|
||||||
|
hashingProgress.value = 0
|
||||||
|
uploadProgress.value = 0
|
||||||
|
sessionId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const computeSHA256 = (file: File): Promise<string> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const worker = new Worker(new URL('./sha256.worker.ts', import.meta.url), { type: 'module' })
|
||||||
|
currentWorker = worker
|
||||||
|
|
||||||
|
worker.onmessage = (e: MessageEvent) => {
|
||||||
|
const data = e.data as { sha256?: string; progress?: number; error?: string }
|
||||||
|
if (data.error) {
|
||||||
|
worker.terminate()
|
||||||
|
currentWorker = null
|
||||||
|
reject(new Error(data.error))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (data.sha256) {
|
||||||
|
worker.terminate()
|
||||||
|
currentWorker = null
|
||||||
|
resolve(data.sha256)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (data.progress !== undefined) {
|
||||||
|
hashingProgress.value = data.progress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
worker.onerror = (e: ErrorEvent) => {
|
||||||
|
worker.terminate()
|
||||||
|
currentWorker = null
|
||||||
|
reject(new Error(e.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
worker.postMessage({ file })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<input ref="fileInput" type="file" style="display: none" @change="onFileSelected" />
|
||||||
|
<el-button :disabled="stage !== 'idle'" @click="triggerUpload">上传文件</el-button>
|
||||||
|
<div v-if="stage === 'hashing'" style="margin-top: 8px">
|
||||||
|
<span>计算哈希中...</span>
|
||||||
|
<el-progress :percentage="Math.round(hashingProgress * 100)" :stroke-width="10" />
|
||||||
|
</div>
|
||||||
|
<div v-if="stage === 'uploading'" style="margin-top: 8px">
|
||||||
|
<span>上传中...</span>
|
||||||
|
<el-progress :percentage="Math.round(uploadProgress * 100)" :stroke-width="10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export {}
|
||||||
|
declare const self: {
|
||||||
|
onmessage: ((e: MessageEvent) => void) | null
|
||||||
|
postMessage: (message: unknown) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
self.onmessage = async (e: MessageEvent) => {
|
||||||
|
const file = e.data.file as File
|
||||||
|
try {
|
||||||
|
// Read entire file into ArrayBuffer (with progress reporting)
|
||||||
|
const CHUNK_READ_SIZE = 16 * 1024 * 1024 // 16MB chunks for progress reporting
|
||||||
|
const buffer = new ArrayBuffer(file.size)
|
||||||
|
const view = new Uint8Array(buffer)
|
||||||
|
let offset = 0
|
||||||
|
|
||||||
|
while (offset < file.size) {
|
||||||
|
const end = Math.min(offset + CHUNK_READ_SIZE, file.size)
|
||||||
|
const slice = file.slice(offset, end)
|
||||||
|
const chunkBuffer = await slice.arrayBuffer()
|
||||||
|
view.set(new Uint8Array(chunkBuffer), offset)
|
||||||
|
offset = end
|
||||||
|
self.postMessage({ progress: offset / file.size })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute SHA256 on entire buffer at once
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||||
|
const sha256 = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
||||||
|
|
||||||
|
self.postMessage({ sha256, progress: 1 })
|
||||||
|
} catch (error) {
|
||||||
|
self.postMessage({ error: error instanceof Error ? error.message : 'SHA256 computation failed' })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { getJob, cancelJob } from '@/api/jobs'
|
||||||
|
import type { JobResponse } from '@/types/jobs'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const cancelling = ref(false)
|
||||||
|
const job = ref<JobResponse | null>(null)
|
||||||
|
const notFound = ref(false)
|
||||||
|
|
||||||
|
const jobId = computed(() => route.params.id as string)
|
||||||
|
|
||||||
|
const fetchJob = async () => {
|
||||||
|
loading.value = true
|
||||||
|
notFound.value = false
|
||||||
|
try {
|
||||||
|
const resp = await getJob(jobId.value)
|
||||||
|
if (resp.success) {
|
||||||
|
job.value = resp.data || null
|
||||||
|
} else {
|
||||||
|
if (resp.error?.includes('not found') || resp.error?.includes('不存在')) {
|
||||||
|
notFound.value = true
|
||||||
|
ElMessage.error('任务不存在')
|
||||||
|
} else {
|
||||||
|
ElMessage.error(resp.error || '获取任务详情失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const axiosError = error as { response?: { status?: number } }
|
||||||
|
if (axiosError.response?.status === 404) {
|
||||||
|
notFound.value = true
|
||||||
|
ElMessage.error('任务不存在')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
cancelling.value = true
|
||||||
|
try {
|
||||||
|
const resp = await cancelJob(jobId.value)
|
||||||
|
if (resp.success) {
|
||||||
|
ElMessage.success('任务已取消')
|
||||||
|
fetchJob()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(resp.error || '取消失败')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Error handled by interceptor
|
||||||
|
} finally {
|
||||||
|
cancelling.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (ts: number | null | undefined): string => {
|
||||||
|
if (ts == null) return '-'
|
||||||
|
return new Date(ts * 1000).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusType = (state: string | undefined): 'primary' | 'success' | 'warning' | 'info' | 'danger' => {
|
||||||
|
switch (state) {
|
||||||
|
case 'RUNNING': return 'success'
|
||||||
|
case 'PENDING': return 'warning'
|
||||||
|
case 'COMPLETED': return 'info'
|
||||||
|
case 'FAILED': return 'danger'
|
||||||
|
default: return 'info'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canCancel = computed(() => {
|
||||||
|
const state = job.value?.job_state?.[0]
|
||||||
|
return state === 'PENDING' || state === 'RUNNING'
|
||||||
|
})
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
router.push('/jobs')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchJob()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="job-detail">
|
||||||
|
<!-- Top action bar -->
|
||||||
|
<div class="action-bar">
|
||||||
|
<el-button @click="goBack">返回</el-button>
|
||||||
|
<el-popconfirm
|
||||||
|
v-if="canCancel"
|
||||||
|
title="确定要取消此任务吗?"
|
||||||
|
@confirm="handleCancel"
|
||||||
|
>
|
||||||
|
<template #reference>
|
||||||
|
<el-button type="danger" :loading="cancelling">取消任务</el-button>
|
||||||
|
</template>
|
||||||
|
</el-popconfirm>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 404 state -->
|
||||||
|
<template v-if="notFound">
|
||||||
|
<el-empty description="任务不存在">
|
||||||
|
<el-button type="primary" @click="goBack">返回列表</el-button>
|
||||||
|
</el-empty>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Loading + content -->
|
||||||
|
<div v-else v-loading="loading">
|
||||||
|
<template v-if="job">
|
||||||
|
<!-- state_reason alert -->
|
||||||
|
<el-alert
|
||||||
|
v-if="job.state_reason"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
class="state-reason-alert"
|
||||||
|
>
|
||||||
|
{{ job.state_reason }}
|
||||||
|
</el-alert>
|
||||||
|
|
||||||
|
<!-- 基本信息 -->
|
||||||
|
<el-descriptions title="基本信息" border :column="2" class="detail-section">
|
||||||
|
<el-descriptions-item label="Job ID">{{ job.job_id ?? '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="任务名称">{{ job.name ?? '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="状态">
|
||||||
|
<el-tag :type="getStatusType(job.job_state?.[0])">
|
||||||
|
{{ job.job_state?.[0] || '-' }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="分区">{{ job.partition ?? '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="QOS">{{ job.qos ?? '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="优先级">{{ job.priority ?? '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="账户">{{ job.account ?? '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="用户">{{ job.user ?? '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="集群">{{ job.cluster ?? '-' }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<!-- 资源信息 -->
|
||||||
|
<el-descriptions title="资源信息" border :column="2" class="detail-section">
|
||||||
|
<el-descriptions-item label="CPU数">{{ job.cpus ?? '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="任务数">{{ job.tasks ?? '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="节点数">{{ job.node_count ?? '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="分配节点">{{ job.nodes ?? '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="Batch Host">{{ job.batch_host ?? '-' }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<!-- 时间信息 -->
|
||||||
|
<el-descriptions title="时间信息" border :column="2" class="detail-section">
|
||||||
|
<el-descriptions-item label="提交时间">{{ formatTime(job.submit_time) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="开始时间">{{ formatTime(job.start_time) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="结束时间">{{ formatTime(job.end_time) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="时间限制">{{ job.time_limit ?? '-' }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<!-- 执行信息 -->
|
||||||
|
<el-descriptions title="执行信息" border :column="2" class="detail-section">
|
||||||
|
<el-descriptions-item label="退出码">{{ job.exit_code ?? '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="工作目录">{{ job.working_directory ?? '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="命令">{{ job.command ?? '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="标准输出">{{ job.standard_output ?? '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="标准错误">{{ job.standard_error ?? '-' }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<!-- 数组任务 -->
|
||||||
|
<el-descriptions
|
||||||
|
v-if="job.array_job_id != null || job.array_task_id != null"
|
||||||
|
title="数组任务"
|
||||||
|
border
|
||||||
|
:column="2"
|
||||||
|
class="detail-section"
|
||||||
|
>
|
||||||
|
<el-descriptions-item label="Array Job ID">{{ job.array_job_id ?? '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="Array Task ID">{{ job.array_task_id ?? '-' }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.job-detail {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-reason-alert {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
<template>
|
||||||
|
<div class="jobs-history">
|
||||||
|
<div class="jobs-history__header">
|
||||||
|
<h2>任务历史</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form :inline="true" class="jobs-history__filters">
|
||||||
|
<el-form-item label="用户名">
|
||||||
|
<el-input v-model="filters.users" placeholder="请输入用户名" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="分区">
|
||||||
|
<el-input v-model="filters.partition" placeholder="请输入分区" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="filters.state" clearable placeholder="请选择状态">
|
||||||
|
<el-option label="RUNNING" value="RUNNING" />
|
||||||
|
<el-option label="PENDING" value="PENDING" />
|
||||||
|
<el-option label="COMPLETED" value="COMPLETED" />
|
||||||
|
<el-option label="FAILED" value="FAILED" />
|
||||||
|
<el-option label="CANCELLED" value="CANCELLED" />
|
||||||
|
<el-option label="TIMEOUT" value="TIMEOUT" />
|
||||||
|
<el-option label="NODE_FAIL" value="NODE_FAIL" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="任务名称">
|
||||||
|
<el-input v-model="filters.job_name" placeholder="请输入任务名称" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="时间范围">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="filters.dateRange"
|
||||||
|
type="datetimerange"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始时间"
|
||||||
|
end-placeholder="结束时间"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||||
|
<el-button @click="handleReset">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
:data="jobs"
|
||||||
|
v-loading="loading"
|
||||||
|
@row-click="handleRowClick"
|
||||||
|
style="cursor: pointer"
|
||||||
|
stripe
|
||||||
|
border
|
||||||
|
>
|
||||||
|
<el-table-column prop="job_id" label="Job ID" width="100" />
|
||||||
|
<el-table-column prop="name" label="任务名称" min-width="160" />
|
||||||
|
<el-table-column label="状态" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="getStatusType(row.job_state?.[0])">
|
||||||
|
{{ row.job_state?.[0] || '-' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="partition" label="分区" width="120" />
|
||||||
|
<el-table-column prop="user" label="用户" width="120" />
|
||||||
|
<el-table-column prop="exit_code" label="退出码" width="90" />
|
||||||
|
<el-table-column label="开始时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatTime(row.start_time) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="结束时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatTime(row.end_time) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="jobs-history__pagination">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
:total="total"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { getJobHistory } from '@/api/jobs'
|
||||||
|
import type { JobResponse } from '@/types/jobs'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const loading = ref(false)
|
||||||
|
const jobs = ref<JobResponse[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
|
||||||
|
const filters = reactive({
|
||||||
|
users: '',
|
||||||
|
partition: '',
|
||||||
|
state: '',
|
||||||
|
job_name: '',
|
||||||
|
dateRange: null as [Date, Date] | null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const buildParams = () => {
|
||||||
|
const params: Record<string, string | number> = {}
|
||||||
|
if (filters.users) params.users = filters.users
|
||||||
|
if (filters.partition) params.partition = filters.partition
|
||||||
|
if (filters.state) params.state = filters.state
|
||||||
|
if (filters.job_name) params.job_name = filters.job_name
|
||||||
|
if (filters.dateRange) {
|
||||||
|
params.start_time = String(Math.floor(filters.dateRange[0].getTime() / 1000))
|
||||||
|
params.end_time = String(Math.floor(filters.dateRange[1].getTime() / 1000))
|
||||||
|
}
|
||||||
|
params.page = currentPage.value
|
||||||
|
params.page_size = pageSize.value
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchHistory = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const resp = await getJobHistory(buildParams())
|
||||||
|
jobs.value = resp.data?.jobs || []
|
||||||
|
total.value = resp.data?.total || 0
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
filters.users = ''
|
||||||
|
filters.partition = ''
|
||||||
|
filters.state = ''
|
||||||
|
filters.job_name = ''
|
||||||
|
filters.dateRange = null
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
currentPage.value = page
|
||||||
|
fetchHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSizeChange = (size: number) => {
|
||||||
|
pageSize.value = size
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRowClick = (row: JobResponse) => {
|
||||||
|
router.push(`/jobs/${row.job_id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (ts: number | null | undefined): string => {
|
||||||
|
if (ts == null) return '-'
|
||||||
|
return new Date(ts * 1000).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusType = (state: string | undefined): 'success' | 'warning' | 'info' | 'danger' => {
|
||||||
|
switch (state) {
|
||||||
|
case 'RUNNING': return 'success'
|
||||||
|
case 'PENDING': return 'warning'
|
||||||
|
case 'COMPLETED': return 'info'
|
||||||
|
case 'FAILED': return 'danger'
|
||||||
|
default: return 'info'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchHistory()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.jobs-history {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs-history__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs-history__header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs-history__filters {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs-history__pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<template>
|
||||||
|
<div class="jobs-list">
|
||||||
|
<div class="jobs-list__header">
|
||||||
|
<h2>任务列表</h2>
|
||||||
|
<el-button type="primary" :icon="Refresh" @click="fetchJobs" :loading="loading">
|
||||||
|
刷新
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
:data="jobs"
|
||||||
|
v-loading="loading"
|
||||||
|
@row-click="handleRowClick"
|
||||||
|
style="cursor: pointer"
|
||||||
|
stripe
|
||||||
|
border
|
||||||
|
>
|
||||||
|
<el-table-column prop="job_id" label="Job ID" width="100" />
|
||||||
|
<el-table-column prop="name" label="任务名称" min-width="160" />
|
||||||
|
<el-table-column label="状态" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="getStatusType(row.job_state?.[0])">
|
||||||
|
{{ row.job_state?.[0] || '-' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="partition" label="分区" width="120" />
|
||||||
|
<el-table-column prop="user" label="用户" width="120" />
|
||||||
|
<el-table-column prop="cpus" label="CPU数" width="80" />
|
||||||
|
<el-table-column label="提交时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatTime(row.submit_time) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="开始时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatTime(row.start_time) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="jobs-list__pagination">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
:total="total"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { getJobs } from '@/api/jobs'
|
||||||
|
import type { JobResponse } from '@/types/jobs'
|
||||||
|
import { Refresh } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const loading = ref(false)
|
||||||
|
const jobs = ref<JobResponse[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
|
||||||
|
const fetchJobs = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const resp = await getJobs({ page: currentPage.value, page_size: pageSize.value })
|
||||||
|
jobs.value = resp.data?.jobs || []
|
||||||
|
total.value = resp.data?.total || 0
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
currentPage.value = page
|
||||||
|
fetchJobs()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSizeChange = (size: number) => {
|
||||||
|
pageSize.value = size
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchJobs()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRowClick = (row: JobResponse) => {
|
||||||
|
router.push(`/jobs/${row.job_id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (ts: number | null | undefined): string => {
|
||||||
|
if (ts == null) return '-'
|
||||||
|
return new Date(ts * 1000).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusType = (state: string | undefined): 'success' | 'warning' | 'info' | 'danger' => {
|
||||||
|
switch (state) {
|
||||||
|
case 'RUNNING': return 'success'
|
||||||
|
case 'PENDING': return 'warning'
|
||||||
|
case 'COMPLETED': return 'info'
|
||||||
|
case 'FAILED': return 'danger'
|
||||||
|
default: return 'info'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchJobs()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.jobs-list {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs-list__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs-list__header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs-list__pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<template>
|
||||||
|
<div class="submit-container">
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>提交任务</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card>
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="form"
|
||||||
|
:rules="rules"
|
||||||
|
label-width="100px"
|
||||||
|
>
|
||||||
|
<el-form-item label="脚本内容" prop="script">
|
||||||
|
<el-input
|
||||||
|
v-model="form.script"
|
||||||
|
type="textarea"
|
||||||
|
:rows="10"
|
||||||
|
placeholder="请输入脚本内容"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="任务名称" prop="job_name">
|
||||||
|
<el-input v-model="form.job_name" placeholder="请输入任务名称" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="分区" prop="partition">
|
||||||
|
<el-input v-model="form.partition" placeholder="请输入分区" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="QOS" prop="qos">
|
||||||
|
<el-input v-model="form.qos" placeholder="请输入 QOS" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="CPU数量" prop="cpus">
|
||||||
|
<el-input-number v-model="form.cpus" :min="1" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="时间限制" prop="time_limit">
|
||||||
|
<el-input v-model="form.time_limit" placeholder="如: 1:00:00" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="工作目录" prop="work_dir">
|
||||||
|
<el-input v-model="form.work_dir" placeholder="请输入工作目录" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="handleSubmit">
|
||||||
|
提交
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="handleReset">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { submitJob } from '@/api/jobs'
|
||||||
|
import type { SubmitJobRequest } from '@/types/jobs'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
const form = reactive<SubmitJobRequest>({
|
||||||
|
script: '',
|
||||||
|
job_name: '',
|
||||||
|
partition: '',
|
||||||
|
qos: '',
|
||||||
|
cpus: undefined,
|
||||||
|
time_limit: '',
|
||||||
|
work_dir: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules: FormRules = {
|
||||||
|
script: [
|
||||||
|
{ required: true, message: '请输入脚本内容', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!formRef.value) return
|
||||||
|
await formRef.value.validate(async (valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const resp = await submitJob({ ...form })
|
||||||
|
if (resp.success) {
|
||||||
|
ElMessage.success(`任务提交成功,Job ID: ${resp.data?.job_id}`)
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/jobs')
|
||||||
|
}, 1000)
|
||||||
|
} else {
|
||||||
|
ElMessage.error(resp.error || '提交失败')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Error already handled by Axios interceptor
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
form.script = ''
|
||||||
|
form.job_name = ''
|
||||||
|
form.partition = ''
|
||||||
|
form.qos = ''
|
||||||
|
form.cpus = undefined
|
||||||
|
form.time_limit = ''
|
||||||
|
form.work_dir = ''
|
||||||
|
formRef.value?.clearValidate()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<template>
|
||||||
|
<div class="list-container">
|
||||||
|
<h2>任务列表</h2>
|
||||||
|
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-select v-model="statusFilter" placeholder="状态筛选" clearable style="width: 200px">
|
||||||
|
<el-option label="全部" value="" />
|
||||||
|
<el-option label="已提交" value="submitted" />
|
||||||
|
<el-option label="准备中" value="preparing" />
|
||||||
|
<el-option label="下载中" value="downloading" />
|
||||||
|
<el-option label="就绪" value="ready" />
|
||||||
|
<el-option label="排队中" value="queued" />
|
||||||
|
<el-option label="运行中" value="running" />
|
||||||
|
<el-option label="已完成" value="completed" />
|
||||||
|
<el-option label="失败" value="failed" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="tasks" v-loading="loading" stripe>
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="task_name" label="任务名称" min-width="160" />
|
||||||
|
<el-table-column prop="app_name" label="应用" width="120" />
|
||||||
|
<el-table-column prop="status" label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="getTaskStatusType(row.status)">{{ row.status }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="partition" label="分区" width="100" />
|
||||||
|
<el-table-column prop="cpus" label="CPU" width="80" />
|
||||||
|
<el-table-column prop="slurm_job_id" label="Slurm Job ID" width="120" />
|
||||||
|
<el-table-column label="创建时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ new Date(row.created_at).toLocaleString('zh-CN') }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="pagination-container">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TaskResponse } from '@/types/tasks'
|
||||||
|
import { listTasks } from '@/api/tasks'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const tasks = ref<TaskResponse[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const statusFilter = ref('')
|
||||||
|
|
||||||
|
const getTaskStatusType = (status: string | undefined): 'success' | 'warning' | 'info' | 'danger' | undefined => {
|
||||||
|
switch (status) {
|
||||||
|
case 'submitted': return 'info'
|
||||||
|
case 'preparing': return 'info'
|
||||||
|
case 'downloading': return 'warning'
|
||||||
|
case 'ready': return undefined
|
||||||
|
case 'queued': return 'warning'
|
||||||
|
case 'running': return 'success'
|
||||||
|
case 'completed': return 'success'
|
||||||
|
case 'failed': return 'danger'
|
||||||
|
default: return 'info'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchTasks = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const resp = await listTasks({
|
||||||
|
page: currentPage.value,
|
||||||
|
page_size: pageSize.value,
|
||||||
|
status: statusFilter.value || undefined
|
||||||
|
})
|
||||||
|
tasks.value = resp.data?.items || []
|
||||||
|
total.value = resp.data?.total || 0
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(statusFilter, () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchTasks()
|
||||||
|
})
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
currentPage.value = page
|
||||||
|
fetchTasks()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSizeChange = (size: number) => {
|
||||||
|
pageSize.value = size
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchTasks()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchTasks()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.list-container {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-container {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<template>
|
||||||
|
<div class="submit-container">
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>提交任务</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-card>
|
||||||
|
<el-form label-width="120px">
|
||||||
|
<el-form-item label="选择应用">
|
||||||
|
<el-select v-model="selectedAppId" placeholder="请选择应用" clearable>
|
||||||
|
<el-option v-for="app in appList" :key="app.id" :label="app.name" :value="app.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="任务名称">
|
||||||
|
<el-input v-model="form.task_name" placeholder="可选" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="关联文件">
|
||||||
|
<el-button @click="showFilePicker = true">选择文件</el-button>
|
||||||
|
<div v-if="selectedFiles.length" style="margin-top: 8px">
|
||||||
|
<el-tag
|
||||||
|
v-for="f in selectedFiles"
|
||||||
|
:key="f.id"
|
||||||
|
closable
|
||||||
|
@close="selectedFiles = selectedFiles.filter(x => x.id !== f.id)"
|
||||||
|
style="margin: 2px"
|
||||||
|
>
|
||||||
|
{{ f.name }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<FilePicker v-model="showFilePicker" @select="selectedFiles = $event" />
|
||||||
|
|
||||||
|
<template v-if="selectedApp">
|
||||||
|
<el-form-item
|
||||||
|
v-for="param in (selectedApp.parameters || [])"
|
||||||
|
:key="param.name"
|
||||||
|
:label="param.label || param.name"
|
||||||
|
>
|
||||||
|
<el-input v-if="param.type === 'string'" v-model="values[param.name]" />
|
||||||
|
|
||||||
|
<el-input-number v-else-if="param.type === 'integer'"
|
||||||
|
:model-value="values[param.name] ? Number(values[param.name]) : undefined"
|
||||||
|
@update:model-value="(val: number | undefined) => {
|
||||||
|
if (val != null) values[param.name] = String(val)
|
||||||
|
else delete values[param.name]
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-select v-else-if="param.type === 'enum'" v-model="values[param.name]">
|
||||||
|
<el-option v-for="opt in param.options" :key="opt" :label="opt" :value="opt" />
|
||||||
|
</el-select>
|
||||||
|
|
||||||
|
<el-switch v-else-if="param.type === 'boolean'"
|
||||||
|
:model-value="values[param.name] === 'true'"
|
||||||
|
@update:model-value="(val: string | number | boolean) => { values[param.name] = String(val) }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-input v-else-if="param.type === 'file' || param.type === 'directory'" disabled placeholder="文件选择功能开发中" />
|
||||||
|
</el-form-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-divider>调度参数</el-divider>
|
||||||
|
|
||||||
|
<el-form-item label="CPU 数量">
|
||||||
|
<el-input-number v-model="form.cpus" :min="1" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="内存 (MB)">
|
||||||
|
<el-input-number v-model="form.memory_per_node" placeholder="MB" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="节点数">
|
||||||
|
<el-input v-model="form.nodes" placeholder="如: 2 或 2-4" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="任务数">
|
||||||
|
<el-input-number v-model="form.tasks" :min="1" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="每任务 CPU 数">
|
||||||
|
<el-input-number v-model="form.cpus_per_task" :min="1" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="handleSubmit">提交</el-button>
|
||||||
|
<el-button @click="handleReset">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import type { Application } from '@/types/tasks'
|
||||||
|
import { createTask, getApplications } from '@/api/tasks'
|
||||||
|
import FilePicker from '@/views/Files/FilePicker.vue'
|
||||||
|
import type { FileResponse } from '@/types/files'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const selectedAppId = ref<number | undefined>(undefined)
|
||||||
|
const appList = ref<Application[]>([])
|
||||||
|
const values = ref<Record<string, string>>({})
|
||||||
|
const submitting = ref(false)
|
||||||
|
const form = reactive({
|
||||||
|
task_name: '',
|
||||||
|
partition: 'normal',
|
||||||
|
cpus: undefined as number | undefined,
|
||||||
|
memory_per_node: undefined as number | undefined,
|
||||||
|
nodes: '',
|
||||||
|
tasks: undefined as number | undefined,
|
||||||
|
cpus_per_task: undefined as number | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedFiles = ref<FileResponse[]>([])
|
||||||
|
const showFilePicker = ref(false)
|
||||||
|
|
||||||
|
const selectedApp = computed(() => appList.value.find(a => a.id === selectedAppId.value))
|
||||||
|
|
||||||
|
watch(selectedAppId, () => { values.value = {} })
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const resp = await getApplications({ page_size: 100 })
|
||||||
|
appList.value = resp.data?.applications || []
|
||||||
|
} catch {
|
||||||
|
// Error already handled by Axios interceptor
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!selectedAppId.value) { ElMessage.warning('请选择应用'); return }
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const taskName = form.task_name.trim() || `task_${selectedAppId.value}_${Date.now()}`
|
||||||
|
const resp = await createTask({ ...form, task_name: taskName, job_name: taskName, app_id: selectedAppId.value, values: values.value, file_ids: selectedFiles.value.map(f => f.id) })
|
||||||
|
if (resp.success) {
|
||||||
|
ElMessage.success('任务提交成功')
|
||||||
|
router.push('/tasks')
|
||||||
|
} else {
|
||||||
|
ElMessage.error(resp.error || '提交失败')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Error already handled by Axios interceptor
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
selectedAppId.value = undefined
|
||||||
|
form.task_name = ''
|
||||||
|
form.partition = 'normal'
|
||||||
|
form.cpus = undefined
|
||||||
|
form.memory_per_node = undefined
|
||||||
|
form.nodes = ''
|
||||||
|
form.tasks = undefined
|
||||||
|
form.cpus_per_task = undefined
|
||||||
|
values.value = {}
|
||||||
|
selectedFiles.value = []
|
||||||
|
showFilePicker.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.submit-container {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
import { test, expect, type Page, type Request } from '@playwright/test'
|
||||||
|
|
||||||
|
const BASE_URL = 'http://localhost:5173'
|
||||||
|
const API_URL = 'http://localhost:8080'
|
||||||
|
const TS = Date.now()
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'serial' })
|
||||||
|
|
||||||
|
let uploadedFileId: number
|
||||||
|
let uploadedFileName: string
|
||||||
|
let subFolderId: number
|
||||||
|
let subFolderName: string
|
||||||
|
|
||||||
|
test.describe('完整端到端流程', () => {
|
||||||
|
|
||||||
|
test('1. 创建根级文件夹', async ({ page }) => {
|
||||||
|
await page.goto(`${BASE_URL}/#/files`)
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
|
||||||
|
await page.locator('button', { hasText: '新建文件夹' }).click()
|
||||||
|
await expect(page.locator('.el-dialog__header', { hasText: '新建文件夹' })).toBeVisible()
|
||||||
|
|
||||||
|
subFolderName = `e2e-folder-${TS}`
|
||||||
|
await page.locator('.el-dialog input').fill(subFolderName)
|
||||||
|
await page.locator('.el-dialog button', { hasText: '创建' }).click()
|
||||||
|
|
||||||
|
await expect(page.locator('.el-message--success')).toBeVisible({ timeout: 5000 })
|
||||||
|
|
||||||
|
await expect(page.locator('text=' + subFolderName)).toBeVisible({ timeout: 3000 })
|
||||||
|
|
||||||
|
const folderRow = page.locator('span', { hasText: new RegExp(subFolderName) }).first()
|
||||||
|
await expect(folderRow).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('2. 进入文件夹并上传文件', async ({ page }) => {
|
||||||
|
await page.goto(`${BASE_URL}/#/files`)
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
|
||||||
|
const folderLink = page.locator('span', { hasText: new RegExp(`e2e-folder-${TS}`) }).first()
|
||||||
|
await folderLink.click()
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
|
||||||
|
await expect(page.locator('.el-breadcrumb__item').last()).toContainText(`e2e-folder-${TS}`)
|
||||||
|
|
||||||
|
const fileInput = page.locator('input[type="file"]')
|
||||||
|
await fileInput.setInputFiles({
|
||||||
|
name: 'e2e-test-file.txt',
|
||||||
|
mimeType: 'text/plain',
|
||||||
|
buffer: Buffer.from(`Hello E2E Test ${TS}`),
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.waitForResponse(
|
||||||
|
resp => resp.url().includes('/api/v1/files/uploads') && resp.status() === 201,
|
||||||
|
{ timeout: 10000 }
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.waitForResponse(
|
||||||
|
resp => resp.url().includes('/complete') && [200, 201].includes(resp.status()),
|
||||||
|
{ timeout: 30000 }
|
||||||
|
).catch(() => {})
|
||||||
|
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
|
||||||
|
const table = page.locator('.el-table')
|
||||||
|
await expect(table).toBeVisible()
|
||||||
|
|
||||||
|
const rows = table.locator('.el-table__body-wrapper .el-table__row')
|
||||||
|
await expect(rows, 'file should appear in table after upload').toHaveCount(1, { timeout: 10000 })
|
||||||
|
await expect(rows.first().locator('td').first()).toContainText('e2e-test-file.txt')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('3. 验证文件出现在表格中', async ({ page }) => {
|
||||||
|
await page.goto(`${BASE_URL}/#/files`)
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
|
||||||
|
const folderLink = page.locator('span', { hasText: new RegExp(`e2e-folder-${TS}`) }).first()
|
||||||
|
await folderLink.click()
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
|
||||||
|
const table = page.locator('.el-table')
|
||||||
|
await expect(table).toBeVisible()
|
||||||
|
|
||||||
|
const rows = table.locator('.el-table__body-wrapper .el-table__row')
|
||||||
|
await expect(rows).toHaveCount(1, { timeout: 5000 })
|
||||||
|
|
||||||
|
const row = rows.first()
|
||||||
|
await expect(row.locator('td').first()).toContainText('e2e-test-file.txt')
|
||||||
|
|
||||||
|
const sizeCell = row.locator('td').nth(1)
|
||||||
|
await expect(sizeCell).toContainText(/B|KB|MB/)
|
||||||
|
|
||||||
|
await expect(row.locator('td').nth(2)).toContainText(/text\/plain|application\/octet-stream/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('4. 获取上传文件 ID(通过 API)', async ({ request }) => {
|
||||||
|
const listResp = await request.get(`${API_URL}/api/v1/files?folder_id=2`)
|
||||||
|
const body = await listResp.json()
|
||||||
|
|
||||||
|
if (body.data?.files?.length > 0) {
|
||||||
|
uploadedFileId = body.data.files[0].id
|
||||||
|
uploadedFileName = body.data.files[0].name
|
||||||
|
} else {
|
||||||
|
const allResp = await request.get(`${API_URL}/api/v1/files?search=e2e-test-file`)
|
||||||
|
const allBody = await allResp.json()
|
||||||
|
expect(allBody.data.files.length).toBeGreaterThanOrEqual(1)
|
||||||
|
uploadedFileId = allBody.data.files[0].id
|
||||||
|
uploadedFileName = allBody.data.files[0].name
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(uploadedFileId).toBeDefined()
|
||||||
|
expect(uploadedFileId).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('5. 下载文件', async ({ page, context }) => {
|
||||||
|
await page.goto(`${BASE_URL}/#/files`)
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
|
||||||
|
const folderLink = page.locator('span', { hasText: new RegExp(`e2e-folder-${TS}`) }).first()
|
||||||
|
await folderLink.click()
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download', { timeout: 10000 }).catch(() => null)
|
||||||
|
|
||||||
|
await page.locator('button', { hasText: '下载' }).first().click()
|
||||||
|
|
||||||
|
const download = await downloadPromise
|
||||||
|
if (download) {
|
||||||
|
const downloadName = download.suggestedFilename()
|
||||||
|
expect(downloadName).toBeTruthy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('6. 搜索文件', async ({ page }) => {
|
||||||
|
await page.goto(`${BASE_URL}/#/files`)
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
|
||||||
|
const searchInput = page.locator('input[placeholder="搜索文件"]')
|
||||||
|
await searchInput.fill('e2e-test-file')
|
||||||
|
|
||||||
|
const [searchResp] = await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
resp => resp.url().includes('/api/v1/files') && resp.url().includes('search='),
|
||||||
|
{ timeout: 5000 }
|
||||||
|
),
|
||||||
|
searchInput.press('Enter'),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(searchResp.status()).toBe(200)
|
||||||
|
const body = await searchResp.json()
|
||||||
|
expect(body.data.files.length).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(body.data.files[0].name).toContain('e2e-test-file')
|
||||||
|
|
||||||
|
await expect(page.locator('.el-table__body-wrapper .el-table__row')).toHaveCount(
|
||||||
|
body.data.files.length,
|
||||||
|
{ timeout: 3000 }
|
||||||
|
)
|
||||||
|
|
||||||
|
await searchInput.fill('')
|
||||||
|
await searchInput.press('Enter')
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('7. 面包屑导航 - 返回根目录', async ({ page }) => {
|
||||||
|
await page.goto(`${BASE_URL}/#/files`)
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
|
||||||
|
const folderLink = page.locator('span', { hasText: new RegExp(`e2e-folder-${TS}`) }).first()
|
||||||
|
await folderLink.click()
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
|
||||||
|
await expect(page.locator('.el-breadcrumb__item').last()).toContainText(`e2e-folder-${TS}`)
|
||||||
|
|
||||||
|
await page.locator('.el-breadcrumb__item').first().click()
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
|
||||||
|
await expect(page.locator('.el-breadcrumb__item')).toHaveCount(1)
|
||||||
|
await expect(page.locator('.el-breadcrumb__item').first()).toContainText('全部文件')
|
||||||
|
|
||||||
|
const rootFolders = page.locator('text=e2e-folder-')
|
||||||
|
await expect(rootFolders.first()).toBeVisible({ timeout: 3000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('8. 删除文件', async ({ page }) => {
|
||||||
|
await page.goto(`${BASE_URL}/#/files`)
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
|
||||||
|
const folderLink = page.locator('span', { hasText: new RegExp(`e2e-folder-${TS}`) }).first()
|
||||||
|
await folderLink.click()
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
|
||||||
|
const rowCountBefore = await page.locator('.el-table__body-wrapper .el-table__row').count()
|
||||||
|
|
||||||
|
page.on('dialog', async dialog => {
|
||||||
|
expect(dialog.type()).toBe('confirm')
|
||||||
|
await dialog.accept()
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.locator('button', { hasText: '删除' }).first().click()
|
||||||
|
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
|
||||||
|
await page.locator('.el-message-box').locator('button', { hasText: '确定' }).click().catch(() => { })
|
||||||
|
|
||||||
|
await expect(page.locator('.el-message--success')).toBeVisible({ timeout: 5000 })
|
||||||
|
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
|
||||||
|
const rowCountAfter = await page.locator('.el-table__body-wrapper .el-table__row').count()
|
||||||
|
expect(rowCountAfter).toBe(rowCountBefore - 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('9. 再上传一个文件(用于 FilePicker 测试)', async ({ request, page }) => {
|
||||||
|
const sha256 = 'a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e'
|
||||||
|
const fileContent = 'Hello World'
|
||||||
|
|
||||||
|
const initResp = await request.post(`${API_URL}/api/v1/files/uploads`, {
|
||||||
|
data: {
|
||||||
|
file_name: 'picker-test-file.txt',
|
||||||
|
file_size: fileContent.length,
|
||||||
|
sha256,
|
||||||
|
chunk_size: 16777216,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect([200, 201]).toContain(initResp.status())
|
||||||
|
const initData = await initResp.json()
|
||||||
|
|
||||||
|
if (initResp.status() === 201 && initData.data?.total_chunks) {
|
||||||
|
const sessionId = initData.data.id
|
||||||
|
const chunkResp = await request.put(`${API_URL}/api/v1/files/uploads/${sessionId}/chunks/0`, {
|
||||||
|
multipart: {
|
||||||
|
chunk: {
|
||||||
|
name: 'chunk',
|
||||||
|
mimeType: 'text/plain',
|
||||||
|
buffer: Buffer.from(fileContent),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(chunkResp.status()).toBe(200)
|
||||||
|
|
||||||
|
const completeResp = await request.post(`${API_URL}/api/v1/files/uploads/${sessionId}/complete`)
|
||||||
|
expect([200, 201]).toContain(completeResp.status())
|
||||||
|
const completeData = await completeResp.json()
|
||||||
|
uploadedFileId = completeData.data.id
|
||||||
|
uploadedFileName = completeData.data.name
|
||||||
|
} else {
|
||||||
|
uploadedFileId = initData.data.id
|
||||||
|
uploadedFileName = initData.data.name
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(uploadedFileId).toBeDefined()
|
||||||
|
expect(uploadedFileId).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('10. FilePicker 弹窗 - 选择文件', async ({ page }) => {
|
||||||
|
await page.goto(`${BASE_URL}/#/tasks/create`)
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
await page.waitForResponse(
|
||||||
|
resp => resp.url().includes('/api/v1/applications'),
|
||||||
|
{ timeout: 5000 }
|
||||||
|
).catch(() => null)
|
||||||
|
|
||||||
|
await page.locator('button', { hasText: '选择文件' }).click()
|
||||||
|
|
||||||
|
await expect(page.locator('.el-dialog__header', { hasText: '选择文件' })).toBeVisible({ timeout: 3000 })
|
||||||
|
|
||||||
|
await expect(page.locator('.el-dialog .el-breadcrumb')).toBeVisible()
|
||||||
|
await expect(page.locator('.el-dialog .el-breadcrumb__item').first()).toContainText('全部文件')
|
||||||
|
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
const checkboxes = page.locator('.el-dialog .el-checkbox')
|
||||||
|
const checkboxCount = await checkboxes.count()
|
||||||
|
|
||||||
|
if (checkboxCount > 0) {
|
||||||
|
await checkboxes.first().click()
|
||||||
|
|
||||||
|
await expect(page.locator('.el-dialog button', { hasText: /确认/ })).toBeVisible()
|
||||||
|
await page.locator('.el-dialog button', { hasText: /确认/ }).click()
|
||||||
|
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
const tags = page.locator('.el-tag')
|
||||||
|
await expect(tags.first()).toBeVisible({ timeout: 3000 })
|
||||||
|
|
||||||
|
const firstTag = tags.first()
|
||||||
|
await expect(firstTag.locator('.el-tag__content')).toContainText(/\.txt/)
|
||||||
|
|
||||||
|
const removeIcon = firstTag.locator('.el-tag__close')
|
||||||
|
await removeIcon.click()
|
||||||
|
await expect(page.locator('.el-tag')).toHaveCount(0, { timeout: 2000 })
|
||||||
|
} else {
|
||||||
|
await page.locator('.el-dialog button', { hasText: '取消' }).click()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('11. FilePicker 多选测试', async ({ request, page }) => {
|
||||||
|
const files: { id: number; name: string }[] = []
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const content = `multi-file-${i}-${TS}`
|
||||||
|
const sha256 = await computeSHA256(content)
|
||||||
|
const initResp = await request.post(`${API_URL}/api/v1/files/uploads`, {
|
||||||
|
data: { file_name: content + '.txt', file_size: content.length, sha256, chunk_size: 16777216 },
|
||||||
|
})
|
||||||
|
const initData = await initResp.json()
|
||||||
|
|
||||||
|
if (initResp.status() === 201 && initData.data?.total_chunks) {
|
||||||
|
const sid = initData.data.id
|
||||||
|
await request.put(`${API_URL}/api/v1/files/uploads/${sid}/chunks/0`, {
|
||||||
|
multipart: { chunk: { name: 'chunk', mimeType: 'text/plain', buffer: Buffer.from(content) } },
|
||||||
|
})
|
||||||
|
const cResp = await request.post(`${API_URL}/api/v1/files/uploads/${sid}/complete`)
|
||||||
|
const cData = await cResp.json()
|
||||||
|
files.push({ id: cData.data.id, name: cData.data.name })
|
||||||
|
} else {
|
||||||
|
files.push({ id: initData.data.id, name: initData.data.name })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto(`${BASE_URL}/#/tasks/create`)
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
await page.waitForResponse(r => r.url().includes('/api/v1/applications'), { timeout: 5000 }).catch(() => null)
|
||||||
|
|
||||||
|
await page.locator('button', { hasText: '选择文件' }).click()
|
||||||
|
await expect(page.locator('.el-dialog__header', { hasText: '选择文件' })).toBeVisible({ timeout: 3000 })
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
|
||||||
|
const checkboxes = page.locator('.el-dialog .el-checkbox')
|
||||||
|
const count = await checkboxes.count()
|
||||||
|
const toSelect = Math.min(count, 3)
|
||||||
|
|
||||||
|
for (let i = 0; i < toSelect; i++) {
|
||||||
|
await checkboxes.nth(i).click()
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.locator('.el-dialog button', { hasText: /确认/ }).click()
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
|
||||||
|
if (toSelect > 0) {
|
||||||
|
const tags = page.locator('.el-tag')
|
||||||
|
await expect(tags).toHaveCount(toSelect, { timeout: 3000 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('12. 删除文件夹', async ({ page }) => {
|
||||||
|
await page.goto(`${BASE_URL}/#/files`)
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
|
||||||
|
const folderLink = page.locator('span', { hasText: new RegExp(`e2e-folder-${TS}`) }).first()
|
||||||
|
const folderRow = folderLink.locator('xpath=ancestor::div[contains(@style, "cursor: pointer")]')
|
||||||
|
|
||||||
|
const deleteBtn = folderRow.locator('button', { hasText: '删除' }).first()
|
||||||
|
if (await deleteBtn.isVisible().catch(() => false)) {
|
||||||
|
await deleteBtn.click()
|
||||||
|
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
const confirmBtn = page.locator('.el-message-box').locator('button', { hasText: '确定' })
|
||||||
|
if (await confirmBtn.isVisible().catch(() => false)) {
|
||||||
|
await confirmBtn.click()
|
||||||
|
await page.waitForTimeout(2000)
|
||||||
|
|
||||||
|
const successMsg = page.locator('.el-message--success')
|
||||||
|
if (await successMsg.isVisible().catch(() => false)) {
|
||||||
|
} else {
|
||||||
|
const errorMsg = page.locator('.el-message--error')
|
||||||
|
if (await errorMsg.isVisible().catch(() => false)) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function computeSHA256(content: string): Promise<string> {
|
||||||
|
const crypto = await import('crypto')
|
||||||
|
return crypto.createHash('sha256').update(content).digest('hex')
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
const BASE_URL = 'http://localhost:5173'
|
||||||
|
const API_URL = 'http://localhost:8080'
|
||||||
|
|
||||||
|
test.describe('文件管理模块', () => {
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// Navigate to file manager page
|
||||||
|
await page.goto(`${BASE_URL}/#/files`)
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('侧边栏有文件管理菜单项', async ({ page }) => {
|
||||||
|
// Check sidebar menu item exists
|
||||||
|
const fileMenuItem = page.locator('.el-menu-item').filter({ hasText: '文件管理' })
|
||||||
|
await expect(fileMenuItem).toBeVisible()
|
||||||
|
|
||||||
|
// Verify FolderOpened icon is present
|
||||||
|
const icon = fileMenuItem.locator('.el-icon')
|
||||||
|
await expect(icon).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('文件管理页面基本结构', async ({ page }) => {
|
||||||
|
// Page title
|
||||||
|
await expect(page.locator('h2', { hasText: '文件管理' })).toBeVisible()
|
||||||
|
|
||||||
|
// Breadcrumb navigation
|
||||||
|
await expect(page.locator('.el-breadcrumb')).toBeVisible()
|
||||||
|
await expect(page.locator('.el-breadcrumb__item').first()).toContainText('全部文件')
|
||||||
|
|
||||||
|
// Upload button
|
||||||
|
await expect(page.locator('button', { hasText: '上传文件' })).toBeVisible()
|
||||||
|
|
||||||
|
// Create folder button
|
||||||
|
await expect(page.locator('button', { hasText: '新建文件夹' })).toBeVisible()
|
||||||
|
|
||||||
|
// Search input
|
||||||
|
await expect(page.locator('input[placeholder="搜索文件"]')).toBeVisible()
|
||||||
|
|
||||||
|
// File table (may be empty)
|
||||||
|
await expect(page.locator('.el-table')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('API 连接正常 - 空文件列表', async ({ page }) => {
|
||||||
|
// Table exists (even if empty - API already responded during navigation)
|
||||||
|
const table = page.locator('.el-table')
|
||||||
|
await expect(table).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('创建文件夹', async ({ page }) => {
|
||||||
|
// Click create folder button
|
||||||
|
await page.locator('button', { hasText: '新建文件夹' }).click()
|
||||||
|
|
||||||
|
// Dialog should appear
|
||||||
|
await expect(page.locator('.el-dialog__header', { hasText: '新建文件夹' })).toBeVisible()
|
||||||
|
|
||||||
|
// Type folder name
|
||||||
|
const dialog = page.locator('.el-dialog')
|
||||||
|
await dialog.locator('input').fill('test-folder-' + Date.now())
|
||||||
|
|
||||||
|
// Click create button
|
||||||
|
await dialog.locator('button', { hasText: '创建' }).click()
|
||||||
|
|
||||||
|
// Wait for success message
|
||||||
|
await expect(page.locator('.el-message--success')).toBeVisible({ timeout: 5000 })
|
||||||
|
|
||||||
|
// Folder should appear in the list
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('搜索框交互', async ({ page }) => {
|
||||||
|
const searchInput = page.locator('input[placeholder="搜索文件"]')
|
||||||
|
await expect(searchInput).toBeVisible()
|
||||||
|
|
||||||
|
// Type search query
|
||||||
|
await searchInput.fill('test')
|
||||||
|
|
||||||
|
// Press enter to trigger search
|
||||||
|
await searchInput.press('Enter')
|
||||||
|
|
||||||
|
// Should make API call with search parameter
|
||||||
|
const response = await page.waitForResponse(
|
||||||
|
resp => resp.url().includes('/api/v1/files') && resp.url().includes('search=test'),
|
||||||
|
{ timeout: 5000 }
|
||||||
|
).catch(() => null)
|
||||||
|
|
||||||
|
// Response might be null if server already responded, that's ok
|
||||||
|
// The important thing is the search input still has the value
|
||||||
|
await expect(searchInput).toHaveValue('test')
|
||||||
|
|
||||||
|
// Clear search
|
||||||
|
await searchInput.fill('')
|
||||||
|
await searchInput.press('Enter')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('面包屑导航 - 初始状态', async ({ page }) => {
|
||||||
|
// Initial breadcrumb should show "全部文件"
|
||||||
|
const breadcrumbs = page.locator('.el-breadcrumb__item')
|
||||||
|
await expect(breadcrumbs.first()).toContainText('全部文件')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('任务提交 - 文件选择器集成', () => {
|
||||||
|
|
||||||
|
test('提交任务页面有文件选择功能', async ({ page }) => {
|
||||||
|
await page.goto(`${BASE_URL}/#/tasks/create`)
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
|
||||||
|
// Wait for applications to load
|
||||||
|
await page.waitForResponse(
|
||||||
|
resp => resp.url().includes('/api/v1/applications'),
|
||||||
|
{ timeout: 5000 }
|
||||||
|
).catch(() => null)
|
||||||
|
|
||||||
|
// Check "关联文件" form item exists
|
||||||
|
await expect(page.locator('.el-form-item', { hasText: '关联文件' })).toBeVisible()
|
||||||
|
|
||||||
|
// Check "选择文件" button exists
|
||||||
|
await expect(page.locator('button', { hasText: '选择文件' })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('点击选择文件打开 FilePicker 弹窗', async ({ page }) => {
|
||||||
|
await page.goto(`${BASE_URL}/#/tasks/create`)
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
|
||||||
|
// Wait for applications to load
|
||||||
|
await page.waitForResponse(
|
||||||
|
resp => resp.url().includes('/api/v1/applications'),
|
||||||
|
{ timeout: 5000 }
|
||||||
|
).catch(() => null)
|
||||||
|
|
||||||
|
// Click "选择文件" button
|
||||||
|
await page.locator('button', { hasText: '选择文件' }).click()
|
||||||
|
|
||||||
|
// FilePicker dialog should appear
|
||||||
|
await expect(page.locator('.el-dialog__header', { hasText: '选择文件' })).toBeVisible({ timeout: 3000 })
|
||||||
|
|
||||||
|
// Should have breadcrumb navigation
|
||||||
|
await expect(page.locator('.el-dialog .el-breadcrumb')).toBeVisible()
|
||||||
|
|
||||||
|
// Should have confirm and cancel buttons
|
||||||
|
await expect(page.locator('.el-dialog button', { hasText: '取消' })).toBeVisible()
|
||||||
|
await expect(page.locator('.el-dialog button', { hasText: '确认' })).toBeVisible()
|
||||||
|
|
||||||
|
// Close dialog
|
||||||
|
await page.locator('.el-dialog button', { hasText: '取消' }).click()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('文件上传流程', () => {
|
||||||
|
|
||||||
|
test('上传按钮触发文件选择', async ({ page }) => {
|
||||||
|
await page.goto(`${BASE_URL}/#/files`)
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
|
||||||
|
// Upload button should be visible and enabled
|
||||||
|
const uploadBtn = page.locator('button', { hasText: '上传文件' })
|
||||||
|
await expect(uploadBtn).toBeVisible()
|
||||||
|
await expect(uploadBtn).toBeEnabled()
|
||||||
|
|
||||||
|
// Click should trigger file input
|
||||||
|
const fileInput = page.locator('input[type="file"]')
|
||||||
|
await expect(fileInput).toBeAttached()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('API 端点验证', () => {
|
||||||
|
|
||||||
|
test('GET /api/v1/files 返回正确格式', async ({ request }) => {
|
||||||
|
const resp = await request.get(`${API_URL}/api/v1/files`)
|
||||||
|
expect(resp.status()).toBe(200)
|
||||||
|
const body = await resp.json()
|
||||||
|
expect(body.success).toBe(true)
|
||||||
|
expect(body.data).toBeDefined()
|
||||||
|
expect(body.data.files).toBeInstanceOf(Array)
|
||||||
|
expect(typeof body.data.total).toBe('number')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GET /api/v1/files/folders 返回正确格式', async ({ request }) => {
|
||||||
|
const resp = await request.get(`${API_URL}/api/v1/files/folders`)
|
||||||
|
expect(resp.status()).toBe(200)
|
||||||
|
const body = await resp.json()
|
||||||
|
expect(body.success).toBe(true)
|
||||||
|
expect(body.data).toBeInstanceOf(Array)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('POST /api/v1/files/folders 创建文件夹', async ({ request }) => {
|
||||||
|
const folderName = `test-folder-${Date.now()}`
|
||||||
|
const resp = await request.post(`${API_URL}/api/v1/files/folders`, {
|
||||||
|
data: { name: folderName }
|
||||||
|
})
|
||||||
|
expect([200, 201]).toContain(resp.status())
|
||||||
|
const body = await resp.json()
|
||||||
|
expect(body.success).toBe(true)
|
||||||
|
expect(body.data.name).toBe(folderName)
|
||||||
|
expect(body.data.id).toBeDefined()
|
||||||
|
expect(typeof body.data.file_count).toBe('number')
|
||||||
|
expect(typeof body.data.subfolder_count).toBe('number')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('POST /api/v1/files/uploads/init - 秒传测试', async ({ request }) => {
|
||||||
|
// Use a known SHA256 to test instant upload behavior
|
||||||
|
const resp = await request.post(`${API_URL}/api/v1/files/uploads`, {
|
||||||
|
data: {
|
||||||
|
file_name: 'test.txt',
|
||||||
|
file_size: 5,
|
||||||
|
sha256: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890',
|
||||||
|
chunk_size: 16777216,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// Will be either 200 (new upload session) or some error
|
||||||
|
// Just verify the endpoint is reachable
|
||||||
|
expect([200, 201, 400, 500]).toContain(resp.status())
|
||||||
|
})
|
||||||
|
|
||||||
|
test('创建子文件夹并在列表中看到', async ({ request }) => {
|
||||||
|
// Create parent folder
|
||||||
|
const parentResp = await request.post(`${API_URL}/api/v1/files/folders`, {
|
||||||
|
data: { name: `parent-${Date.now()}` }
|
||||||
|
})
|
||||||
|
const parent = await parentResp.json()
|
||||||
|
const parentId = parent.data.id
|
||||||
|
|
||||||
|
// Create child folder
|
||||||
|
const childResp = await request.post(`${API_URL}/api/v1/files/folders`, {
|
||||||
|
data: { name: `child-${Date.now()}`, parent_id: parentId }
|
||||||
|
})
|
||||||
|
expect([200, 201]).toContain(childResp.status())
|
||||||
|
const child = await childResp.json()
|
||||||
|
expect(child.data.parent_id).toBe(parentId)
|
||||||
|
|
||||||
|
// List folders under parent
|
||||||
|
const listResp = await request.get(`${API_URL}/api/v1/files/folders?parent_id=${parentId}`)
|
||||||
|
expect(listResp.status()).toBe(200)
|
||||||
|
const list = await listResp.json()
|
||||||
|
expect(list.data.length).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('删除空文件夹成功', async ({ request }) => {
|
||||||
|
// Create a folder
|
||||||
|
const createResp = await request.post(`${API_URL}/api/v1/files/folders`, {
|
||||||
|
data: { name: `delete-me-${Date.now()}` }
|
||||||
|
})
|
||||||
|
const folder = await createResp.json()
|
||||||
|
const folderId = folder.data.id
|
||||||
|
|
||||||
|
// Delete it (should succeed - empty folder)
|
||||||
|
const deleteResp = await request.delete(`${API_URL}/api/v1/files/folders/${folderId}`)
|
||||||
|
expect(deleteResp.status()).toBe(200)
|
||||||
|
const deleteBody = await deleteResp.json()
|
||||||
|
expect(deleteBody.success).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import AutoImport from 'unplugin-auto-import/vite'
|
||||||
|
import Components from 'unplugin-vue-components/vite'
|
||||||
|
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
AutoImport({
|
||||||
|
imports: ['vue', 'vue-router'],
|
||||||
|
resolvers: [ElementPlusResolver()],
|
||||||
|
dts: 'src/auto-imports.d.ts',
|
||||||
|
}),
|
||||||
|
Components({
|
||||||
|
resolvers: [ElementPlusResolver()],
|
||||||
|
dts: 'src/components.d.ts',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user