Compare commits
22
Commits
| 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/
|
||||
*.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())
|
||||
_ = service.NewDownloadService(memStore, blobStore, fileStore, "files", 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())
|
||||
fileH := handler.NewFileHandler(fileSvc, zap.NewNop())
|
||||
|
||||
@@ -31,11 +31,38 @@ type taskListData struct {
|
||||
}
|
||||
|
||||
type taskListItem struct {
|
||||
ID int64 `json:"id"`
|
||||
TaskName string `json:"task_name"`
|
||||
AppID int64 `json:"app_id"`
|
||||
Status string `json:"status"`
|
||||
SlurmJobID *int32 `json:"slurm_job_id"`
|
||||
ID int64 `json:"id"`
|
||||
TaskName string `json:"task_name"`
|
||||
AppID int64 `json:"app_id"`
|
||||
Status string `json:"status"`
|
||||
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.
|
||||
@@ -259,3 +286,233 @@ func TestIntegration_Task_Validation(t *testing.T) {
|
||||
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,
|
||||
"description": "Search files by name",
|
||||
"schema": { "type": "string" }
|
||||
},
|
||||
{
|
||||
"name": "user_id",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Filter by file owner user ID",
|
||||
"schema": { "type": "integer", "format": "int64" }
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -2738,7 +2745,18 @@
|
||||
"folder_id": {
|
||||
"type": "integer",
|
||||
"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
|
||||
},
|
||||
"size": {
|
||||
@@ -2869,6 +2887,114 @@
|
||||
"format": "int64"
|
||||
},
|
||||
"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",
|
||||
"format": "date-time",
|
||||
"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)
|
||||
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)
|
||||
fileH = handler.NewFileHandler(fileSvc, logger)
|
||||
|
||||
@@ -14,7 +14,8 @@ import (
|
||||
)
|
||||
|
||||
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)
|
||||
DownloadFile(ctx context.Context, fileID int64, rangeHeader string) (io.ReadCloser, *model.File, *model.FileBlob, int64, int64, error)
|
||||
DeleteFile(ctx context.Context, fileID int64) error
|
||||
@@ -50,9 +51,20 @@ func (h *FileHandler) ListFiles(c *gin.Context) {
|
||||
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")
|
||||
|
||||
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 {
|
||||
h.logger.Error("failed to list files", zap.Error(err))
|
||||
server.InternalError(c, err.Error())
|
||||
@@ -75,23 +87,13 @@ func (h *FileHandler) GetFile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
file, blob, err := h.svc.GetFileMetadata(c.Request.Context(), id)
|
||||
resp, err := h.svc.GetFileResponse(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get file", zap.Int64("id", id), zap.Error(err))
|
||||
server.InternalError(c, err.Error())
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,14 +18,19 @@ import (
|
||||
)
|
||||
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
func (m *mockFileService) ListFiles(ctx context.Context, folderID *int64, page, pageSize int, search string) ([]model.FileResponse, int64, error) {
|
||||
return m.listFilesFn(ctx, folderID, page, pageSize, search)
|
||||
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, 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) {
|
||||
@@ -67,7 +72,7 @@ func newFileHandlerSetup() *fileHandlerSetup {
|
||||
|
||||
func TestListFiles_Empty(t *testing.T) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -97,7 +102,7 @@ func TestListFiles_Empty(t *testing.T) {
|
||||
func TestListFiles_WithFiles(t *testing.T) {
|
||||
s := newFileHandlerSetup()
|
||||
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{
|
||||
{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},
|
||||
@@ -133,7 +138,7 @@ func TestListFiles_WithFiles(t *testing.T) {
|
||||
func TestListFiles_WithFolderID(t *testing.T) {
|
||||
s := newFileHandlerSetup()
|
||||
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
|
||||
return []model.FileResponse{}, 0, nil
|
||||
}
|
||||
@@ -152,7 +157,7 @@ func TestListFiles_WithFolderID(t *testing.T) {
|
||||
|
||||
func TestListFiles_ServiceError(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -170,12 +175,11 @@ func TestListFiles_ServiceError(t *testing.T) {
|
||||
func TestGetFile_Found(t *testing.T) {
|
||||
s := newFileHandlerSetup()
|
||||
now := time.Now()
|
||||
s.mock.getFileMetadataFn = func(ctx context.Context, fileID int64) (*model.File, *model.FileBlob, error) {
|
||||
return &model.File{
|
||||
ID: 1, Name: "test.txt", BlobSHA256: "abc123", CreatedAt: now, UpdatedAt: now,
|
||||
}, &model.FileBlob{
|
||||
ID: 1, SHA256: "abc123", FileSize: 1024, MimeType: "text/plain", CreatedAt: now,
|
||||
}, nil
|
||||
rootPath := "/"
|
||||
s.mock.getFileResponseFn = func(ctx context.Context, fileID int64) (*model.FileResponse, error) {
|
||||
return &model.FileResponse{
|
||||
ID: 1, Name: "test.txt", SHA256: "abc123", FolderPath: &rootPath, CreatedAt: now, UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -195,8 +199,8 @@ func TestGetFile_Found(t *testing.T) {
|
||||
|
||||
func TestGetFile_NotFound(t *testing.T) {
|
||||
s := newFileHandlerSetup()
|
||||
s.mock.getFileMetadataFn = func(ctx context.Context, fileID int64) (*model.File, *model.FileBlob, error) {
|
||||
return nil, nil, fmt.Errorf("file not found: 999")
|
||||
s.mock.getFileResponseFn = func(ctx context.Context, fileID int64) (*model.FileResponse, error) {
|
||||
return nil, fmt.Errorf("file not found: 999")
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -76,18 +76,45 @@ func (h *TaskHandler) ListTasks(c *gin.Context) {
|
||||
responses := make([]model.TaskResponse, 0, len(tasks))
|
||||
for i := range tasks {
|
||||
responses = append(responses, model.TaskResponse{
|
||||
ID: tasks[i].ID,
|
||||
TaskName: tasks[i].TaskName,
|
||||
AppID: tasks[i].AppID,
|
||||
AppName: tasks[i].AppName,
|
||||
Status: tasks[i].Status,
|
||||
CurrentStep: tasks[i].CurrentStep,
|
||||
RetryCount: tasks[i].RetryCount,
|
||||
SlurmJobID: tasks[i].SlurmJobID,
|
||||
WorkDir: tasks[i].WorkDir,
|
||||
ErrorMessage: tasks[i].ErrorMessage,
|
||||
CreatedAt: tasks[i].CreatedAt,
|
||||
UpdatedAt: tasks[i].UpdatedAt,
|
||||
ID: tasks[i].ID,
|
||||
TaskName: tasks[i].TaskName,
|
||||
AppID: tasks[i].AppID,
|
||||
AppName: tasks[i].AppName,
|
||||
Status: tasks[i].Status,
|
||||
CurrentStep: tasks[i].CurrentStep,
|
||||
RetryCount: tasks[i].RetryCount,
|
||||
SlurmJobID: tasks[i].SlurmJobID,
|
||||
WorkDir: tasks[i].WorkDir,
|
||||
ErrorMessage: tasks[i].ErrorMessage,
|
||||
CreatedAt: tasks[i].CreatedAt,
|
||||
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) {
|
||||
h, db := setupTaskHandler(t, nil)
|
||||
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"
|
||||
)
|
||||
|
||||
// Parameter type constants for ParameterSchema.Type.
|
||||
// 参数类型常量
|
||||
const (
|
||||
ParamTypeString = "string"
|
||||
ParamTypeInteger = "integer"
|
||||
@@ -15,62 +15,59 @@ const (
|
||||
ParamTypeBoolean = "boolean"
|
||||
)
|
||||
|
||||
// Application represents a parameterized application definition for HPC job submission.
|
||||
// Application 表示一个参数化的 HPC 应用定义。
|
||||
type Application struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Name string `gorm:"uniqueIndex;size:255;not null" json:"name"`
|
||||
Description string `gorm:"type:text" json:"description,omitempty"`
|
||||
Icon string `gorm:"size:255" json:"icon,omitempty"`
|
||||
Category string `gorm:"size:255" json:"category,omitempty"`
|
||||
ScriptTemplate string `gorm:"type:text;not null" json:"script_template"`
|
||||
Parameters json.RawMessage `gorm:"type:json" json:"parameters,omitempty"`
|
||||
Scope string `gorm:"size:50;default:'system'" json:"scope,omitempty"`
|
||||
CreatedBy int64 `json:"created_by,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` // 主键
|
||||
Name string `gorm:"uniqueIndex;size:255;not null" json:"name"` // 应用名称(唯一)
|
||||
Description string `gorm:"type:text" json:"description,omitempty"` // 应用描述
|
||||
Icon string `gorm:"size:255" json:"icon,omitempty"` // 图标
|
||||
Category string `gorm:"size:255" json:"category,omitempty"` // 分类
|
||||
ScriptTemplate string `gorm:"type:text;not null" json:"script_template"` // 脚本模板
|
||||
Parameters json.RawMessage `gorm:"type:json" json:"parameters,omitempty"` // 参数表单JSON
|
||||
Scope string `gorm:"size:50;default:'system'" json:"scope,omitempty"` // 作用域(system/user)
|
||||
CreatedBy int64 `json:"created_by,omitempty"` // 创建者ID
|
||||
CreatedAt time.Time `json:"created_at"` // 创建时间
|
||||
UpdatedAt time.Time `json:"updated_at"` // 更新时间
|
||||
}
|
||||
|
||||
func (Application) TableName() string {
|
||||
return "hpc_applications"
|
||||
}
|
||||
|
||||
// ParameterSchema defines a single parameter in an application's form schema.
|
||||
// ParameterSchema 定义应用表单中单个参数的格式。
|
||||
type ParameterSchema struct {
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Default string `json:"default,omitempty"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Name string `json:"name"` // 参数名
|
||||
Label string `json:"label,omitempty"` // 显示名称
|
||||
Type string `json:"type"` // 参数类型
|
||||
Required bool `json:"required,omitempty"` // 是否必填
|
||||
Default string `json:"default,omitempty"` // 默认值
|
||||
Options []string `json:"options,omitempty"` // 枚举选项列表
|
||||
Description string `json:"description,omitempty"` // 参数说明
|
||||
}
|
||||
|
||||
// CreateApplicationRequest is the DTO for creating a new application.
|
||||
// CreateApplicationRequest 是创建应用的 API 请求。
|
||||
type CreateApplicationRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
ScriptTemplate string `json:"script_template" binding:"required"`
|
||||
Parameters json.RawMessage `json:"parameters,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Name string `json:"name" binding:"required"` // 应用名称(必填)
|
||||
Description string `json:"description,omitempty"` // 应用描述
|
||||
Icon string `json:"icon,omitempty"` // 图标
|
||||
Category string `json:"category,omitempty"` // 分类
|
||||
ScriptTemplate string `json:"script_template" binding:"required"` // 脚本模板(必填)
|
||||
Parameters json.RawMessage `json:"parameters,omitempty"` // 参数表单JSON
|
||||
Scope string `json:"scope,omitempty"` // 作用域
|
||||
}
|
||||
|
||||
// UpdateApplicationRequest is the DTO for updating an existing application.
|
||||
// All fields are optional. Parameters uses *json.RawMessage to distinguish
|
||||
// between "not provided" (nil) and "set to empty" (non-nil).
|
||||
// UpdateApplicationRequest 是更新应用的 API 请求。所有字段可选。
|
||||
type UpdateApplicationRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Icon *string `json:"icon,omitempty"`
|
||||
Category *string `json:"category,omitempty"`
|
||||
ScriptTemplate *string `json:"script_template,omitempty"`
|
||||
Parameters *json.RawMessage `json:"parameters,omitempty"`
|
||||
Scope *string `json:"scope,omitempty"`
|
||||
Name *string `json:"name,omitempty"` // 应用名称
|
||||
Description *string `json:"description,omitempty"` // 应用描述
|
||||
Icon *string `json:"icon,omitempty"` // 图标
|
||||
Category *string `json:"category,omitempty"` // 分类
|
||||
ScriptTemplate *string `json:"script_template,omitempty"` // 脚本模板
|
||||
Parameters *json.RawMessage `json:"parameters,omitempty"` // 参数表单JSON
|
||||
Scope *string `json:"scope,omitempty"` // 作用域
|
||||
}
|
||||
|
||||
// ApplicationSubmitRequest is the DTO for submitting a job from an application.
|
||||
// ApplicationID is parsed from the URL :id parameter, not included in the body.
|
||||
// ApplicationSubmitRequest 是通过应用提交作业的 API 请求。
|
||||
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
|
||||
|
||||
// NodeResponse is the API response for a node.
|
||||
// NodeResponse 是节点查询 API 响应。
|
||||
type NodeResponse struct {
|
||||
// Identity
|
||||
Name string `json:"name"` // 节点主机名
|
||||
@@ -37,7 +37,7 @@ type NodeResponse struct {
|
||||
ActiveFeatures string `json:"active_features,omitempty"` // 当前生效的特性标签 (只读)
|
||||
}
|
||||
|
||||
// PartitionResponse is the API response for a partition.
|
||||
// PartitionResponse 是分区查询 API 响应。
|
||||
type PartitionResponse struct {
|
||||
// Identity
|
||||
Name string `json:"name"` // 分区名称
|
||||
|
||||
+100
-98
@@ -9,157 +9,159 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// FileBlob represents a physical file stored in MinIO, deduplicated by SHA256.
|
||||
// FileBlob 表示存储在 MinIO 中的物理文件,按 SHA256 去重。
|
||||
type FileBlob struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
SHA256 string `gorm:"uniqueIndex;size:64;not null" json:"sha256"`
|
||||
MinioKey string `gorm:"size:255;not null" json:"minio_key"`
|
||||
FileSize int64 `gorm:"not null" json:"file_size"`
|
||||
MimeType string `gorm:"size:255" json:"mime_type"`
|
||||
RefCount int `gorm:"not null;default:0" json:"ref_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` // 主键
|
||||
SHA256 string `gorm:"uniqueIndex;size:64;not null" json:"sha256"` // 文件哈希
|
||||
MinioKey string `gorm:"size:255;not null" json:"minio_key"` // MinIO对象键
|
||||
FileSize int64 `gorm:"not null" json:"file_size"` // 文件大小(字节)
|
||||
MimeType string `gorm:"size:255" json:"mime_type"` // MIME类型
|
||||
RefCount int `gorm:"not null;default:0" json:"ref_count"` // 引用计数
|
||||
CreatedAt time.Time `json:"created_at"` // 创建时间
|
||||
UpdatedAt time.Time `json:"updated_at"` // 更新时间
|
||||
}
|
||||
|
||||
func (FileBlob) TableName() string {
|
||||
return "hpc_file_blobs"
|
||||
}
|
||||
|
||||
// File represents a logical file visible to users, backed by a FileBlob.
|
||||
// File 表示用户可见的逻辑文件,底层由 FileBlob 存储。
|
||||
type File struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Name string `gorm:"size:255;not null" json:"name"`
|
||||
FolderID *int64 `gorm:"index" json:"folder_id,omitempty"`
|
||||
BlobSHA256 string `gorm:"size:64;not null" json:"blob_sha256"`
|
||||
UserID *int64 `gorm:"index" json:"user_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` // 主键
|
||||
Name string `gorm:"size:255;not null" json:"name"` // 文件名
|
||||
FolderID *int64 `gorm:"index" json:"folder_id,omitempty"` // 所属文件夹ID
|
||||
BlobSHA256 string `gorm:"size:64;not null" json:"blob_sha256"` // 关联的文件blob哈希
|
||||
UserID *int64 `gorm:"index" json:"user_id,omitempty"` // 所有者ID
|
||||
CreatedAt time.Time `json:"created_at"` // 创建时间
|
||||
UpdatedAt time.Time `json:"updated_at"` // 更新时间
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"` // 软删除时间
|
||||
}
|
||||
|
||||
func (File) TableName() string {
|
||||
return "hpc_files"
|
||||
}
|
||||
|
||||
// Folder represents a directory in the virtual file system.
|
||||
// Folder 表示虚拟文件系统中的目录。
|
||||
type Folder struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Name string `gorm:"size:255;not null" json:"name"`
|
||||
ParentID *int64 `gorm:"index" json:"parent_id,omitempty"`
|
||||
Path string `gorm:"uniqueIndex;size:768;not null" json:"path"`
|
||||
UserID *int64 `gorm:"index" json:"user_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` // 主键
|
||||
Name string `gorm:"size:255;not null" json:"name"` // 文件夹名称
|
||||
ParentID *int64 `gorm:"index" json:"parent_id,omitempty"` // 父文件夹ID
|
||||
Path string `gorm:"uniqueIndex;size:768;not null" json:"path"` // 完整路径(唯一)
|
||||
UserID *int64 `gorm:"index" json:"user_id,omitempty"` // 所有者ID
|
||||
CreatedAt time.Time `json:"created_at"` // 创建时间
|
||||
UpdatedAt time.Time `json:"updated_at"` // 更新时间
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"` // 软删除时间
|
||||
}
|
||||
|
||||
func (Folder) TableName() string {
|
||||
return "hpc_folders"
|
||||
}
|
||||
|
||||
// UploadSession represents an in-progress chunked upload.
|
||||
// State transitions: pending→uploading, pending→completed(zero-byte), uploading→merging,
|
||||
// UploadSession 表示一个进行中的分块上传会话。
|
||||
// 状态转换: pending→uploading, pending→completed(零字节), uploading→merging,
|
||||
// uploading→cancelled, merging→completed, merging→failed, any→expired
|
||||
type UploadSession struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
FileName string `gorm:"size:255;not null" json:"file_name"`
|
||||
FileSize int64 `gorm:"not null" json:"file_size"`
|
||||
ChunkSize int64 `gorm:"not null" json:"chunk_size"`
|
||||
TotalChunks int `gorm:"not null" json:"total_chunks"`
|
||||
SHA256 string `gorm:"size:64;not null" json:"sha256"`
|
||||
FolderID *int64 `gorm:"index" json:"folder_id,omitempty"`
|
||||
Status string `gorm:"size:20;not null;default:pending" json:"status"`
|
||||
MinioPrefix string `gorm:"size:255;not null" json:"minio_prefix"`
|
||||
MimeType string `gorm:"size:255;default:'application/octet-stream'" json:"mime_type"`
|
||||
UserID *int64 `gorm:"index" json:"user_id,omitempty"`
|
||||
ExpiresAt time.Time `gorm:"not null" json:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` // 主键
|
||||
FileName string `gorm:"size:255;not null" json:"file_name"` // 文件名
|
||||
FileSize int64 `gorm:"not null" json:"file_size"` // 文件总大小
|
||||
ChunkSize int64 `gorm:"not null" json:"chunk_size"` // 分块大小
|
||||
TotalChunks int `gorm:"not null" json:"total_chunks"` // 总分块数
|
||||
SHA256 string `gorm:"size:64;not null" json:"sha256"` // 文件哈希
|
||||
FolderID *int64 `gorm:"index" json:"folder_id,omitempty"` // 目标文件夹ID
|
||||
Status string `gorm:"size:20;not null;default:pending" json:"status"` // 会话状态
|
||||
MinioPrefix string `gorm:"size:255;not null" json:"minio_prefix"` // MinIO存储前缀
|
||||
MimeType string `gorm:"size:255;default:'application/octet-stream'" json:"mime_type"` // MIME类型
|
||||
UserID *int64 `gorm:"index" json:"user_id,omitempty"` // 上传者ID
|
||||
ExpiresAt time.Time `gorm:"not null" json:"expires_at"` // 过期时间
|
||||
CreatedAt time.Time `json:"created_at"` // 创建时间
|
||||
UpdatedAt time.Time `json:"updated_at"` // 更新时间
|
||||
}
|
||||
|
||||
func (UploadSession) TableName() string {
|
||||
return "hpc_upload_sessions"
|
||||
}
|
||||
|
||||
// UploadChunk represents a single chunk of an upload session.
|
||||
// UploadChunk 表示上传会话中的单个分块。
|
||||
type UploadChunk struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
SessionID int64 `gorm:"not null;uniqueIndex:idx_session_chunk" json:"session_id"`
|
||||
ChunkIndex int `gorm:"not null;uniqueIndex:idx_session_chunk" json:"chunk_index"`
|
||||
MinioKey string `gorm:"size:255;not null" json:"minio_key"`
|
||||
SHA256 string `gorm:"size:64" json:"sha256,omitempty"`
|
||||
Size int64 `gorm:"not null" json:"size"`
|
||||
Status string `gorm:"size:20;not null;default:pending" json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"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"` // 分块序号
|
||||
MinioKey string `gorm:"size:255;not null" json:"minio_key"` // MinIO对象键
|
||||
SHA256 string `gorm:"size:64" json:"sha256,omitempty"` // 分块哈希
|
||||
Size int64 `gorm:"not null" json:"size"` // 分块大小
|
||||
Status string `gorm:"size:20;not null;default:pending" json:"status"` // 分块状态
|
||||
CreatedAt time.Time `json:"created_at"` // 创建时间
|
||||
UpdatedAt time.Time `json:"updated_at"` // 更新时间
|
||||
}
|
||||
|
||||
func (UploadChunk) TableName() string {
|
||||
return "hpc_upload_chunks"
|
||||
}
|
||||
|
||||
// InitUploadRequest is the DTO for initiating a chunked upload.
|
||||
// InitUploadRequest 是初始化分块上传的 API 请求。
|
||||
type InitUploadRequest struct {
|
||||
FileName string `json:"file_name" binding:"required"`
|
||||
FileSize int64 `json:"file_size" binding:"required"`
|
||||
SHA256 string `json:"sha256" binding:"required"`
|
||||
FolderID *int64 `json:"folder_id,omitempty"`
|
||||
ChunkSize *int64 `json:"chunk_size,omitempty"`
|
||||
MimeType string `json:"mime_type,omitempty"`
|
||||
FileName string `json:"file_name" binding:"required"` // 文件名(必填)
|
||||
FileSize int64 `json:"file_size" binding:"required"` // 文件大小(必填)
|
||||
SHA256 string `json:"sha256" binding:"required"` // 文件哈希(必填)
|
||||
FolderID *int64 `json:"folder_id,omitempty"` // 目标文件夹ID
|
||||
ChunkSize *int64 `json:"chunk_size,omitempty"` // 分块大小
|
||||
MimeType string `json:"mime_type,omitempty"` // MIME类型
|
||||
}
|
||||
|
||||
// CreateFolderRequest is the DTO for creating a new folder.
|
||||
// CreateFolderRequest 是创建文件夹的 API 请求。
|
||||
type CreateFolderRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
ParentID *int64 `json:"parent_id,omitempty"`
|
||||
Name string `json:"name" binding:"required"` // 文件夹名称(必填)
|
||||
ParentID *int64 `json:"parent_id,omitempty"` // 父文件夹ID
|
||||
}
|
||||
|
||||
// UploadSessionResponse is the DTO returned when creating/querying an upload session.
|
||||
// UploadSessionResponse 是上传会话的 API 响应。
|
||||
type UploadSessionResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
FileName string `json:"file_name"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
ChunkSize int64 `json:"chunk_size"`
|
||||
TotalChunks int `json:"total_chunks"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Status string `json:"status"`
|
||||
UploadedChunks []int `json:"uploaded_chunks"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID int64 `json:"id"` // 主键
|
||||
FileName string `json:"file_name"` // 文件名
|
||||
FileSize int64 `json:"file_size"` // 文件大小
|
||||
ChunkSize int64 `json:"chunk_size"` // 分块大小
|
||||
TotalChunks int `json:"total_chunks"` // 总分块数
|
||||
SHA256 string `json:"sha256"` // 文件哈希
|
||||
Status string `json:"status"` // 会话状态
|
||||
UploadedChunks []int `json:"uploaded_chunks"` // 已上传的分块序号列表
|
||||
ExpiresAt time.Time `json:"expires_at"` // 过期时间
|
||||
CreatedAt time.Time `json:"created_at"` // 创建时间
|
||||
}
|
||||
|
||||
// FileResponse is the DTO for a file in API responses.
|
||||
// FileResponse 是文件 API 响应。
|
||||
type FileResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
FolderID *int64 `json:"folder_id,omitempty"`
|
||||
Size int64 `json:"size"`
|
||||
MimeType string `json:"mime_type"`
|
||||
SHA256 string `json:"sha256"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID int64 `json:"id"` // 主键
|
||||
Name string `json:"name"` // 文件名
|
||||
FolderID *int64 `json:"folder_id"` // 所属文件夹ID
|
||||
FolderPath *string `json:"folder_path"` // 所属文件夹路径
|
||||
UserID *int64 `json:"user_id"` // 所有者ID
|
||||
Size int64 `json:"size"` // 文件大小
|
||||
MimeType string `json:"mime_type"` // MIME类型
|
||||
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 {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ParentID *int64 `json:"parent_id,omitempty"`
|
||||
Path string `json:"path"`
|
||||
FileCount int64 `json:"file_count"`
|
||||
SubFolderCount int64 `json:"subfolder_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID int64 `json:"id"` // 主键
|
||||
Name string `json:"name"` // 文件夹名称
|
||||
ParentID *int64 `json:"parent_id,omitempty"` // 父文件夹ID
|
||||
Path string `json:"path"` // 完整路径
|
||||
FileCount int64 `json:"file_count"` // 文件数量
|
||||
SubFolderCount int64 `json:"subfolder_count"` // 子文件夹数量
|
||||
CreatedAt time.Time `json:"created_at"` // 创建时间
|
||||
}
|
||||
|
||||
// ListFilesResponse is the paginated response for listing files.
|
||||
// ListFilesResponse 是文件列表分页响应。
|
||||
type ListFilesResponse struct {
|
||||
Files []FileResponse `json:"files"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Files []FileResponse `json:"files"` // 文件列表
|
||||
Total int64 `json:"total"` // 总数
|
||||
Page int `json:"page"` // 页码
|
||||
PageSize int `json:"page_size"` // 每页条数
|
||||
}
|
||||
|
||||
// ValidateFileName rejects empty, "..", "/", "\", null bytes, control chars, leading/trailing spaces.
|
||||
// ValidateFileName 校验文件名:禁止空值、"..", "/", "\", null字节、控制字符、首尾空格。
|
||||
func ValidateFileName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("file name cannot be empty")
|
||||
@@ -184,7 +186,7 @@ func ValidateFileName(name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateFolderName rejects same as ValidateFileName plus ".".
|
||||
// ValidateFolderName 校验文件夹名:在 ValidateFileName 基础上额外禁止 "."。
|
||||
func ValidateFolderName(name string) error {
|
||||
if name == "." {
|
||||
return fmt.Errorf("folder name cannot be '.'")
|
||||
|
||||
+29
-6
@@ -1,19 +1,42 @@
|
||||
package model
|
||||
|
||||
// SubmitJobRequest is the API request for submitting a job.
|
||||
// SubmitJobRequest 是提交作业的 API 请求。
|
||||
type SubmitJobRequest struct {
|
||||
Script string `json:"script"` // 作业脚本内容
|
||||
Partition string `json:"partition,omitempty"` // 提交到的分区
|
||||
QOS string `json:"qos,omitempty"` // 使用的 QOS 策略
|
||||
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"` // 运行时间限制 (分钟)
|
||||
JobName string `json:"job_name,omitempty"` // 作业名称
|
||||
Environment map[string]string `json:"environment,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 {
|
||||
// Identity
|
||||
JobID int32 `json:"job_id"` // Slurm 作业 ID
|
||||
@@ -59,7 +82,7 @@ type JobResponse struct {
|
||||
ArrayTaskID *int32 `json:"array_task_id,omitempty"` // 数组作业中的子任务 ID
|
||||
}
|
||||
|
||||
// JobListResponse is the paginated response for job listings.
|
||||
// JobListResponse 是作业列表分页响应。
|
||||
type JobListResponse struct {
|
||||
Jobs []JobResponse `json:"jobs"` // 作业列表
|
||||
Total int `json:"total"` // 符合条件的作业总数
|
||||
@@ -67,13 +90,13 @@ type JobListResponse struct {
|
||||
PageSize int `json:"page_size"` // 每页条数
|
||||
}
|
||||
|
||||
// JobListQuery contains pagination parameters for active job listing.
|
||||
// JobListQuery 是活跃作业列表查询参数。
|
||||
type JobListQuery struct {
|
||||
Page int `form:"page,default=1" json:"page,omitempty"` // 页码 (从 1 开始)
|
||||
PageSize int `form:"page_size,default=20" json:"page_size,omitempty"` // 每页条数
|
||||
}
|
||||
|
||||
// JobHistoryQuery contains query parameters for job history.
|
||||
// JobHistoryQuery 是作业历史查询参数。
|
||||
type JobHistoryQuery struct {
|
||||
Users string `form:"users" json:"users,omitempty"` // 按用户名过滤 (逗号分隔)
|
||||
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"
|
||||
)
|
||||
|
||||
// Task status constants.
|
||||
// 任务状态常量
|
||||
const (
|
||||
TaskStatusSubmitted = "submitted"
|
||||
TaskStatusPreparing = "preparing"
|
||||
@@ -19,75 +19,155 @@ const (
|
||||
TaskStatusFailed = "failed"
|
||||
)
|
||||
|
||||
// Task step constants for step-level retry tracking.
|
||||
// 任务步骤常量,用于步骤级重试追踪
|
||||
const (
|
||||
TaskStepPreparing = "preparing"
|
||||
TaskStepDownloading = "downloading"
|
||||
TaskStepSubmitting = "submitting"
|
||||
)
|
||||
|
||||
// Task represents an HPC task submitted through the application framework.
|
||||
// Task 表示通过应用框架提交的 HPC 任务记录。
|
||||
type Task struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
TaskName string `gorm:"size:255" json:"task_name"`
|
||||
AppID int64 `json:"app_id"`
|
||||
AppName string `gorm:"size:255" json:"app_name"`
|
||||
Status string `json:"status"`
|
||||
CurrentStep string `json:"current_step"`
|
||||
RetryCount int `json:"retry_count"`
|
||||
Values json.RawMessage `gorm:"type:text" json:"values,omitempty"`
|
||||
InputFileIDs json.RawMessage `json:"input_file_ids" gorm:"column:input_file_ids;type:text"`
|
||||
Script string `json:"script,omitempty"`
|
||||
SlurmJobID *int32 `json:"slurm_job_id,omitempty"`
|
||||
WorkDir string `json:"work_dir,omitempty"`
|
||||
Partition string `json:"partition,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
UserID string `json:"user_id"`
|
||||
SubmittedAt time.Time `json:"submitted_at"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` // 主键
|
||||
TaskName string `gorm:"size:255" json:"task_name"` // 任务名称
|
||||
AppID int64 `json:"app_id"` // 所属应用ID
|
||||
AppName string `gorm:"size:255" json:"app_name"` // 应用名称
|
||||
Status string `json:"status"` // 任务状态
|
||||
CurrentStep string `json:"current_step"` // 当前执行步骤
|
||||
RetryCount int `json:"retry_count"` // 重试次数
|
||||
Values json.RawMessage `gorm:"type:text" json:"values,omitempty"` // 业务参数JSON
|
||||
InputFileIDs json.RawMessage `json:"input_file_ids" gorm:"column:input_file_ids;type:text"` // 输入文件ID列表JSON
|
||||
Script string `json:"script,omitempty"` // 渲染后的脚本内容
|
||||
SlurmJobID *int32 `json:"slurm_job_id,omitempty"` // Slurm作业ID
|
||||
WorkDir string `json:"work_dir,omitempty"` // 工作目录
|
||||
Partition string `json:"partition,omitempty"` // 提交到的分区(空字符串表示未设置)
|
||||
ErrorMessage string `json:"error_message,omitempty"` // 错误信息
|
||||
UserID string `json:"user_id"` // 提交用户ID
|
||||
SubmittedAt time.Time `json:"submitted_at"` // 提交时间
|
||||
StartedAt *time.Time `json:"started_at,omitempty"` // 开始运行时间
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"` // 完成时间
|
||||
CreatedAt time.Time `json:"created_at"` // 创建时间
|
||||
UpdatedAt time.Time `json:"updated_at"` // 更新时间
|
||||
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 {
|
||||
return "hpc_tasks"
|
||||
}
|
||||
|
||||
// CreateTaskRequest is the DTO for creating a new task.
|
||||
// CreateTaskRequest 是创建任务的 API 请求。
|
||||
type CreateTaskRequest struct {
|
||||
AppID int64 `json:"app_id" binding:"required"`
|
||||
TaskName string `json:"task_name"`
|
||||
Values map[string]string `json:"values"`
|
||||
InputFileIDs []int64 `json:"file_ids"`
|
||||
AppID int64 `json:"app_id" binding:"required"` // 所属应用ID(必填)
|
||||
TaskName string `json:"task_name"` // 任务名称
|
||||
Values map[string]string `json:"values"` // 脚本业务参数键值对
|
||||
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 {
|
||||
ID int64 `json:"id"`
|
||||
TaskName string `json:"task_name"`
|
||||
AppID int64 `json:"app_id"`
|
||||
AppName string `json:"app_name"`
|
||||
Status string `json:"status"`
|
||||
CurrentStep string `json:"current_step"`
|
||||
RetryCount int `json:"retry_count"`
|
||||
SlurmJobID *int32 `json:"slurm_job_id"`
|
||||
WorkDir string `json:"work_dir"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID int64 `json:"id"` // 主键
|
||||
TaskName string `json:"task_name"` // 任务名称
|
||||
AppID int64 `json:"app_id"` // 所属应用ID
|
||||
AppName string `json:"app_name"` // 应用名称
|
||||
Status string `json:"status"` // 任务状态
|
||||
CurrentStep string `json:"current_step"` // 当前执行步骤
|
||||
RetryCount int `json:"retry_count"` // 重试次数
|
||||
SlurmJobID *int32 `json:"slurm_job_id"` // Slurm作业ID
|
||||
WorkDir string `json:"work_dir"` // 工作目录
|
||||
ErrorMessage string `json:"error_message"` // 错误信息
|
||||
CreatedAt time.Time `json:"created_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 {
|
||||
Items []TaskResponse `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Items []TaskResponse `json:"items"` // 任务列表
|
||||
Total int64 `json:"total"` // 总数
|
||||
}
|
||||
|
||||
// TaskListQuery contains query parameters for listing tasks.
|
||||
// TaskListQuery 是任务列表查询参数。
|
||||
type TaskListQuery struct {
|
||||
Page int `form:"page" json:"page,omitempty"`
|
||||
PageSize int `form:"page_size" json:"page_size,omitempty"`
|
||||
Status string `form:"status" json:"status,omitempty"`
|
||||
Page int `form:"page" json:"page,omitempty"` // 页码
|
||||
PageSize int `form:"page_size" json:"page_size,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)
|
||||
jobID := int32(42)
|
||||
|
||||
cpus := int32(16)
|
||||
memPerNode := int64(32768)
|
||||
timeLimit := int32(60)
|
||||
task := Task{
|
||||
ID: 1,
|
||||
TaskName: "test task",
|
||||
AppID: 10,
|
||||
AppName: "GROMACS",
|
||||
Status: TaskStatusRunning,
|
||||
CurrentStep: TaskStepSubmitting,
|
||||
RetryCount: 1,
|
||||
Values: json.RawMessage(`{"np":"4"}`),
|
||||
InputFileIDs: json.RawMessage(`[1,2,3]`),
|
||||
Script: "#!/bin/bash",
|
||||
SlurmJobID: &jobID,
|
||||
WorkDir: "/data/work",
|
||||
Partition: "gpu",
|
||||
ErrorMessage: "",
|
||||
UserID: "user1",
|
||||
SubmittedAt: now,
|
||||
StartedAt: &now,
|
||||
FinishedAt: nil,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
ID: 1,
|
||||
TaskName: "test task",
|
||||
AppID: 10,
|
||||
AppName: "GROMACS",
|
||||
Status: TaskStatusRunning,
|
||||
CurrentStep: TaskStepSubmitting,
|
||||
RetryCount: 1,
|
||||
Values: json.RawMessage(`{"np":"4"}`),
|
||||
InputFileIDs: json.RawMessage(`[1,2,3]`),
|
||||
Script: "#!/bin/bash",
|
||||
SlurmJobID: &jobID,
|
||||
WorkDir: "/data/work",
|
||||
Partition: "gpu",
|
||||
ErrorMessage: "",
|
||||
UserID: "user1",
|
||||
SubmittedAt: now,
|
||||
StartedAt: &now,
|
||||
FinishedAt: nil,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Cpus: &cpus,
|
||||
MemoryPerNode: &memPerNode,
|
||||
TimeLimit: &timeLimit,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(task)
|
||||
@@ -80,6 +86,18 @@ func TestTask_JSONRoundTrip(t *testing.T) {
|
||||
if got.FinishedAt != nil {
|
||||
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) {
|
||||
@@ -102,3 +120,303 @@ func TestCreateTaskRequest_JSONBinding(t *testing.T) {
|
||||
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) {
|
||||
params := []model.ParameterSchema{{Name: "INPUT", Type: model.ParamTypeString}}
|
||||
values := map[string]string{"INPUT": "data.txt"}
|
||||
|
||||
@@ -26,6 +26,8 @@ func derefInt32(i *int32) int32 {
|
||||
return *i
|
||||
}
|
||||
|
||||
func int32Ptr(i int32) *int32 { return &i }
|
||||
|
||||
func derefInt64(i *int64) int64 {
|
||||
if i == nil {
|
||||
return 0
|
||||
@@ -33,6 +35,13 @@ func derefInt64(i *int64) int64 {
|
||||
return *i
|
||||
}
|
||||
|
||||
func derefInt32ToStr(i *int32) string {
|
||||
if i == nil {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatInt(int64(*i), 10)
|
||||
}
|
||||
|
||||
func uint32NoValString(v *slurm.Uint32NoVal) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
|
||||
@@ -16,28 +16,59 @@ import (
|
||||
|
||||
// FileService handles file listing, metadata, download, and deletion operations.
|
||||
type FileService struct {
|
||||
storage storage.ObjectStorage
|
||||
blobStore *store.BlobStore
|
||||
fileStore *store.FileStore
|
||||
bucket string
|
||||
db *gorm.DB
|
||||
logger *zap.Logger
|
||||
storage storage.ObjectStorage
|
||||
blobStore *store.BlobStore
|
||||
fileStore *store.FileStore
|
||||
folderStore *store.FolderStore
|
||||
bucket string
|
||||
db *gorm.DB
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// 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{
|
||||
storage: storage,
|
||||
blobStore: blobStore,
|
||||
fileStore: fileStore,
|
||||
bucket: bucket,
|
||||
db: db,
|
||||
logger: logger,
|
||||
storage: storage,
|
||||
blobStore: blobStore,
|
||||
fileStore: fileStore,
|
||||
folderStore: folderStore,
|
||||
bucket: bucket,
|
||||
db: db,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ListFiles returns a paginated list of files, optionally filtered by folder or search query.
|
||||
func (s *FileService) ListFiles(ctx context.Context, folderID *int64, page, pageSize int, search string) ([]model.FileResponse, int64, error) {
|
||||
// buildFileResponse creates a FileResponse from a File and FileBlob, including folder_path and user_id.
|
||||
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 total int64
|
||||
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)
|
||||
}
|
||||
|
||||
// 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))
|
||||
for _, f := range files {
|
||||
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)
|
||||
}
|
||||
|
||||
responses = append(responses, model.FileResponse{
|
||||
ID: f.ID,
|
||||
Name: f.Name,
|
||||
FolderID: f.FolderID,
|
||||
Size: blob.FileSize,
|
||||
MimeType: blob.MimeType,
|
||||
SHA256: f.BlobSHA256,
|
||||
CreatedAt: f.CreatedAt,
|
||||
UpdatedAt: f.UpdatedAt,
|
||||
})
|
||||
responses = append(responses, s.buildFileResponse(ctx, f, *blob))
|
||||
}
|
||||
|
||||
return responses, total, nil
|
||||
@@ -97,6 +130,16 @@ func (s *FileService) GetFileMetadata(ctx context.Context, fileID int64) (*model
|
||||
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.
|
||||
// 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) {
|
||||
|
||||
@@ -78,7 +78,7 @@ func setupFileTestDB(t *testing.T) *gorm.DB {
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
return db
|
||||
@@ -88,7 +88,7 @@ func setupFileService(t *testing.T) (*FileService, *mockFileStorage, *gorm.DB) {
|
||||
t.Helper()
|
||||
db := setupFileTestDB(t)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ func createTestFile(t *testing.T, db *gorm.DB, name, blobSHA256 string, folderID
|
||||
func TestListFiles_Empty(t *testing.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 {
|
||||
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, "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 {
|
||||
t.Fatalf("ListFiles: %v", err)
|
||||
}
|
||||
@@ -173,7 +173,7 @@ func TestListFiles_Search(t *testing.T) {
|
||||
createTestFile(t, db, "document.pdf", "sha256other", nil)
|
||||
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 {
|
||||
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, "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 {
|
||||
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)
|
||||
}
|
||||
|
||||
rootFiles, rootTotal, err := svc.ListFiles(context.Background(), nil, 1, 10, "")
|
||||
rootFiles, rootTotal, err := svc.ListFiles(context.Background(), nil, nil, 1, 10, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListFiles root: %v", err)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gcy_hpc_server/internal/model"
|
||||
@@ -49,6 +50,73 @@ func (s *JobService) SubmitJob(ctx context.Context, req *model.SubmitJobRequest)
|
||||
"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{
|
||||
Script: &script,
|
||||
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) {
|
||||
client, cleanup := mockJobServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
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:
|
||||
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:
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
// Only tokens defined in the schema are replaced. Replacement is done longest-name-first
|
||||
// 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 {
|
||||
sorted := make([]model.ParameterSchema, len(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)
|
||||
})
|
||||
|
||||
// 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
|
||||
for _, p := range sorted {
|
||||
val, ok := values[p.Name]
|
||||
@@ -89,8 +97,13 @@ func RenderScript(template string, params []model.ParameterSchema, values map[st
|
||||
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
|
||||
}
|
||||
|
||||
@@ -122,13 +122,40 @@ func (s *TaskService) CreateTask(ctx context.Context, req *model.CreateTaskReque
|
||||
|
||||
// 8. Create task record
|
||||
task := &model.Task{
|
||||
TaskName: taskName,
|
||||
AppID: app.ID,
|
||||
AppName: app.Name,
|
||||
Status: model.TaskStatusSubmitted,
|
||||
Values: valuesJSON,
|
||||
InputFileIDs: fileIDsJSON,
|
||||
SubmittedAt: time.Now(),
|
||||
TaskName: taskName,
|
||||
AppID: app.ID,
|
||||
AppName: app.Name,
|
||||
Status: model.TaskStatusSubmitted,
|
||||
Values: valuesJSON,
|
||||
InputFileIDs: fileIDsJSON,
|
||||
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)
|
||||
@@ -261,13 +288,104 @@ func (s *TaskService) ProcessTask(ctx context.Context, taskID int64) 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
|
||||
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
|
||||
jobResp, err := s.jobSvc.SubmitJob(ctx, &model.SubmitJobRequest{
|
||||
Script: rendered,
|
||||
WorkDir: workDir,
|
||||
Script: rendered,
|
||||
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 {
|
||||
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 (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gcy_hpc_server/internal/model"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
appSvc := service.NewApplicationService(appStore, jobSvc, workDir, logger, taskSvc)
|
||||
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
|
||||
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