diff --git a/.planning/STATE.md b/.planning/STATE.md index ae9586b6..ff123d17 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -4,8 +4,8 @@ milestone: v4.2.0 milestone_name: 容器合并 · SQLite 迁移 · 配置统一 status: Awaiting next milestone stopped_at: Phase 58 context gathered -last_updated: "2026-06-01T08:05:26.830Z" -last_activity: 2026-06-01 — Milestone v4.2.0 completed and archived +last_updated: "2026-06-02T00:00:00.000Z" +last_activity: 2026-06-02 — Completed quick task 260602: 规则导入导出按钮 progress: total_phases: 5 completed_phases: 1 @@ -28,7 +28,7 @@ See: .planning/PROJECT.md (updated 2026-05-14) Phase: Milestone v4.2.0 complete Plan: — Status: Awaiting next milestone -Last activity: 2026-06-01 — Milestone v4.2.0 completed and archived +Last activity: 2026-06-02 — Completed quick task 260602: 规则导入导出按钮 ## Accumulated Context @@ -78,6 +78,7 @@ v3.6 关键技术决策(已落地,可作为后续里程碑参考): | 260513-fjd | 修复 SubnetThirdOctet 碰撞测试阈值(10 → 40,匹配生日悖论期望) | 2026-05-13 | 0def841 | [260513-fjd-subnetthirdoctet](./quick/260513-fjd-subnetthirdoctet/) | | 260513-gii | 修复 UpsertHost SQL 占位符不匹配(移除孤立的 $13,POST /v1/admin/hosts 500) | 2026-05-13 | 04636fd | [260513-gii-upserthost-sql](./quick/260513-gii-upserthost-sql/) | | 260513-kru | 修复 worker netns 获取失败(增加重试 + 容器状态检查 + 延迟) | 2026-05-13 | f1c3a35 | [260513-kru-worker-netns](./quick/260513-kru-worker-netns/) | +| 260602 | 规则导入导出按钮 | 2026-06-02 | pending | [260602-rules-import-export](./quick/260602-rules-import-export/) | ### Roadmap Evolution diff --git a/.planning/quick/260602-rules-import-export/260602-PLAN.md b/.planning/quick/260602-rules-import-export/260602-PLAN.md new file mode 100644 index 00000000..f0663503 --- /dev/null +++ b/.planning/quick/260602-rules-import-export/260602-PLAN.md @@ -0,0 +1,23 @@ +--- +status: complete +--- + +# Quick Task 260602: 规则导入导出按钮 + +## 目标 + +在主机详情页代理白名单的规则区域,在「添加规则」按钮后新增「规则导出」和「规则导入」按钮。 + +## 任务 + +1. 在 `web/admin/src/components/bypass/custom-rules-table.tsx` 中新增导出/导入交互。 +2. 导出当前展示规则为本地 JSON 文件。 +3. 导入 JSON 文件并通过现有创建规则 API 追加为自定义规则。 +4. 补充 `custom-rules-table` 相关单元测试。 + +## 验收 + +- 规则区域按钮顺序为「添加规则」「规则导出」「规则导入」。 +- 导出文件为 JSON,包含 `version`、`exported_at`、`rules`。 +- 导入支持上述对象格式,也支持直接上传规则数组。 +- 导入成功后提示点击「应用」生效。 diff --git a/.planning/quick/260602-rules-import-export/260602-SUMMARY.md b/.planning/quick/260602-rules-import-export/260602-SUMMARY.md new file mode 100644 index 00000000..fbc80d17 --- /dev/null +++ b/.planning/quick/260602-rules-import-export/260602-SUMMARY.md @@ -0,0 +1,28 @@ +--- +status: complete +--- + +# Quick Task 260602: 规则导入导出按钮 - Summary + +## 完成内容 + +- 在主机详情页代理白名单规则区新增「规则导出」和「规则导入」按钮。 +- 导出当前规则为本地 JSON 文件,格式包含 `version`、`exported_at`、`rules`。 +- 导入支持上传导出文件或直接上传规则数组,并通过现有创建规则 API 追加为自定义规则。 +- 导入规则使用 `confirm_risky: true`,成功后提示用户点击「应用」生效。 +- 补充按钮展示和 JSON 上传导入的单元测试。 + +## 修改文件 + +- `web/admin/src/components/bypass/custom-rules-table.tsx` +- `web/admin/src/components/bypass/__tests__/custom-rules-table.test.tsx` +- `.planning/STATE.md` + +## 验证 + +- `corepack pnpm --dir web/admin typecheck`:未通过运行,原因是 `web/admin/node_modules` 缺失,`tsc` 命令不可用。 +- `corepack pnpm --dir web/admin test:unit -- src/components/bypass/__tests__/custom-rules-table.test.tsx`:未通过运行,原因是 `web/admin/node_modules` 缺失,`vitest` 命令不可用。 + +## 后续 + +安装前端依赖后重新运行类型检查和相关单元测试。 diff --git a/internal/agentapi/contracts.go b/internal/agentapi/contracts.go index ee68d38b..cecf528b 100644 --- a/internal/agentapi/contracts.go +++ b/internal/agentapi/contracts.go @@ -53,6 +53,7 @@ type HostActionRequest struct { Hostname string `json:"hostname"` MemoryLimitMB int `json:"memory_limit_mb,omitempty"` CPULimit float64 `json:"cpu_limit,omitempty"` + PidsLimit *int `json:"pids_limit,omitempty"` Username string `json:"username,omitempty"` EntryPassword string `json:"entry_password,omitempty"` SSHPublicKey string `json:"ssh_public_key,omitempty"` diff --git a/internal/controlplane/http/admin_hosts.go b/internal/controlplane/http/admin_hosts.go index 7d0f002e..9347cb7a 100644 --- a/internal/controlplane/http/admin_hosts.go +++ b/internal/controlplane/http/admin_hosts.go @@ -40,7 +40,7 @@ type AdminHostStore interface { ListRunningHosts(ctx context.Context) ([]repository.Host, error) GetHostWithClaudeAccount(ctx context.Context, hostID string) (repository.HostWithClaudeAccount, error) // Phase 33 D-22 UpdateHostMounts(ctx context.Context, hostID string, mounts repository.HostMounts) error - UpdateHostResources(ctx context.Context, hostID string, memoryLimitMB *int, cpuLimit *float64) error + UpdateHostResources(ctx context.Context, hostID string, memoryLimitMB *int, cpuLimit *float64, pidsLimit *int) error } type AdminHostsHandler struct { @@ -153,6 +153,7 @@ func (h *AdminHostsHandler) Create() nethttp.Handler { Timezone string `json:"timezone"` MemoryLimitMB *int `json:"memory_limit_mb"` CPULimit *float64 `json:"cpu_limit"` + PidsLimit *int `json:"pids_limit"` HostMounts repository.HostMounts `json:"host_mounts"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.UserID == "" { @@ -164,6 +165,19 @@ func (h *AdminHostsHandler) Create() nethttp.Handler { return } + if err := validateMemoryLimit(body.MemoryLimitMB); err != nil { + writeJSON(w, nethttp.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if err := validateCPULimit(body.CPULimit); err != nil { + writeJSON(w, nethttp.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if err := validatePidsLimit(body.PidsLimit); err != nil { + writeJSON(w, nethttp.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + timezone := body.Timezone if timezone == "" { timezone = "America/Los_Angeles" @@ -226,6 +240,7 @@ func (h *AdminHostsHandler) Create() nethttp.Handler { Hostname: hostname, MemoryLimitMB: resolveMemory(body.MemoryLimitMB), CPULimit: resolveCPU(body.CPULimit), + PidsLimit: resolvePidsLimit(body.PidsLimit), HostMounts: expandHostMountSources(body.HostMounts), }) if err == nil { @@ -1068,7 +1083,7 @@ func (h *AdminHostsHandler) UpdateMounts() nethttp.Handler { hid := hostID if _, err := h.events.RecordEvent(r.Context(), repository.RecordEventParams{ HostID: &hid, Level: "info", Type: "admin.host.update_mounts", - Message: "管理员更新主机挂载配置", + Message: "管理员更新主机挂载配置", Metadata: map[string]any{"operator": "admin", "mount_count": len(body.Mounts)}, }); err != nil { h.logger.Error("record event failed", "type", "admin.host.update_mounts", "error", err) @@ -1086,33 +1101,25 @@ func (h *AdminHostsHandler) PatchResources() nethttp.Handler { var body struct { MemoryLimitMB *int `json:"memory_limit_mb"` CPULimit *float64 `json:"cpu_limit"` + PidsLimit *int `json:"pids_limit"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeJSON(w, nethttp.StatusBadRequest, map[string]string{"error": "invalid request body"}) return } - if body.MemoryLimitMB != nil && *body.MemoryLimitMB > 0 { - if *body.MemoryLimitMB < 128 { - writeJSON(w, nethttp.StatusBadRequest, map[string]string{"error": "memory_limit_mb 不能小于 128 MB"}) - return - } - if *body.MemoryLimitMB > 262144 { - writeJSON(w, nethttp.StatusBadRequest, map[string]string{"error": "memory_limit_mb 不能大于 262144 MB (256 GB)"}) - return - } + if err := validateMemoryLimit(body.MemoryLimitMB); err != nil { + writeJSON(w, nethttp.StatusBadRequest, map[string]string{"error": err.Error()}) + return } - if body.CPULimit != nil && *body.CPULimit > 0 { - if *body.CPULimit < 0.1 { - writeJSON(w, nethttp.StatusBadRequest, map[string]string{"error": "cpu_limit 不能小于 0.1 核"}) - return - } - if *body.CPULimit > 64 { - writeJSON(w, nethttp.StatusBadRequest, map[string]string{"error": "cpu_limit 不能大于 64 核"}) - return - } + if err := validateCPULimit(body.CPULimit); err != nil { + writeJSON(w, nethttp.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if err := validatePidsLimit(body.PidsLimit); err != nil { + writeJSON(w, nethttp.StatusBadRequest, map[string]string{"error": err.Error()}) + return } - host, err := h.store.GetHost(r.Context(), hostID) if err != nil { @@ -1125,14 +1132,18 @@ func (h *AdminHostsHandler) PatchResources() nethttp.Handler { return } - if host.Status != "stopped" { - writeJSON(w, nethttp.StatusConflict, map[string]string{"error": "主机正在运行中,请先停止"}) - return + if host.Status == "running" || host.Status == "stopped" { + if err := dockerUpdateHostResources(r.Context(), "cloudproxy-"+hostID, body.MemoryLimitMB, body.CPULimit, body.PidsLimit); err != nil { + h.logger.Error("docker update resources failed", "host_id", hostID, "error", err) + writeJSON(w, nethttp.StatusBadGateway, map[string]string{"error": "docker update resources failed"}) + return + } } if err := h.store.UpdateHostResources(r.Context(), hostID, resolveResourceMemory(body.MemoryLimitMB), resolveResourceCPU(body.CPULimit), + resolveResourcePidsLimit(body.PidsLimit), ); err != nil { h.logger.Error("update host resources failed", "host_id", hostID, "error", err) writeJSON(w, nethttp.StatusInternalServerError, map[string]string{"error": "update resources failed"}) @@ -1143,7 +1154,7 @@ func (h *AdminHostsHandler) PatchResources() nethttp.Handler { hid := hostID if _, err := h.events.RecordEvent(r.Context(), repository.RecordEventParams{ HostID: &hid, Level: "info", Type: "admin.host.resources_updated", - Message: "管理员更新主机资源限制", + Message: "管理员更新主机资源限制", Metadata: map[string]any{"operator": "admin"}, }); err != nil { h.logger.Error("record event failed", "type", "admin.host.resources_updated", "error", err) @@ -1154,7 +1165,46 @@ func (h *AdminHostsHandler) PatchResources() nethttp.Handler { }) } +func dockerResourcePidsLimitValue(limit int) string { + if limit == 0 { + return "-1" + } + return strconv.Itoa(limit) +} + +var dockerUpdateHostResources = func(ctx context.Context, containerName string, memoryLimitMB *int, cpuLimit *float64, pidsLimit *int) error { + args := []string{"update"} + if memoryLimitMB != nil { + if *memoryLimitMB == 0 { + args = append(args, "--memory", "0") + } else { + args = append(args, "--memory", fmt.Sprintf("%dm", *memoryLimitMB)) + } + } + if cpuLimit != nil { + if *cpuLimit == 0 { + args = append(args, "--cpus", "0") + } else { + args = append(args, "--cpus", fmt.Sprintf("%.1f", *cpuLimit)) + } + } + if pidsLimit != nil { + args = append(args, "--pids-limit", dockerResourcePidsLimitValue(*pidsLimit)) + } + if len(args) == 1 { + return nil + } + args = append(args, containerName) + cmd := exec.CommandContext(ctx, "docker", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("docker update: %w (%s)", err, strings.TrimSpace(string(output))) + } + return nil +} + // syncContainerPassword updates the Linux user password inside a running container via docker exec. + // Exposed as a package-level var so unit tests can inject a fake implementation (Phase 29.1). var syncContainerPassword = func(containerName, user, password string) error { cmd := exec.CommandContext(context.Background(), "docker", "exec", "-i", containerName, @@ -1240,9 +1290,48 @@ func (h *AdminHostsHandler) GetLogs() nethttp.Handler { }) } -func intPtr(v int) *int { return &v } +func intPtr(v int) *int { return &v } func floatPtr(v float64) *float64 { return &v } +func validateMemoryLimit(mb *int) error { + if mb == nil || *mb == 0 { + return nil + } + if *mb < 128 { + return fmt.Errorf("memory_limit_mb 不能小于 128 MB") + } + if *mb > 262144 { + return fmt.Errorf("memory_limit_mb 不能大于 262144 MB (256 GB)") + } + return nil +} + +func validateCPULimit(cpu *float64) error { + if cpu == nil || *cpu == 0 { + return nil + } + if *cpu < 0.1 { + return fmt.Errorf("cpu_limit 不能小于 0.1 核") + } + if *cpu > 64 { + return fmt.Errorf("cpu_limit 不能大于 64 核") + } + return nil +} + +func validatePidsLimit(pids *int) error { + if pids == nil || *pids == 0 { + return nil + } + if *pids < 64 { + return fmt.Errorf("pids_limit 不能小于 64") + } + if *pids > 131072 { + return fmt.Errorf("pids_limit 不能大于 131072") + } + return nil +} + // resolveMemory 三态解析:nil → 默认值(4096) / 0 → 无限制 / >0 → 传值。 func resolveMemory(mb *int) *int { if mb == nil { @@ -1261,8 +1350,16 @@ func resolveCPU(cpu *float64) *float64 { return cpu } -// resolveResourceMemory PATCH 端点的三态解析:nil=不修改,0→NULL(无限制),>0→传值。 -// 注意:0 不能转 nil,SQL 层需要区分"不传"和"传 0 设无限制"。 +// resolvePidsLimit 三态解析:nil → 默认值(1024) / 0 → 无限制 / >0 → 传值。 +func resolvePidsLimit(pids *int) *int { + if pids == nil { + def := 1024 + return &def + } + return pids +} + +// resolveResourceMemory PATCH 端点的三态解析:nil=不修改,0=无限制,>0=传值。 func resolveResourceMemory(mb *int) *int { if mb == nil { return nil @@ -1270,7 +1367,7 @@ func resolveResourceMemory(mb *int) *int { return mb } -// resolveResourceCPU PATCH 端点的三态解析:nil=不修改,0→NULL(无限制),>0→传值。 +// resolveResourceCPU PATCH 端点的三态解析:nil=不修改,0=无限制,>0=传值。 func resolveResourceCPU(cpu *float64) *float64 { if cpu == nil { return nil @@ -1278,4 +1375,10 @@ func resolveResourceCPU(cpu *float64) *float64 { return cpu } - +// resolveResourcePidsLimit PATCH 端点的三态解析:nil=不修改,0=无限制,>0=传值。 +func resolveResourcePidsLimit(pids *int) *int { + if pids == nil { + return nil + } + return pids +} diff --git a/internal/controlplane/http/admin_hosts_test.go b/internal/controlplane/http/admin_hosts_test.go index e98972d9..5cd20f86 100644 --- a/internal/controlplane/http/admin_hosts_test.go +++ b/internal/controlplane/http/admin_hosts_test.go @@ -7,6 +7,7 @@ import ( "log/slog" nethttp "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -17,16 +18,19 @@ import ( ) type stubHostStore struct { - hosts []repository.HostWithUsername - detail repository.HostDetail - host repository.Host - runningHosts []repository.Host - hostWithCA repository.HostWithClaudeAccount - hostWithCAErr error - listErr error - detailErr error - hostErr error - runningErr error + hosts []repository.HostWithUsername + detail repository.HostDetail + host repository.Host + runningHosts []repository.Host + hostWithCA repository.HostWithClaudeAccount + hostWithCAErr error + listErr error + detailErr error + hostErr error + runningErr error + updatedMemoryMB *int + updatedCPU *float64 + updatedPids *int } func (s *stubHostStore) ListHostsWithUsername(_ context.Context) ([]repository.HostWithUsername, error) { @@ -73,11 +77,13 @@ func (s *stubHostStore) UpdateHostMounts(_ context.Context, _ string, _ reposito return nil } -func (s *stubHostStore) UpdateHostResources(_ context.Context, _ string, _ *int, _ *float64) error { +func (s *stubHostStore) UpdateHostResources(_ context.Context, _ string, memoryLimitMB *int, cpuLimit *float64, pidsLimit *int) error { + s.updatedMemoryMB = memoryLimitMB + s.updatedCPU = cpuLimit + s.updatedPids = pidsLimit return nil } - func TestAdminHostsHandler(t *testing.T) { now := time.Now().Truncate(time.Second) sampleHost := repository.HostWithUsername{ @@ -366,3 +372,56 @@ func TestAdminHostList_DoesNotIncludePersistentVolumeName(t *testing.T) { t.Errorf("OOS-A19: list endpoint must NOT include persistent_volume_name; got %v", first) } } + +func TestPatchResources_RunningHostAppliesDockerUpdateAndPersistsPidsLimit(t *testing.T) { + orig := dockerUpdateHostResources + var dockerContainer string + var dockerMemory *int + var dockerCPU *float64 + var dockerPids *int + dockerUpdateHostResources = func(_ context.Context, containerName string, memoryLimitMB *int, cpuLimit *float64, pidsLimit *int) error { + dockerContainer = containerName + dockerMemory = memoryLimitMB + dockerCPU = cpuLimit + dockerPids = pidsLimit + return nil + } + t.Cleanup(func() { dockerUpdateHostResources = orig }) + + store := &stubHostStore{host: repository.Host{ID: "h-1", Status: "running"}} + router := adminTestRouter(t, Dependencies{ + Logger: slog.Default(), + AdminHosts: store, + HostActions: &stubQueuer{}, + EventRecorder: &stubEventRecorder{}, + }) + srv := httptest.NewServer(router) + defer srv.Close() + + req, _ := nethttp.NewRequest("PATCH", srv.URL+"/v1/admin/hosts/h-1/resources", strings.NewReader(`{"pids_limit":2048}`)) + req.Header.Set("Authorization", "Bearer "+validAdminToken(t)) + req.Header.Set("Content-Type", "application/json") + resp, err := nethttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + var body map[string]any + _ = json.NewDecoder(resp.Body).Decode(&body) + t.Fatalf("status=%d, body=%v", resp.StatusCode, body) + } + if dockerContainer != "cloudproxy-h-1" { + t.Fatalf("docker container=%q, want cloudproxy-h-1", dockerContainer) + } + if dockerMemory != nil || dockerCPU != nil { + t.Fatalf("docker update should only receive pids limit, memory=%v cpu=%v", dockerMemory, dockerCPU) + } + if dockerPids == nil || *dockerPids != 2048 { + t.Fatalf("docker pids=%v, want 2048", dockerPids) + } + if store.updatedPids == nil || *store.updatedPids != 2048 { + t.Fatalf("stored pids=%v, want 2048", store.updatedPids) + } +} diff --git a/internal/runtime/runtime_service.go b/internal/runtime/runtime_service.go index fa156e1d..f8f4a88c 100644 --- a/internal/runtime/runtime_service.go +++ b/internal/runtime/runtime_service.go @@ -63,7 +63,7 @@ type QueueHostActionRepo interface { } type Service struct { - repo QueueHostActionRepo + repo QueueHostActionRepo dispatcher interface { Dispatch(context.Context, agentapi.HostActionRequest) (agentapi.HostActionResponse, error) } @@ -176,10 +176,11 @@ func (s *Service) QueueHostAction(ctx context.Context, hostID string, action age "cloud-cli-proxy.host_id": host.ID, "cloud-cli-proxy.slot_key": firstNonEmpty(host.SlotKey, defaultManagedUserSlotKey), }, - Timezone: host.Timezone, - Hostname: host.Hostname, - MemoryLimitMB: ptrToInt(host.MemoryLimitMB), - CPULimit: ptrToFloat(host.CPULimit), + Timezone: host.Timezone, + Hostname: host.Hostname, + MemoryLimitMB: ptrToInt(host.MemoryLimitMB), + CPULimit: ptrToFloat(host.CPULimit), + PidsLimit: host.PidsLimit, Username: owner.Username, EntryPassword: owner.EntryPassword, SSHPublicKey: "", @@ -329,7 +330,6 @@ func firstNonEmpty(values ...string) string { return "" } - func ptrToInt(p *int) int { if p == nil { return 0 diff --git a/internal/runtime/tasks/worker.go b/internal/runtime/tasks/worker.go index 42b7e514..f74c0b5b 100644 --- a/internal/runtime/tasks/worker.go +++ b/internal/runtime/tasks/worker.go @@ -217,6 +217,16 @@ func actionToHostStatus(action agentapi.HostAction) string { } } +func dockerPidsLimitValue(limit *int) string { + if limit == nil { + return "1024" + } + if *limit == 0 { + return "-1" + } + return fmt.Sprintf("%d", *limit) +} + func (w *Worker) buildCreateArgs(request agentapi.HostActionRequest, containerName, hostname string, egressCfg *network.EgressConfig) ([]string, error) { homeDir := firstNonEmpty(request.HomeDir, hostHomeDir(request.HostID)) @@ -226,8 +236,8 @@ func (w *Worker) buildCreateArgs(request agentapi.HostActionRequest, containerNa "--network", "bridge", // unless-stopped:容器进程崩溃时 docker 自动重启,docker daemon 重启后也会恢复运行。 "--restart", "unless-stopped", - // 防止 fork 炸弹耗尽宿主机 pid;防止容器日志撑满磁盘。 - "--pids-limit", "512", + // 防止 fork 炸弹耗尽宿主机 pid;0 表示不限制。 + "--pids-limit", dockerPidsLimitValue(request.PidsLimit), "--log-opt", "max-size=10m", "--log-opt", "max-file=3", // Phase 51 QUAL-06 / 闭 Phase 49 GAP-1:worker capability 收紧。 @@ -1211,14 +1221,14 @@ func (t *pullProgressTracker) maybeReport() { layersCopy[k] = v } broadcast.BroadcastJSON("tasks", map[string]any{ - "topic": "tasks", - "action": "progress", - "id": t.taskID, + "topic": "tasks", + "action": "progress", + "id": t.taskID, "payload": map[string]any{ - "percent": percent, - "message": message, - "host_id": t.hostID, - "layers": layersCopy, + "percent": percent, + "message": message, + "host_id": t.hostID, + "layers": layersCopy, }, }) } diff --git a/internal/runtime/tasks/worker_pids_test.go b/internal/runtime/tasks/worker_pids_test.go new file mode 100644 index 00000000..4a298138 --- /dev/null +++ b/internal/runtime/tasks/worker_pids_test.go @@ -0,0 +1,39 @@ +package tasks + +import ( + "strings" + "testing" +) + +func TestBuildCreateArgs_PidsLimit(t *testing.T) { + w := &Worker{} + + tests := []struct { + name string + set func(*int) *int + want string + }{ + {name: "default", set: func(_ *int) *int { return nil }, want: "1024"}, + {name: "unlimited", set: func(v *int) *int { *v = 0; return v }, want: "-1"}, + {name: "custom", set: func(v *int) *int { *v = 2048; return v }, want: "2048"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := minimalCreateHostRequest("h-" + tt.name) + var value int + req.PidsLimit = tt.set(&value) + + args, err := w.buildCreateArgs(req, "c1", "c1", nil) + if err != nil { + t.Fatalf("buildCreateArgs: %v", err) + } + + joined := strings.Join(args, " ") + want := "--pids-limit " + tt.want + if !strings.Contains(joined, want) { + t.Fatalf("missing %q in args: %v", want, args) + } + }) + } +} diff --git a/internal/store/migrations/0002_add_host_pids_limit.sql b/internal/store/migrations/0002_add_host_pids_limit.sql new file mode 100644 index 00000000..67c02105 --- /dev/null +++ b/internal/store/migrations/0002_add_host_pids_limit.sql @@ -0,0 +1,6 @@ +-- 0002_add_host_pids_limit.sql +-- 为主机资源限制增加 Docker pids 上限,默认 1024。 + +ALTER TABLE hosts ADD COLUMN pids_limit INTEGER NOT NULL DEFAULT 1024; + +UPDATE hosts SET pids_limit = 1024 WHERE pids_limit IS NULL; diff --git a/internal/store/repository/models.go b/internal/store/repository/models.go index 4d835e20..faf46afe 100644 --- a/internal/store/repository/models.go +++ b/internal/store/repository/models.go @@ -32,20 +32,21 @@ type User struct { } type Host struct { - ID string `json:"id"` - UserID string `json:"user_id"` - Status string `json:"status"` - ShortID string `json:"short_id"` - TemplateImageRef string `json:"template_image_ref"` - HomeVolumeName string `json:"home_volume_name"` - SlotKey string `json:"slot_key"` - Timezone string `json:"timezone"` - Hostname string `json:"hostname"` - MemoryLimitMB *int `json:"memory_limit_mb"` - CPULimit *float64 `json:"cpu_limit"` + ID string `json:"id"` + UserID string `json:"user_id"` + Status string `json:"status"` + ShortID string `json:"short_id"` + TemplateImageRef string `json:"template_image_ref"` + HomeVolumeName string `json:"home_volume_name"` + SlotKey string `json:"slot_key"` + Timezone string `json:"timezone"` + Hostname string `json:"hostname"` + MemoryLimitMB *int `json:"memory_limit_mb"` + CPULimit *float64 `json:"cpu_limit"` + PidsLimit *int `json:"pids_limit"` HostMounts HostMounts `json:"host_mounts"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type HostMount struct { @@ -111,15 +112,15 @@ type Event struct { } type EgressIP struct { - ID string `json:"id"` - Label string `json:"label"` - IPAddress string `json:"ip_address"` - DetectedIPAddress *string `json:"detected_ip_address,omitempty"` - Provider string `json:"provider"` - Status string `json:"status"` - ProxyConfig json.RawMessage `json:"proxy_config,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Label string `json:"label"` + IPAddress string `json:"ip_address"` + DetectedIPAddress *string `json:"detected_ip_address,omitempty"` + Provider string `json:"provider"` + Status string `json:"status"` + ProxyConfig json.RawMessage `json:"proxy_config,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type DashboardStats struct { @@ -158,8 +159,8 @@ type UpdateEgressIPParams struct { } type HostDetail struct { - Host Host `json:"host"` - User User `json:"user"` + Host Host `json:"host"` + User User `json:"user"` Bindings []BindingWithIP `json:"bindings"` } @@ -171,11 +172,11 @@ type BindingWithIP struct { type HostWithUsername struct { Host - Username string `json:"username"` - EgressIPLabel *string `json:"egress_ip_label,omitempty"` - EgressIPAddr *string `json:"egress_ip_address,omitempty"` - EgressIPDetectedAddr *string `json:"egress_ip_detected_address,omitempty"` - DockerStatus string `json:"docker_status,omitempty"` + Username string `json:"username"` + EgressIPLabel *string `json:"egress_ip_label,omitempty"` + EgressIPAddr *string `json:"egress_ip_address,omitempty"` + EgressIPDetectedAddr *string `json:"egress_ip_detected_address,omitempty"` + DockerStatus string `json:"docker_status,omitempty"` } // HostWithClaudeAccount D-23:纯 DB JOIN,避免在 detail handler 引入 docker exec。 @@ -277,6 +278,7 @@ type UpsertHostParams struct { Hostname string MemoryLimitMB *int CPULimit *float64 + PidsLimit *int HostMounts HostMounts } diff --git a/internal/store/repository/queries.go b/internal/store/repository/queries.go index ccf6ae52..04932815 100644 --- a/internal/store/repository/queries.go +++ b/internal/store/repository/queries.go @@ -114,7 +114,7 @@ func (r *Repository) UpdateUserPassword(ctx context.Context, userID string, pass // listHostsByUserIDSQL 将 SQL 文本提升为包级常量,方便仓储层回归测试断言。 const listHostsByUserIDSQL = ` - SELECT id, user_id, status, COALESCE(short_id, ''), template_image_ref, home_volume_name, slot_key, timezone, hostname, memory_limit_mb, cpu_limit, host_mounts, created_at, updated_at + SELECT id, user_id, status, COALESCE(short_id, ''), template_image_ref, home_volume_name, slot_key, timezone, hostname, memory_limit_mb, cpu_limit, pids_limit, host_mounts, created_at, updated_at FROM hosts WHERE user_id = ? ORDER BY created_at ASC ` @@ -133,7 +133,7 @@ func (r *Repository) ListHostsByUserID(ctx context.Context, userID string) ([]Ho if err := rows.Scan( &item.ID, &item.UserID, &item.Status, &item.ShortID, &item.TemplateImageRef, &item.HomeVolumeName, &item.SlotKey, &item.Timezone, &item.Hostname, - &item.MemoryLimitMB, &item.CPULimit, + &item.MemoryLimitMB, &item.CPULimit, &item.PidsLimit, &rawMounts, &item.CreatedAt, &item.UpdatedAt, ); err != nil { @@ -194,7 +194,7 @@ func (r *Repository) GetDashboardStats(ctx context.Context) (DashboardStats, err // listHostsSQL 将 SQL 文本提升为包级常量,方便仓储层回归测试断言。 const listHostsSQL = ` - SELECT id, user_id, status, COALESCE(short_id, ''), template_image_ref, home_volume_name, slot_key, timezone, hostname, memory_limit_mb, cpu_limit, host_mounts, created_at, updated_at + SELECT id, user_id, status, COALESCE(short_id, ''), template_image_ref, home_volume_name, slot_key, timezone, hostname, memory_limit_mb, cpu_limit, pids_limit, host_mounts, created_at, updated_at FROM hosts ORDER BY updated_at DESC ` @@ -222,7 +222,7 @@ func (r *Repository) ListHosts(ctx context.Context) ([]Host, error) { &item.Hostname, &item.MemoryLimitMB, &item.CPULimit, - + &item.PidsLimit, &rawMounts, &item.CreatedAt, &item.UpdatedAt, @@ -265,7 +265,7 @@ func (r *Repository) GetPrimaryHostByUserID(ctx context.Context, userID string) var item Host var rawMounts json.RawMessage if err := r.db.QueryRowContext(ctx, ` - SELECT id, user_id, status, COALESCE(short_id, ''), template_image_ref, home_volume_name, slot_key, timezone, hostname, memory_limit_mb, cpu_limit, host_mounts, created_at, updated_at + SELECT id, user_id, status, COALESCE(short_id, ''), template_image_ref, home_volume_name, slot_key, timezone, hostname, memory_limit_mb, cpu_limit, pids_limit, host_mounts, created_at, updated_at FROM hosts WHERE user_id = ? AND slot_key = 'primary' LIMIT 1 @@ -281,7 +281,7 @@ func (r *Repository) GetPrimaryHostByUserID(ctx context.Context, userID string) &item.Hostname, &item.MemoryLimitMB, &item.CPULimit, - + &item.PidsLimit, &rawMounts, &item.CreatedAt, &item.UpdatedAt, @@ -367,8 +367,8 @@ func (r *Repository) UpsertHost(ctx context.Context, params UpsertHostParams) (H var item Host var rawMounts json.RawMessage if err := r.db.QueryRowContext(ctx, ` - INSERT INTO hosts (id, user_id, status, short_id, template_image_ref, home_volume_name, slot_key, timezone, hostname, memory_limit_mb, cpu_limit, host_mounts) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO hosts (id, user_id, status, short_id, template_image_ref, home_volume_name, slot_key, timezone, hostname, memory_limit_mb, cpu_limit, pids_limit, host_mounts) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (user_id, slot_key) DO UPDATE SET status = excluded.status, @@ -378,11 +378,12 @@ func (r *Repository) UpsertHost(ctx context.Context, params UpsertHostParams) (H hostname = excluded.hostname, memory_limit_mb = excluded.memory_limit_mb, cpu_limit = excluded.cpu_limit, + pids_limit = excluded.pids_limit, host_mounts = excluded.host_mounts, updated_at = CURRENT_TIMESTAMP RETURNING id, user_id, status, COALESCE(short_id, ''), template_image_ref, home_volume_name, slot_key, timezone, hostname, - memory_limit_mb, cpu_limit, host_mounts, created_at, updated_at + memory_limit_mb, cpu_limit, pids_limit, host_mounts, created_at, updated_at `, uuid.NewString(), params.UserID, @@ -395,6 +396,7 @@ func (r *Repository) UpsertHost(ctx context.Context, params UpsertHostParams) (H params.Hostname, params.MemoryLimitMB, params.CPULimit, + params.PidsLimit, mountsJSON, ).Scan( &item.ID, @@ -408,7 +410,7 @@ func (r *Repository) UpsertHost(ctx context.Context, params UpsertHostParams) (H &item.Hostname, &item.MemoryLimitMB, &item.CPULimit, - + &item.PidsLimit, &rawMounts, &item.CreatedAt, &item.UpdatedAt, @@ -648,7 +650,7 @@ func (r *Repository) GetHostDetail(ctx context.Context, hostID string) (HostDeta const listHostsWithUsernameSQL = ` SELECT h.id, h.user_id, h.status, COALESCE(h.short_id, ''), h.template_image_ref, h.home_volume_name, h.slot_key, h.timezone, h.hostname, - h.memory_limit_mb, h.cpu_limit, + h.memory_limit_mb, h.cpu_limit, h.pids_limit, h.host_mounts, h.created_at, h.updated_at, u.username, e.label, e.ip_address, e.detected_ip_address FROM hosts h @@ -672,7 +674,7 @@ func (r *Repository) ListHostsWithUsername(ctx context.Context) ([]HostWithUsern if err := rows.Scan( &item.ID, &item.UserID, &item.Status, &item.ShortID, &item.TemplateImageRef, &item.HomeVolumeName, &item.SlotKey, &item.Timezone, &item.Hostname, - &item.MemoryLimitMB, &item.CPULimit, + &item.MemoryLimitMB, &item.CPULimit, &item.PidsLimit, &rawMounts, &item.CreatedAt, &item.UpdatedAt, &item.Username, @@ -744,7 +746,7 @@ func (r *Repository) GetEgressIPByHost(ctx context.Context, hostID string) (Egre // getHostSQL 将 SQL 文本提升为包级常量,方便仓储层回归测试断言。 const getHostSQL = ` - SELECT id, user_id, status, COALESCE(short_id, ''), template_image_ref, home_volume_name, slot_key, timezone, hostname, memory_limit_mb, cpu_limit, host_mounts, created_at, updated_at + SELECT id, user_id, status, COALESCE(short_id, ''), template_image_ref, home_volume_name, slot_key, timezone, hostname, memory_limit_mb, cpu_limit, pids_limit, host_mounts, created_at, updated_at FROM hosts WHERE id = ? ` @@ -764,7 +766,7 @@ func (r *Repository) GetHost(ctx context.Context, hostID string) (Host, error) { &item.Hostname, &item.MemoryLimitMB, &item.CPULimit, - + &item.PidsLimit, &rawMounts, &item.CreatedAt, &item.UpdatedAt, @@ -982,7 +984,7 @@ func (r *Repository) UpdateUserExpiry(ctx context.Context, userID string, expire // listRunningHostsByUserIDSQL 将 SQL 文本提升为包级常量,方便仓储层回归测试断言。 const listRunningHostsByUserIDSQL = ` - SELECT id, user_id, status, COALESCE(short_id, ''), template_image_ref, home_volume_name, slot_key, timezone, hostname, memory_limit_mb, cpu_limit, host_mounts, created_at, updated_at + SELECT id, user_id, status, COALESCE(short_id, ''), template_image_ref, home_volume_name, slot_key, timezone, hostname, memory_limit_mb, cpu_limit, pids_limit, host_mounts, created_at, updated_at FROM hosts WHERE user_id = ? AND status = 'running' ` @@ -1001,7 +1003,7 @@ func (r *Repository) ListRunningHostsByUserID(ctx context.Context, userID string if err := rows.Scan( &item.ID, &item.UserID, &item.Status, &item.ShortID, &item.TemplateImageRef, &item.HomeVolumeName, &item.SlotKey, &item.Timezone, &item.Hostname, - &item.MemoryLimitMB, &item.CPULimit, + &item.MemoryLimitMB, &item.CPULimit, &item.PidsLimit, &rawMounts, &item.CreatedAt, &item.UpdatedAt, ); err != nil { @@ -1020,7 +1022,7 @@ func (r *Repository) ListRunningHostsByUserID(ctx context.Context, userID string // listRunningHostsSQL 将 SQL 文本提升为包级常量,方便仓储层回归测试断言。 const listRunningHostsSQL = ` - SELECT id, user_id, status, COALESCE(short_id, ''), template_image_ref, home_volume_name, slot_key, timezone, hostname, memory_limit_mb, cpu_limit, host_mounts, created_at, updated_at + SELECT id, user_id, status, COALESCE(short_id, ''), template_image_ref, home_volume_name, slot_key, timezone, hostname, memory_limit_mb, cpu_limit, pids_limit, host_mounts, created_at, updated_at FROM hosts WHERE status = 'running' ORDER BY updated_at ASC @@ -1028,7 +1030,7 @@ const listRunningHostsSQL = ` // listFailedHostsSQL 查询 status='failed' 的主机,供 reconciler 自动恢复。 const listFailedHostsSQL = ` - SELECT id, user_id, status, COALESCE(short_id, ''), template_image_ref, home_volume_name, slot_key, timezone, hostname, memory_limit_mb, cpu_limit, host_mounts, created_at, updated_at + SELECT id, user_id, status, COALESCE(short_id, ''), template_image_ref, home_volume_name, slot_key, timezone, hostname, memory_limit_mb, cpu_limit, pids_limit, host_mounts, created_at, updated_at FROM hosts WHERE status = 'failed' ORDER BY updated_at ASC @@ -1048,7 +1050,7 @@ func (r *Repository) ListRunningHosts(ctx context.Context) ([]Host, error) { if err := rows.Scan( &item.ID, &item.UserID, &item.Status, &item.ShortID, &item.TemplateImageRef, &item.HomeVolumeName, &item.SlotKey, &item.Timezone, &item.Hostname, - &item.MemoryLimitMB, &item.CPULimit, + &item.MemoryLimitMB, &item.CPULimit, &item.PidsLimit, &rawMounts, &item.CreatedAt, &item.UpdatedAt, ); err != nil { @@ -1079,7 +1081,7 @@ func (r *Repository) ListFailedHosts(ctx context.Context) ([]Host, error) { if err := rows.Scan( &item.ID, &item.UserID, &item.Status, &item.ShortID, &item.TemplateImageRef, &item.HomeVolumeName, &item.SlotKey, &item.Timezone, &item.Hostname, - &item.MemoryLimitMB, &item.CPULimit, + &item.MemoryLimitMB, &item.CPULimit, &item.PidsLimit, &rawMounts, &item.CreatedAt, &item.UpdatedAt, ); err != nil { @@ -1528,15 +1530,16 @@ func (r *Repository) UpdateHostMounts(ctx context.Context, hostID string, mounts return err } -// UpdateHostResources 更新主机的资源限制(内存/CPU/磁盘)。 -func (r *Repository) UpdateHostResources(ctx context.Context, hostID string, memoryLimitMB *int, cpuLimit *float64) error { +// UpdateHostResources 更新主机的资源限制(内存/CPU/进程数)。 +func (r *Repository) UpdateHostResources(ctx context.Context, hostID string, memoryLimitMB *int, cpuLimit *float64, pidsLimit *int) error { _, err := r.db.ExecContext(ctx, ` UPDATE hosts SET memory_limit_mb = COALESCE(?, memory_limit_mb), cpu_limit = COALESCE(?, cpu_limit), + pids_limit = COALESCE(?, pids_limit), updated_at = CURRENT_TIMESTAMP WHERE id = ? - `, memoryLimitMB, cpuLimit, hostID) + `, memoryLimitMB, cpuLimit, pidsLimit, hostID) if err != nil { return fmt.Errorf("update host resources: %w", err) } @@ -1592,8 +1595,8 @@ const getHostWithClaudeAccountSQL = ` SELECT h.id, h.user_id, h.status, COALESCE(h.short_id, ''), h.template_image_ref, h.home_volume_name, - h.slot_key, h.timezone, h.hostname, h.memory_limit_mb, h.cpu_limit, - h.h.host_mounts, h.created_at, h.updated_at, + h.slot_key, h.timezone, h.hostname, h.memory_limit_mb, h.cpu_limit, h.pids_limit, + h.host_mounts, h.created_at, h.updated_at, COALESCE(ca.persistent_volume_name, '') FROM hosts h LEFT JOIN claude_accounts ca ON ca.host_id = h.id @@ -1610,7 +1613,7 @@ func (r *Repository) GetHostWithClaudeAccount(ctx context.Context, hostID string &item.ID, &item.UserID, &item.Status, &item.ShortID, &item.TemplateImageRef, &item.HomeVolumeName, &item.SlotKey, &item.Timezone, &item.Hostname, - &item.MemoryLimitMB, &item.CPULimit, + &item.MemoryLimitMB, &item.CPULimit, &item.PidsLimit, &rawMounts, &item.CreatedAt, &item.UpdatedAt, &item.PersistentVolumeName, diff --git a/web/admin/src/components/bypass/__tests__/custom-rules-table.test.tsx b/web/admin/src/components/bypass/__tests__/custom-rules-table.test.tsx index 5f5fd54c..0853be2f 100644 --- a/web/admin/src/components/bypass/__tests__/custom-rules-table.test.tsx +++ b/web/admin/src/components/bypass/__tests__/custom-rules-table.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { CustomRulesTable } from "../custom-rules-table"; @@ -10,7 +10,7 @@ vi.mock("@/lib/api", () => ({ apiFetch: (...args: unknown[]) => apiFetchMock(...args), })); vi.mock("sonner", () => ({ - toast: { success: vi.fn(), error: vi.fn() }, + toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() }, })); function renderWithClient(ui: React.ReactNode) { @@ -91,4 +91,53 @@ describe("CustomRulesTable", () => { // r-2 (keyword abc) 匹配 expect(screen.getByTestId("rules-row-r-2")).toBeInTheDocument(); }); + + it("展示规则导出和规则导入按钮", async () => { + apiFetchMock.mockResolvedValue({ rules }); + renderWithClient(); + + await screen.findByTestId("rules-row-r-1"); + + expect(screen.getByTestId("export-custom-rules")).toHaveTextContent("规则导出"); + expect(screen.getByTestId("import-custom-rules")).toHaveTextContent("规则导入"); + }); + + it("上传 JSON 文件后导入规则", async () => { + apiFetchMock.mockImplementation((url: string, init?: RequestInit) => { + if (url === "/bypass/rules?host_id=h-1") return Promise.resolve({ rules: [] }); + if (url === "/bypass/rules" && init?.method === "POST") { + return Promise.resolve({ rule: rules[0] }); + } + return Promise.reject(new Error("unexpected request")); + }); + const user = userEvent.setup(); + renderWithClient(); + + await screen.findByText("暂无规则"); + const input = screen.getByTestId("import-custom-rules-input") as HTMLInputElement; + const file = new File( + [JSON.stringify({ rules: [{ rule_type: "ip", value: "192.0.2.1", note: "测试" }] })], + "rules.json", + { type: "application/json" }, + ); + + await user.upload(input, file); + + await waitFor(() => { + expect(apiFetchMock).toHaveBeenCalledWith( + "/bypass/rules", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + scope: "host", + host_id: "h-1", + rule_type: "ip", + value: "192.0.2.1", + note: "测试", + confirm_risky: true, + }), + }), + ); + }); + }); }); diff --git a/web/admin/src/components/bypass/custom-rules-table.tsx b/web/admin/src/components/bypass/custom-rules-table.tsx index e467a7f8..d56ed7fa 100644 --- a/web/admin/src/components/bypass/custom-rules-table.tsx +++ b/web/admin/src/components/bypass/custom-rules-table.tsx @@ -1,8 +1,9 @@ -import { useState, useMemo } from "react"; -import { Pencil, Trash2, Plus, Search } from "lucide-react"; +import { useMemo, useRef, useState } from "react"; +import { Download, Pencil, Plus, Search, Trash2, Upload } from "lucide-react"; import { toast } from "sonner"; import { useBypassRules, + useCreateBypassRule, useDeleteBypassRule, } from "@/hooks/use-bypass-rules"; import { parseBypassError } from "@/lib/i18n/bypass-error-codes"; @@ -49,6 +50,24 @@ const TYPE_LABELS: Record = { domain_keyword: "域名关键词", }; +const VALID_RULE_TYPES = new Set([ + "ip", + "cidr", + "domain", + "domain_suffix", + "domain_keyword", +]); + +interface BypassRulesExportFile { + version: number; + exported_at: string; + rules: Array<{ + rule_type: BypassRuleType; + value: string; + note?: string; + }>; +} + interface CustomRulesTableProps { hostId: string; presetRows?: PresetRuleRow[]; @@ -57,7 +76,9 @@ interface CustomRulesTableProps { export function CustomRulesTable({ hostId, presetRows, onDeletePreset }: CustomRulesTableProps) { const rulesQuery = useBypassRules(hostId); + const createMutation = useCreateBypassRule(hostId); const deleteMutation = useDeleteBypassRule(hostId); + const importInputRef = useRef(null); const [typeFilter, setTypeFilter] = useState("all"); const [search, setSearch] = useState(""); @@ -68,6 +89,21 @@ export function CustomRulesTable({ hostId, presetRows, onDeletePreset }: CustomR const [deletePresetTarget, setDeletePresetTarget] = useState(null); const customRules = rulesQuery.data?.rules ?? []; + const exportableRules = useMemo( + () => [ + ...(presetRows?.map((rule) => ({ + rule_type: rule.rule_type, + value: rule.value, + note: rule.note || undefined, + })) ?? []), + ...customRules.map((rule) => ({ + rule_type: rule.rule_type, + value: rule.value, + note: rule.note ?? undefined, + })), + ], + [customRules, presetRows], + ); const filtered = useMemo(() => { const all = customRules.filter((r) => { @@ -141,22 +177,129 @@ export function CustomRulesTable({ hostId, presetRows, onDeletePreset }: CustomR setDeletePresetTarget(null); } + async function handleExportRules() { + if (exportableRules.length === 0) { + toast.info("暂无可导出的规则"); + return; + } + + const data: BypassRulesExportFile = { + version: 1, + exported_at: new Date().toISOString(), + rules: exportableRules, + }; + const blob = new Blob([JSON.stringify(data, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `bypass-rules-${hostId}.json`; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + toast.success("规则已导出"); + } + + async function handleImportRules(file: File) { + try { + const text = await file.text(); + const parsed = JSON.parse(text) as Partial | BypassRulesExportFile["rules"]; + const rules = Array.isArray(parsed) ? parsed : parsed.rules; + + if (!Array.isArray(rules) || rules.length === 0) { + toast.error("导入文件中没有规则"); + return; + } + + for (const rule of rules) { + if ( + !rule || + !VALID_RULE_TYPES.has(rule.rule_type) || + typeof rule.value !== "string" || + rule.value.trim() === "" + ) { + toast.error("导入文件格式不正确"); + return; + } + } + + await Promise.all( + rules.map((rule) => + createMutation.mutateAsync({ + rule_type: rule.rule_type, + value: rule.value.trim(), + note: typeof rule.note === "string" ? rule.note : undefined, + confirm_risky: true, + }), + ), + ); + toast.success(`已导入 ${rules.length} 条规则,请点击「应用」生效`); + } catch (err) { + if (err instanceof SyntaxError) { + toast.error("导入文件不是有效 JSON"); + return; + } + toast.error(parseBypassError(err).message); + } finally { + if (importInputRef.current) { + importInputRef.current.value = ""; + } + } + } + const hasAny = (presetRows?.length ?? 0) + customRules.length > 0; const filteredTotal = filteredPresets.length + filtered.length; return (
-
+

规则

- +
+ + + + { + const file = event.target.files?.[0]; + if (file) void handleImportRules(file); + }} + data-testid="import-custom-rules-input" + /> +
diff --git a/web/admin/src/components/hosts/create-host-dialog.tsx b/web/admin/src/components/hosts/create-host-dialog.tsx index 69a9110f..a6ef1426 100644 --- a/web/admin/src/components/hosts/create-host-dialog.tsx +++ b/web/admin/src/components/hosts/create-host-dialog.tsx @@ -86,6 +86,7 @@ export function CreateHostDialog({ const [egressIpId, setEgressIpId] = useState(""); const [timezone, setTimezone] = useState("America/Los_Angeles"); const [resources, setResources] = useState({ + pids_limit: 1024, memory_limit_mb: null, cpu_limit: null, }); @@ -136,7 +137,7 @@ export function CreateHostDialog({ const mounts = hostMounts .filter((m) => m.source && m.target && m.source.startsWith("/") && m.target.startsWith("/")); createMutation.mutate( - { user_id: userId, egress_ip_id: egressIpId, timezone, memory_limit_mb: resources.memory_limit_mb, cpu_limit: resources.cpu_limit, host_mounts: mounts.length > 0 ? mounts : undefined }, + { user_id: userId, egress_ip_id: egressIpId, timezone, pids_limit: resources.pids_limit, memory_limit_mb: resources.memory_limit_mb, cpu_limit: resources.cpu_limit, host_mounts: mounts.length > 0 ? mounts : undefined }, { onSuccess: (data) => { setTaskId(data.task_id); @@ -150,7 +151,7 @@ export function CreateHostDialog({ setUserId(""); setEgressIpId(""); setTimezone("America/Los_Angeles"); - setResources({ memory_limit_mb: null, cpu_limit: null }); + setResources({ pids_limit: 1024, memory_limit_mb: null, cpu_limit: null }); setHostMounts([{ source: "", target: "" }]); setTaskId(null); onOpenChange(false); @@ -232,7 +233,7 @@ export function CreateHostDialog({

- 不设置则使用默认值(4 GB 内存 / 2 核 CPU / 20 GB 磁盘)。选择"无限制"可使用宿主机全部资源。 + 不设置则使用默认值(1024 进程 / 4 GB 内存 / 2 核 CPU)。选择“无限制”可使用宿主机对应资源。

p.value === value); + const preset = presets.find((p) => p.value !== -1 && p.value === value); if (preset) return String(preset.value); - return "custom"; + return "-1"; } function getDisplayLabel(presets: readonly PresetItem[], value: number | null, unit: string, defaultLabel: string): string { if (value === null) return defaultLabel; const preset = presets.find((p) => p.value === value); if (preset) return preset.label; - return `${value} ${unit}`; + return unit ? `${value} ${unit}` : String(value); } export function ResourceLimitsSelector({ value, onChange, disabled }: ResourceLimitsSelectorProps) { - const [customMemory, setCustomMemory] = useState(""); - const [customCPU, setCustomCPU] = useState(""); - function isInCustomMode(currentValue: number | null, presets: readonly PresetItem[]): boolean { if (currentValue === null) return false; - return !presets.some((p) => p.value === currentValue); + return !presets.some((p) => p.value !== -1 && p.value === currentValue); } return (
+ {/* 进程数选择器 */} +
+ + + {isInCustomMode(value.pids_limit, PIDS_PRESETS) && ( +
+ { + const v = e.target.value === "" ? 0 : Number(e.target.value); + if (Number.isInteger(v) && v >= 0) { + onChange({ ...value, pids_limit: v }); + } + }} + /> + +
+ )} +
+ {/* 内存选择器 */}
{ - if (v === "custom") { - setCustomCPU(""); - onChange({ ...value, cpu_limit: 0 }); + if (v === "-1") { + onChange({ ...value, cpu_limit: 3 }); } else { onChange({ ...value, cpu_limit: Number(v) }); } diff --git a/web/admin/src/hooks/use-hosts.ts b/web/admin/src/hooks/use-hosts.ts index be38bbde..de9a4d3c 100644 --- a/web/admin/src/hooks/use-hosts.ts +++ b/web/admin/src/hooks/use-hosts.ts @@ -66,6 +66,7 @@ export interface HostDetail { hostname: string; memory_limit_mb: number | null; cpu_limit: number | null; + pids_limit: number | null; host_mounts?: HostMount[]; created_at: string; @@ -122,7 +123,7 @@ export function useHostDetail(hostId: string) { export function useCreateHost() { const qc = useQueryClient(); return useMutation({ - mutationFn: (data: { user_id: string; egress_ip_id: string; timezone?: string; memory_limit_mb?: number | null; cpu_limit?: number | null; host_mounts?: HostMount[] }) => + mutationFn: (data: { user_id: string; egress_ip_id: string; timezone?: string; pids_limit?: number | null; memory_limit_mb?: number | null; cpu_limit?: number | null; host_mounts?: HostMount[] }) => apiFetch<{ host: HostWithUsername; task_id: string }>("/hosts", { method: "POST", body: JSON.stringify(data), @@ -153,6 +154,7 @@ export function usePatchHostResources(hostId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: (data: { + pids_limit?: number | null; memory_limit_mb?: number | null; cpu_limit?: number | null; }) => diff --git a/web/admin/src/routes/_dashboard/hosts/$hostId.tsx b/web/admin/src/routes/_dashboard/hosts/$hostId.tsx index 5d501de9..b802bfc1 100644 --- a/web/admin/src/routes/_dashboard/hosts/$hostId.tsx +++ b/web/admin/src/routes/_dashboard/hosts/$hostId.tsx @@ -124,6 +124,7 @@ function HostDetailPage() { const [showLayers, setShowLayers] = useState(false); const [editingResources, setEditingResources] = useState(false); const [editResourcesValue, setEditResourcesValue] = useState({ + pids_limit: null, memory_limit_mb: null, cpu_limit: null, }); @@ -449,11 +450,21 @@ function HostDetailPage() {

资源限制

- {isRunning ? "运行中不可编辑" : "停止中,可以编辑"} + 运行中编辑会立即应用到容器

{!editingResources ? (
+
+ 进程数 + + {host.pids_limit != null + ? host.pids_limit === 0 + ? "无限制" + : String(host.pids_limit) + : "默认 (1024)"} + +
内存 @@ -481,10 +492,9 @@ function HostDetailPage() { size="sm" variant="outline" className="mt-2 w-full" - disabled={isRunning} - title={isRunning ? "请先停止主机" : undefined} onClick={() => { setEditResourcesValue({ + pids_limit: host.pids_limit, memory_limit_mb: host.memory_limit_mb, cpu_limit: host.cpu_limit, }); @@ -516,6 +526,7 @@ function HostDetailPage() { onClick={() => { patchResourcesMutation.mutate( { + pids_limit: editResourcesValue.pids_limit, memory_limit_mb: editResourcesValue.memory_limit_mb, cpu_limit: editResourcesValue.cpu_limit, },