Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .planning/STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-01Milestone v4.2.0 completed and archived
last_updated: "2026-06-02T00:00:00.000Z"
last_activity: 2026-06-02Completed quick task 260602: 规则导入导出按钮
progress:
total_phases: 5
completed_phases: 1
Expand All @@ -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-01Milestone v4.2.0 completed and archived
Last activity: 2026-06-02Completed quick task 260602: 规则导入导出按钮

## Accumulated Context

Expand Down Expand Up @@ -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

Expand Down
23 changes: 23 additions & 0 deletions .planning/quick/260602-rules-import-export/260602-PLAN.md
Original file line number Diff line number Diff line change
@@ -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`。
- 导入支持上述对象格式,也支持直接上传规则数组。
- 导入成功后提示点击「应用」生效。
28 changes: 28 additions & 0 deletions .planning/quick/260602-rules-import-export/260602-SUMMARY.md
Original file line number Diff line number Diff line change
@@ -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` 命令不可用。

## 后续

安装前端依赖后重新运行类型检查和相关单元测试。
1 change: 1 addition & 0 deletions internal/agentapi/contracts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
163 changes: 133 additions & 30 deletions internal/controlplane/http/admin_hosts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 == "" {
Expand All @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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"})
Expand All @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -1261,21 +1350,35 @@ 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
}
return mb
}

// resolveResourceCPU PATCH 端点的三态解析:nil=不修改,0→NULL(无限制),>0传值。
// resolveResourceCPU PATCH 端点的三态解析:nil=不修改,0=无限制,>0=传值。
func resolveResourceCPU(cpu *float64) *float64 {
if cpu == nil {
return nil
}
return cpu
}


// resolveResourcePidsLimit PATCH 端点的三态解析:nil=不修改,0=无限制,>0=传值。
func resolveResourcePidsLimit(pids *int) *int {
if pids == nil {
return nil
}
return pids
}
Loading
Loading