Compare commits

..

7 Commits

Author SHA1 Message Date
MengMengCode cbe9339316 release: v1.1.19 2026-06-12 16:01:52 +08:00
Meng Meng 79d6dad684 Merge pull request #12 from MengMengCode/chore/update-version-badge
chore: add version badge to README
2026-06-12 15:59:47 +08:00
MengMengCode 55c7a9796c chore: add version badge to README 2026-06-12 15:59:24 +08:00
Meng Meng f4a15a0d90 Merge pull request #11 from MengMengCode/feature/split-rate-limits
feat: split rate limits with configurable per-endpoint controls
2026-06-12 15:57:35 +08:00
MengMengCode c7742319b2 feat: split rate limits with configurable per-endpoint controls 2026-06-12 15:53:31 +08:00
Meng Meng 01c14ecba6 chore: update firewall i18n strings
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:42:22 +08:00
MengMengCode 989e1b6645 chore: update firewall i18n strings
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:40:59 +08:00
20 changed files with 788 additions and 145 deletions
+2 -2
View File
@@ -2,7 +2,7 @@
<img src="frontend/public/favicon.svg" width="96" alt="CLICD"> <img src="frontend/public/favicon.svg" width="96" alt="CLICD">
</p> </p>
<h1 align="center">CLICD</h1> <h1 align="center">CLICD <sub><sup>v1.1.18</sup></sub></h1>
<p align="center"> <p align="center">
<img alt="Go" src="https://img.shields.io/badge/Go-1.24-00ADD8?style=flat-square&logo=go&logoColor=white"> <img alt="Go" src="https://img.shields.io/badge/Go-1.24-00ADD8?style=flat-square&logo=go&logoColor=white">
@@ -73,7 +73,7 @@ curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh
| 虚拟化管理 | 在同一个面板里管理 LXC 容器和 KVM 虚拟机,支持创建、重装、开机、关机、重启、删除、重置密码、到期时间和批量操作。 | | 虚拟化管理 | 在同一个面板里管理 LXC 容器和 KVM 虚拟机,支持创建、重装、开机、关机、重启、删除、重置密码、到期时间和批量操作。 |
| 镜像与模板 | 内置模板和镜像管理,支持 Ubuntu、Debian、Alpine、CentOS、Arch Linux、Fedora、Rocky Linux 等常见发行版,镜像可按需下载、取消、启用、禁用和清理缓存。 | | 镜像与模板 | 内置模板和镜像管理,支持 Ubuntu、Debian、Alpine、CentOS、Arch Linux、Fedora、Rocky Linux 等常见发行版,镜像可按需下载、取消、启用、禁用和清理缓存。 |
| 网络能力 | 支持 NAT4 端口配额、随机可用端口、TCP/UDP 端口映射、公网 IPv4 池管理、IPv6 前缀检测、IPv6 状态检查和容器级 IPv6 分配。 | | 网络能力 | 支持 NAT4 端口配额、随机可用端口、TCP/UDP 端口映射、公网 IPv4 池管理、IPv6 前缀检测、IPv6 状态检查和容器级 IPv6 分配。 |
| 资源限制 | 支持 CPU、内存、磁盘、Swap、带宽用量、流量重置、流量限制和资源限制管理;容器到期或超额后可自动关机,避免资源和流量失控。 | | 资源限制 | 支持 CPU、内存、磁盘、Swap、独立上行/下行带宽、读/写 I/O 限速、流量重置、流量限制和资源限制管理;容器到期或超额后可自动关机,避免资源和流量失控。 |
| 远程控制 | 内置 WebSSH 和 WebVNC 票据访问,用户可以直接在浏览器打开终端或控制台,不需要手动复制连接信息。 | | 远程控制 | 内置 WebSSH 和 WebVNC 票据访问,用户可以直接在浏览器打开终端或控制台,不需要手动复制连接信息。 |
| 快照能力 | 支持快照总览、容器快照、创建快照、删除快照、恢复快照、计划快照和快照配额。 | | 快照能力 | 支持快照总览、容器快照、创建快照、删除快照、恢复快照、计划快照和快照配额。 |
| 安全告警 | 基于 conntrack 做轻量安全检测,可识别端口扫描、横向扫描、爆破倾向、SMTP 滥用、UDP 反射、挖矿端口、代理/VPN/Tor 等风险,并提供安全日志、汇总和设置项。 | | 安全告警 | 基于 conntrack 做轻量安全检测,可识别端口扫描、横向扫描、爆破倾向、SMTP 滥用、UDP 反射、挖矿端口、代理/VPN/Tor 等风险,并提供安全日志、汇总和设置项。 |
+150 -11
View File
@@ -2,6 +2,8 @@ package api
import ( import (
"encoding/json" "encoding/json"
"fmt"
"io"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@@ -210,10 +212,21 @@ func listContainers(w http.ResponseWriter, r *http.Request) {
func createContainer(w http.ResponseWriter, r *http.Request) { func createContainer(w http.ResponseWriter, r *http.Request) {
var cfg lxc.ContainerConfig var cfg lxc.ContainerConfig
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { body, err := io.ReadAll(r.Body)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return return
} }
var fields map[string]json.RawMessage
if err := json.Unmarshal(body, &cfg); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
_ = json.Unmarshal(body, &fields)
if err := normalizeCreateResourceLimits(&cfg, fields); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
if cfg.Name == "" { if cfg.Name == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Container name is required"}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Container name is required"})
return return
@@ -386,10 +399,14 @@ func updateTrafficLimit(w http.ResponseWriter, r *http.Request, id int) {
func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) { func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
var req struct { var req struct {
VCPU float64 `json:"vcpu"` VCPU *float64 `json:"vcpu"`
RAMMB int `json:"ram_mb"` RAMMB *int `json:"ram_mb"`
IOMBps int `json:"io_speed_mbps"` IOMBps *int `json:"io_speed_mbps"`
BWMbps int `json:"network_bw_mbps"` IOReadMBps *int `json:"io_read_mbps"`
IOWriteMBps *int `json:"io_write_mbps"`
BWMbps *int `json:"network_bw_mbps"`
NetworkDownMbps *int `json:"network_down_mbps"`
NetworkUpMbps *int `json:"network_up_mbps"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request"}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request"})
@@ -404,21 +421,35 @@ func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
// Update config // Update config
nextVCPU := c.VCPU nextVCPU := c.VCPU
nextRAMMB := c.RAMMB nextRAMMB := c.RAMMB
if req.VCPU > 0 { if req.VCPU != nil {
nextVCPU = req.VCPU nextVCPU = *req.VCPU
} }
if req.RAMMB > 0 { if req.RAMMB != nil {
nextRAMMB = req.RAMMB nextRAMMB = *req.RAMMB
} }
if err := validateRuntimeResourceRequest(c.Runtime(), nextVCPU, nextRAMMB, c.DiskGB); err != nil { if err := validateRuntimeResourceRequest(c.Runtime(), nextVCPU, nextRAMMB, c.DiskGB); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return return
} }
for name, value := range map[string]*int{
"network_bw_mbps": req.BWMbps,
"network_down_mbps": req.NetworkDownMbps,
"network_up_mbps": req.NetworkUpMbps,
"io_speed_mbps": req.IOMBps,
"io_read_mbps": req.IOReadMBps,
"io_write_mbps": req.IOWriteMBps,
} {
if err := rejectNegativeLimit(name, value); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
}
c.VCPU = nextVCPU c.VCPU = nextVCPU
c.RAMMB = nextRAMMB c.RAMMB = nextRAMMB
c.IOSpeedMBps = req.IOMBps applyNetworkLimitPatch(c, req.BWMbps, req.NetworkDownMbps, req.NetworkUpMbps)
c.NetworkBWMbps = req.BWMbps applyIOLimitPatch(c, req.IOMBps, req.IOReadMBps, req.IOWriteMBps)
config.NormalizeContainerResourceAliases(c)
config.SaveConfig() config.SaveConfig()
// Re-apply resource limits to running container // Re-apply resource limits to running container
@@ -436,6 +467,114 @@ func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: msg}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: msg})
} }
func normalizeCreateResourceLimits(cfg *lxc.ContainerConfig, fields map[string]json.RawMessage) error {
if cfg == nil {
return nil
}
if err := rejectNegativeCreateLimits(*cfg); err != nil {
return err
}
bwSet := hasJSONField(fields, "network_bw_mbps")
downSet := hasJSONField(fields, "network_down_mbps")
upSet := hasJSONField(fields, "network_up_mbps")
if bwSet {
if !downSet {
cfg.NetworkDownMbps = cfg.NetworkBWMbps
}
if !upSet {
cfg.NetworkUpMbps = cfg.NetworkBWMbps
}
}
ioSet := hasJSONField(fields, "io_speed_mbps")
readSet := hasJSONField(fields, "io_read_mbps")
writeSet := hasJSONField(fields, "io_write_mbps")
if ioSet {
if !readSet {
cfg.IOReadMBps = cfg.IOSpeedMBps
}
if !writeSet {
cfg.IOWriteMBps = cfg.IOSpeedMBps
}
}
cfg.NormalizeResourceAliases()
return nil
}
func rejectNegativeCreateLimits(cfg lxc.ContainerConfig) error {
for name, value := range map[string]int{
"network_bw_mbps": cfg.NetworkBWMbps,
"network_down_mbps": cfg.NetworkDownMbps,
"network_up_mbps": cfg.NetworkUpMbps,
"io_speed_mbps": cfg.IOSpeedMBps,
"io_read_mbps": cfg.IOReadMBps,
"io_write_mbps": cfg.IOWriteMBps,
} {
if value < 0 {
return fmt.Errorf("%s cannot be negative", name)
}
}
return nil
}
func hasJSONField(fields map[string]json.RawMessage, name string) bool {
if fields == nil {
return false
}
_, ok := fields[name]
return ok
}
func rejectNegativeLimit(name string, value *int) error {
if value != nil && *value < 0 {
return fmt.Errorf("%s cannot be negative", name)
}
return nil
}
func applyNetworkLimitPatch(c *config.Container, legacy *int, down *int, up *int) {
if c == nil {
return
}
config.NormalizeContainerResourceAliases(c)
nextDown := c.NetworkDownMbps
nextUp := c.NetworkUpMbps
if legacy != nil {
nextDown = *legacy
nextUp = *legacy
}
if down != nil {
nextDown = *down
}
if up != nil {
nextUp = *up
}
c.NetworkDownMbps = nextDown
c.NetworkUpMbps = nextUp
c.NetworkBWMbps = config.LegacySymmetricLimit(nextDown, nextUp)
}
func applyIOLimitPatch(c *config.Container, legacy *int, read *int, write *int) {
if c == nil {
return
}
config.NormalizeContainerResourceAliases(c)
nextRead := c.IOReadMBps
nextWrite := c.IOWriteMBps
if legacy != nil {
nextRead = *legacy
nextWrite = *legacy
}
if read != nil {
nextRead = *read
}
if write != nil {
nextWrite = *write
}
c.IOReadMBps = nextRead
c.IOWriteMBps = nextWrite
c.IOSpeedMBps = config.LegacySymmetricLimit(nextRead, nextWrite)
}
func getRandomPort(w http.ResponseWriter, r *http.Request, id int) { func getRandomPort(w http.ResponseWriter, r *http.Request, id int) {
c := config.FindContainer(id) c := config.FindContainer(id)
if c == nil { if c == nil {
+1
View File
@@ -32,6 +32,7 @@ func runtimeFromTemplateID(templateID string) string {
func createByRuntime(cfg lxc.ContainerConfig) error { func createByRuntime(cfg lxc.ContainerConfig) error {
cfg.Virtualization = runtimeFromRequest(cfg.Virtualization) cfg.Virtualization = runtimeFromRequest(cfg.Virtualization)
cfg.NormalizeResourceAliases()
if cfg.Virtualization == config.VirtualizationKVM { if cfg.Virtualization == config.VirtualizationKVM {
return kvmManager.CreateContainer(cfg) return kvmManager.CreateContainer(cfg)
} }
+9
View File
@@ -98,6 +98,7 @@ func (q *TaskQueue) EnqueueWithAudit(containerID int, containerName string, task
} }
if cfg != nil { if cfg != nil {
task.Config = *cfg task.Config = *cfg
task.Config.NormalizeResourceAliases()
} }
q.enqueueTask(task) q.enqueueTask(task)
q.persistTasks() q.persistTasks()
@@ -162,6 +163,7 @@ func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig, user s
var result []string var result []string
for _, cfg := range configs { for _, cfg := range configs {
cfgCopy := cfg cfgCopy := cfg
cfgCopy.NormalizeResourceAliases()
id := q.nextID id := q.nextID
q.nextID++ q.nextID++
task := &Task{ task := &Task{
@@ -246,6 +248,7 @@ func (q *TaskQueue) createWorker() {
if task.Config.Name == "" { if task.Config.Name == "" {
task.Config.Name = task.ContainerName task.Config.Name = task.ContainerName
} }
task.Config.NormalizeResourceAliases()
if task.Config.Name == "" { if task.Config.Name == "" {
task.Status = "failed" task.Status = "failed"
task.Error = "container name is required" task.Error = "container name is required"
@@ -592,6 +595,11 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
if req.Containers[i].VCPU <= 0 { if req.Containers[i].VCPU <= 0 {
req.Containers[i].VCPU = 1 req.Containers[i].VCPU = 1
} }
if err := rejectNegativeCreateLimits(req.Containers[i]); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
return
}
req.Containers[i].NormalizeResourceAliases()
req.Containers[i].Virtualization = runtimeFromRequest(req.Containers[i].Virtualization) req.Containers[i].Virtualization = runtimeFromRequest(req.Containers[i].Virtualization)
if req.Containers[i].RAMMB < 128 { if req.Containers[i].RAMMB < 128 {
req.Containers[i].RAMMB = 512 req.Containers[i].RAMMB = 512
@@ -820,6 +828,7 @@ func RestoreTasks() {
if cfg.Name == "" { if cfg.Name == "" {
cfg.Name = containerName cfg.Name = containerName
} }
cfg.NormalizeResourceAliases()
containerID := st.ContainerID containerID := st.ContainerID
if containerID <= 0 && containerName != "" { if containerID <= 0 && containerName != "" {
if c := config.FindContainerByName(containerName); c != nil { if c := config.FindContainerByName(containerName); c != nil {
+1
View File
@@ -388,6 +388,7 @@ func cliCreateContainer(reader *bufio.Reader) {
IOSpeedMBps: promptInt(reader, "IO 速度 (MB/s)", 500), IOSpeedMBps: promptInt(reader, "IO 速度 (MB/s)", 500),
ExtraPorts: promptPortList(reader, "额外 NAT 端口,多个用逗号分隔"), ExtraPorts: promptPortList(reader, "额外 NAT 端口,多个用逗号分隔"),
} }
cfg.NormalizeResourceAliases()
cliPrintf("\n正在创建容器 %s ...\n", name) cliPrintf("\n正在创建容器 %s ...\n", name)
if err := manager.CreateContainer(cfg); err != nil { if err := manager.CreateContainer(cfg); err != nil {
+93
View File
@@ -115,6 +115,8 @@ type Container struct {
RAMMB int `json:"ram_mb"` RAMMB int `json:"ram_mb"`
DiskGB int `json:"disk_gb"` DiskGB int `json:"disk_gb"`
NetworkBWMbps int `json:"network_bw_mbps"` NetworkBWMbps int `json:"network_bw_mbps"`
NetworkDownMbps int `json:"network_down_mbps"`
NetworkUpMbps int `json:"network_up_mbps"`
MonthlyTrafficGB int `json:"monthly_traffic_gb"` MonthlyTrafficGB int `json:"monthly_traffic_gb"`
TrafficMode string `json:"traffic_mode"` // "total" or "in_out" TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
TrafficInGB int `json:"traffic_in_gb"` // 0 = unlimited TrafficInGB int `json:"traffic_in_gb"` // 0 = unlimited
@@ -123,6 +125,8 @@ type Container struct {
TrafficUsedTX int64 `json:"traffic_used_tx"` TrafficUsedTX int64 `json:"traffic_used_tx"`
TrafficResetDate string `json:"traffic_reset_date"` TrafficResetDate string `json:"traffic_reset_date"`
IOSpeedMBps int `json:"io_speed_mbps"` IOSpeedMBps int `json:"io_speed_mbps"`
IOReadMBps int `json:"io_read_mbps"`
IOWriteMBps int `json:"io_write_mbps"`
Status string `json:"status"` Status string `json:"status"`
IP string `json:"ip"` IP string `json:"ip"`
PublicIPv4s []PublicIPv4Assignment `json:"public_ipv4s,omitempty"` PublicIPv4s []PublicIPv4Assignment `json:"public_ipv4s,omitempty"`
@@ -704,6 +708,9 @@ func migrateLoadedConfig() bool {
if ensureContainerNetworkAssignments() { if ensureContainerNetworkAssignments() {
changed = true changed = true
} }
if ensureContainerResourceAliases() {
changed = true
}
if ensureContainerSnapshotScheduleDefaults() { if ensureContainerSnapshotScheduleDefaults() {
changed = true changed = true
} }
@@ -802,6 +809,91 @@ func ensureContainerNetworkAssignments() bool {
return changed return changed
} }
func ensureContainerResourceAliases() bool {
changed := false
for i := range AppConfig.Containers {
if NormalizeContainerResourceAliases(&AppConfig.Containers[i]) {
changed = true
}
}
return changed
}
func NormalizeContainerResourceAliases(c *Container) bool {
if c == nil {
return false
}
changed := false
if c.NetworkBWMbps < 0 {
c.NetworkBWMbps = 0
changed = true
}
if c.NetworkDownMbps < 0 {
c.NetworkDownMbps = 0
changed = true
}
if c.NetworkUpMbps < 0 {
c.NetworkUpMbps = 0
changed = true
}
if c.NetworkDownMbps == 0 && c.NetworkUpMbps == 0 && c.NetworkBWMbps > 0 {
c.NetworkDownMbps = c.NetworkBWMbps
c.NetworkUpMbps = c.NetworkBWMbps
changed = true
}
nextNetworkBW := LegacySymmetricLimit(c.NetworkDownMbps, c.NetworkUpMbps)
if c.NetworkBWMbps != nextNetworkBW {
c.NetworkBWMbps = nextNetworkBW
changed = true
}
if c.IOSpeedMBps < 0 {
c.IOSpeedMBps = 0
changed = true
}
if c.IOReadMBps < 0 {
c.IOReadMBps = 0
changed = true
}
if c.IOWriteMBps < 0 {
c.IOWriteMBps = 0
changed = true
}
if c.IOReadMBps == 0 && c.IOWriteMBps == 0 && c.IOSpeedMBps > 0 {
c.IOReadMBps = c.IOSpeedMBps
c.IOWriteMBps = c.IOSpeedMBps
changed = true
}
nextIO := LegacySymmetricLimit(c.IOReadMBps, c.IOWriteMBps)
if c.IOSpeedMBps != nextIO {
c.IOSpeedMBps = nextIO
changed = true
}
return changed
}
func LegacySymmetricLimit(a, b int) int {
if a < 0 {
a = 0
}
if b < 0 {
b = 0
}
if a == b {
return a
}
if a == 0 {
return b
}
if b == 0 {
return a
}
if a < b {
return a
}
return b
}
func migrateSubUsers() bool { func migrateSubUsers() bool {
changed := false changed := false
for i := range AppConfig.SubUsers { for i := range AppConfig.SubUsers {
@@ -886,6 +978,7 @@ func AddContainer(c Container) {
c.UUID = NewContainerUUID() c.UUID = NewContainerUUID()
} }
c.Virtualization = NormalizeVirtualization(c.Virtualization) c.Virtualization = NormalizeVirtualization(c.Virtualization)
NormalizeContainerResourceAliases(&c)
AppConfig.Containers = append(AppConfig.Containers, c) AppConfig.Containers = append(AppConfig.Containers, c)
SaveConfig() SaveConfig()
} }
+138 -25
View File
@@ -28,11 +28,15 @@ type savedTaskConfig struct {
RAMMB int `json:"ram_mb"` RAMMB int `json:"ram_mb"`
DiskGB int `json:"disk_gb"` DiskGB int `json:"disk_gb"`
NetworkBWMbps int `json:"network_bw_mbps"` NetworkBWMbps int `json:"network_bw_mbps"`
NetworkDownMbps int `json:"network_down_mbps"`
NetworkUpMbps int `json:"network_up_mbps"`
MonthlyTrafficGB int `json:"monthly_traffic_gb"` MonthlyTrafficGB int `json:"monthly_traffic_gb"`
TrafficMode string `json:"traffic_mode"` TrafficMode string `json:"traffic_mode"`
TrafficInGB int `json:"traffic_in_gb"` TrafficInGB int `json:"traffic_in_gb"`
TrafficOutGB int `json:"traffic_out_gb"` TrafficOutGB int `json:"traffic_out_gb"`
IOSpeedMBps int `json:"io_speed_mbps"` IOSpeedMBps int `json:"io_speed_mbps"`
IOReadMBps int `json:"io_read_mbps"`
IOWriteMBps int `json:"io_write_mbps"`
ExtraPorts []int `json:"extra_ports"` ExtraPorts []int `json:"extra_ports"`
PortMappingCount int `json:"port_mapping_count"` PortMappingCount int `json:"port_mapping_count"`
AssignNAT *bool `json:"assign_nat,omitempty"` AssignNAT *bool `json:"assign_nat,omitempty"`
@@ -55,10 +59,12 @@ func parseSavedTaskConfig(raw string) savedTaskConfig {
} }
var cfg savedTaskConfig var cfg savedTaskConfig
_ = json.Unmarshal([]byte(raw), &cfg) _ = json.Unmarshal([]byte(raw), &cfg)
normalizeSavedTaskConfigLimits(&cfg)
return cfg return cfg
} }
func encodeSavedTaskConfig(cfg savedTaskConfig) string { func encodeSavedTaskConfig(cfg savedTaskConfig) string {
normalizeSavedTaskConfigLimits(&cfg)
data, err := json.Marshal(cfg) data, err := json.Marshal(cfg)
if err != nil { if err != nil {
return "" return ""
@@ -66,6 +72,41 @@ func encodeSavedTaskConfig(cfg savedTaskConfig) string {
return string(data) return string(data)
} }
func normalizeSavedTaskConfigLimits(cfg *savedTaskConfig) {
if cfg == nil {
return
}
if cfg.NetworkBWMbps < 0 {
cfg.NetworkBWMbps = 0
}
if cfg.NetworkDownMbps < 0 {
cfg.NetworkDownMbps = 0
}
if cfg.NetworkUpMbps < 0 {
cfg.NetworkUpMbps = 0
}
if cfg.NetworkDownMbps == 0 && cfg.NetworkUpMbps == 0 && cfg.NetworkBWMbps > 0 {
cfg.NetworkDownMbps = cfg.NetworkBWMbps
cfg.NetworkUpMbps = cfg.NetworkBWMbps
}
cfg.NetworkBWMbps = LegacySymmetricLimit(cfg.NetworkDownMbps, cfg.NetworkUpMbps)
if cfg.IOSpeedMBps < 0 {
cfg.IOSpeedMBps = 0
}
if cfg.IOReadMBps < 0 {
cfg.IOReadMBps = 0
}
if cfg.IOWriteMBps < 0 {
cfg.IOWriteMBps = 0
}
if cfg.IOReadMBps == 0 && cfg.IOWriteMBps == 0 && cfg.IOSpeedMBps > 0 {
cfg.IOReadMBps = cfg.IOSpeedMBps
cfg.IOWriteMBps = cfg.IOSpeedMBps
}
cfg.IOSpeedMBps = LegacySymmetricLimit(cfg.IOReadMBps, cfg.IOWriteMBps)
}
func encodeStringSlice(values []string) string { func encodeStringSlice(values []string) string {
if len(values) == 0 { if len(values) == 0 {
return "" return ""
@@ -148,6 +189,8 @@ func ensureSchema() error {
ram_mb INTEGER, ram_mb INTEGER,
disk_gb INTEGER, disk_gb INTEGER,
network_bw_mbps INTEGER, network_bw_mbps INTEGER,
network_down_mbps INTEGER NOT NULL DEFAULT 0,
network_up_mbps INTEGER NOT NULL DEFAULT 0,
monthly_traffic_gb INTEGER, monthly_traffic_gb INTEGER,
traffic_mode TEXT, traffic_mode TEXT,
traffic_in_gb INTEGER, traffic_in_gb INTEGER,
@@ -156,6 +199,8 @@ func ensureSchema() error {
traffic_used_tx INTEGER, traffic_used_tx INTEGER,
traffic_reset_date TEXT, traffic_reset_date TEXT,
io_speed_mbps INTEGER, io_speed_mbps INTEGER,
io_read_mbps INTEGER NOT NULL DEFAULT 0,
io_write_mbps INTEGER NOT NULL DEFAULT 0,
status TEXT, status TEXT,
ip TEXT, ip TEXT,
ipv6 TEXT, ipv6 TEXT,
@@ -282,11 +327,15 @@ func ensureSchema() error {
cfg_ram_mb INTEGER, cfg_ram_mb INTEGER,
cfg_disk_gb INTEGER, cfg_disk_gb INTEGER,
cfg_network_bw_mbps INTEGER, cfg_network_bw_mbps INTEGER,
cfg_network_down_mbps INTEGER NOT NULL DEFAULT 0,
cfg_network_up_mbps INTEGER NOT NULL DEFAULT 0,
cfg_monthly_traffic_gb INTEGER, cfg_monthly_traffic_gb INTEGER,
cfg_traffic_mode TEXT, cfg_traffic_mode TEXT,
cfg_traffic_in_gb INTEGER, cfg_traffic_in_gb INTEGER,
cfg_traffic_out_gb INTEGER, cfg_traffic_out_gb INTEGER,
cfg_io_speed_mbps INTEGER, cfg_io_speed_mbps INTEGER,
cfg_io_read_mbps INTEGER NOT NULL DEFAULT 0,
cfg_io_write_mbps INTEGER NOT NULL DEFAULT 0,
cfg_port_mapping_count INTEGER, cfg_port_mapping_count INTEGER,
cfg_assign_nat INTEGER, cfg_assign_nat INTEGER,
cfg_snapshot_limit INTEGER, cfg_snapshot_limit INTEGER,
@@ -340,6 +389,7 @@ func ensureSchema() error {
} }
func ensureSchemaMigrations() error { func ensureSchemaMigrations() error {
added := map[string]bool{}
for _, column := range []struct { for _, column := range []struct {
table string table string
name string name string
@@ -352,6 +402,10 @@ func ensureSchemaMigrations() error {
{"api_keys", "last_used_ip", "TEXT"}, {"api_keys", "last_used_ip", "TEXT"},
{"tasks", "ip", "TEXT"}, {"tasks", "ip", "TEXT"},
{"tasks", "user_agent", "TEXT"}, {"tasks", "user_agent", "TEXT"},
{"tasks", "cfg_network_down_mbps", "INTEGER NOT NULL DEFAULT 0"},
{"tasks", "cfg_network_up_mbps", "INTEGER NOT NULL DEFAULT 0"},
{"tasks", "cfg_io_read_mbps", "INTEGER NOT NULL DEFAULT 0"},
{"tasks", "cfg_io_write_mbps", "INTEGER NOT NULL DEFAULT 0"},
{"tasks", "cfg_assign_ipv4", "INTEGER"}, {"tasks", "cfg_assign_ipv4", "INTEGER"},
{"tasks", "cfg_ipv4_count", "INTEGER"}, {"tasks", "cfg_ipv4_count", "INTEGER"},
{"tasks", "cfg_public_ipv4s", "TEXT"}, {"tasks", "cfg_public_ipv4s", "TEXT"},
@@ -364,21 +418,61 @@ func ensureSchemaMigrations() error {
{"port_mappings", "host_ip", "TEXT"}, {"port_mappings", "host_ip", "TEXT"},
{"container_public_ipv4s", "prefix_len", "INTEGER"}, {"container_public_ipv4s", "prefix_len", "INTEGER"},
{"container_public_ipv4s", "gateway", "TEXT"}, {"container_public_ipv4s", "gateway", "TEXT"},
{"containers", "network_down_mbps", "INTEGER NOT NULL DEFAULT 0"},
{"containers", "network_up_mbps", "INTEGER NOT NULL DEFAULT 0"},
{"containers", "io_read_mbps", "INTEGER NOT NULL DEFAULT 0"},
{"containers", "io_write_mbps", "INTEGER NOT NULL DEFAULT 0"},
{"containers", "firewall_enabled", "INTEGER NOT NULL DEFAULT 0"}, {"containers", "firewall_enabled", "INTEGER NOT NULL DEFAULT 0"},
{"containers", "firewall_default_action", "TEXT NOT NULL DEFAULT 'DROP'"}, {"containers", "firewall_default_action", "TEXT NOT NULL DEFAULT 'DROP'"},
{"containers", "firewall_rules", "TEXT"}, {"containers", "firewall_rules", "TEXT"},
} { } {
if err := ensureColumn(column.table, column.name, column.def); err != nil { wasAdded, err := ensureColumn(column.table, column.name, column.def)
if err != nil {
return err
}
if wasAdded {
added[column.table+"."+column.name] = true
}
}
if added["containers.network_down_mbps"] || added["containers.network_up_mbps"] {
if _, err := db.Exec(`UPDATE containers
SET network_down_mbps = COALESCE(NULLIF(network_down_mbps, 0), COALESCE(network_bw_mbps, 0)),
network_up_mbps = COALESCE(NULLIF(network_up_mbps, 0), COALESCE(network_bw_mbps, 0))
WHERE COALESCE(network_bw_mbps, 0) > 0`); err != nil {
return err
}
}
if added["containers.io_read_mbps"] || added["containers.io_write_mbps"] {
if _, err := db.Exec(`UPDATE containers
SET io_read_mbps = COALESCE(NULLIF(io_read_mbps, 0), COALESCE(io_speed_mbps, 0)),
io_write_mbps = COALESCE(NULLIF(io_write_mbps, 0), COALESCE(io_speed_mbps, 0))
WHERE COALESCE(io_speed_mbps, 0) > 0`); err != nil {
return err
}
}
if added["tasks.cfg_network_down_mbps"] || added["tasks.cfg_network_up_mbps"] {
if _, err := db.Exec(`UPDATE tasks
SET cfg_network_down_mbps = COALESCE(NULLIF(cfg_network_down_mbps, 0), COALESCE(cfg_network_bw_mbps, 0)),
cfg_network_up_mbps = COALESCE(NULLIF(cfg_network_up_mbps, 0), COALESCE(cfg_network_bw_mbps, 0))
WHERE COALESCE(cfg_network_bw_mbps, 0) > 0`); err != nil {
return err
}
}
if added["tasks.cfg_io_read_mbps"] || added["tasks.cfg_io_write_mbps"] {
if _, err := db.Exec(`UPDATE tasks
SET cfg_io_read_mbps = COALESCE(NULLIF(cfg_io_read_mbps, 0), COALESCE(cfg_io_speed_mbps, 0)),
cfg_io_write_mbps = COALESCE(NULLIF(cfg_io_write_mbps, 0), COALESCE(cfg_io_speed_mbps, 0))
WHERE COALESCE(cfg_io_speed_mbps, 0) > 0`); err != nil {
return err return err
} }
} }
return nil return nil
} }
func ensureColumn(table, name, def string) error { func ensureColumn(table, name, def string) (bool, error) {
rows, err := db.Query("PRAGMA table_info(" + table + ")") rows, err := db.Query("PRAGMA table_info(" + table + ")")
if err != nil { if err != nil {
return err return false, err
} }
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
@@ -387,17 +481,17 @@ func ensureColumn(table, name, def string) error {
var notNull, pk int var notNull, pk int
var defaultValue interface{} var defaultValue interface{}
if err := rows.Scan(&cid, &columnName, &columnType, &notNull, &defaultValue, &pk); err != nil { if err := rows.Scan(&cid, &columnName, &columnType, &notNull, &defaultValue, &pk); err != nil {
return err return false, err
} }
if columnName == name { if columnName == name {
return nil return false, nil
} }
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return err return false, err
} }
_, err = db.Exec("ALTER TABLE " + table + " ADD COLUMN " + name + " " + def) _, err = db.Exec("ALTER TABLE " + table + " ADD COLUMN " + name + " " + def)
return err return err == nil, err
} }
func loadConfigFromDB() (*ClicdConfig, bool, error) { func loadConfigFromDB() (*ClicdConfig, bool, error) {
@@ -578,20 +672,25 @@ func saveMeta(tx *sql.Tx) error {
func saveContainers(tx *sql.Tx) error { func saveContainers(tx *sql.Tx) error {
for _, c := range AppConfig.Containers { for _, c := range AppConfig.Containers {
NormalizeContainerResourceAliases(&c)
if _, err := tx.Exec(`INSERT INTO containers ( if _, err := tx.Exec(`INSERT INTO containers (
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, mac_address, template, id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, mac_address, template,
vcpu, ram_mb, disk_gb, network_bw_mbps, monthly_traffic_gb, traffic_mode, traffic_in_gb, vcpu, ram_mb, disk_gb, network_bw_mbps, network_down_mbps, network_up_mbps,
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date, io_speed_mbps, monthly_traffic_gb, traffic_mode, traffic_in_gb,
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date,
io_speed_mbps, io_read_mbps, io_write_mbps,
status, ip, ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password, status, ip, ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password,
ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at, ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at,
snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time, snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by, snapshot_schedule_last_run, snapshot_schedule_next_run, snapshot_schedule_created_by,
policy_blocked, policy_blocked_reason, policy_blocked_at, policy_blocked, policy_blocked_reason, policy_blocked_at,
firewall_enabled, firewall_default_action, firewall_rules firewall_enabled, firewall_default_action, firewall_rules
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
c.ID, c.UUID, c.Name, c.Virtualization, c.LXCName, c.KVMName, c.DiskImage, c.MACAddress, c.Template, c.ID, c.UUID, c.Name, c.Virtualization, c.LXCName, c.KVMName, c.DiskImage, c.MACAddress, c.Template,
c.VCPU, c.RAMMB, c.DiskGB, c.NetworkBWMbps, c.MonthlyTrafficGB, c.TrafficMode, c.TrafficInGB, c.VCPU, c.RAMMB, c.DiskGB, c.NetworkBWMbps, c.NetworkDownMbps, c.NetworkUpMbps,
c.TrafficOutGB, c.TrafficUsedRX, c.TrafficUsedTX, c.TrafficResetDate, c.IOSpeedMBps, c.MonthlyTrafficGB, c.TrafficMode, c.TrafficInGB,
c.TrafficOutGB, c.TrafficUsedRX, c.TrafficUsedTX, c.TrafficResetDate,
c.IOSpeedMBps, c.IOReadMBps, c.IOWriteMBps,
c.Status, c.IP, c.IPv6, c.IPv6PrefixLen, c.IPv6Interface, c.VNCPort, c.SSHPort, c.SSHPassword, c.Status, c.IP, c.IPv6, c.IPv6PrefixLen, c.IPv6Interface, c.VNCPort, c.SSHPort, c.SSHPassword,
c.SSHHostKey, c.PortMappingLimit, c.SnapshotLimit, c.CreatedAt, c.ExpiresAt, c.SSHHostKey, c.PortMappingLimit, c.SnapshotLimit, c.CreatedAt, c.ExpiresAt,
boolInt(c.SnapshotScheduleEnabled), c.SnapshotScheduleIntervalHours, c.SnapshotScheduleTime, boolInt(c.SnapshotScheduleEnabled), c.SnapshotScheduleIntervalHours, c.SnapshotScheduleTime,
@@ -732,15 +831,19 @@ func saveTasksDB(tx *sql.Tx) error {
if _, err := tx.Exec(`INSERT INTO tasks( if _, err := tx.Exec(`INSERT INTO tasks(
id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent, id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent,
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb, cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb, cfg_network_bw_mbps, cfg_network_down_mbps, cfg_network_up_mbps,
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_io_read_mbps, cfg_io_write_mbps,
cfg_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit,
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses, cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_expires_at cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User, task.IP, task.UserAgent, task.ID, task.Type, task.ContainerID, task.ContainerName, task.Status, task.Error, task.CreatedAt, task.TemplateID, task.User, task.IP, task.UserAgent,
cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB, cfg.Name, cfg.Virtualization, cfg.TemplateID, cfg.VCPU, cfg.CPUPercent, cfg.RAMMB, cfg.DiskGB,
cfg.NetworkBWMbps, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB, cfg.NetworkBWMbps, cfg.NetworkDownMbps, cfg.NetworkUpMbps,
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.PortMappingCount, boolPtrInt(cfg.AssignNAT), cfg.SnapshotLimit, cfg.MonthlyTrafficGB, cfg.TrafficMode, cfg.TrafficInGB,
cfg.TrafficOutGB, cfg.IOSpeedMBps, cfg.IOReadMBps, cfg.IOWriteMBps,
cfg.PortMappingCount, boolPtrInt(cfg.AssignNAT), cfg.SnapshotLimit,
boolInt(cfg.AssignIPv4), cfg.IPv4Count, encodeStringSlice(cfg.PublicIPv4s), boolInt(cfg.AssignIPv4), cfg.IPv4Count, encodeStringSlice(cfg.PublicIPv4s),
boolInt(cfg.AssignIPv6), cfg.IPv6Count, encodeStringSlice(cfg.IPv6Addresses), boolInt(cfg.AssignIPv6), cfg.IPv6Count, encodeStringSlice(cfg.IPv6Addresses),
cfg.SSHAuthMode, cfg.SSHPassword, cfg.SSHPublicKey, cfg.ExpiresAt, cfg.SSHAuthMode, cfg.SSHPassword, cfg.SSHPublicKey, cfg.ExpiresAt,
@@ -788,8 +891,10 @@ func saveSnapshots(tx *sql.Tx) error {
func loadContainers() ([]Container, error) { func loadContainers() ([]Container, error) {
rows, err := db.Query(`SELECT rows, err := db.Query(`SELECT
id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, mac_address, template, id, uuid, name, virtualization, lxc_name, kvm_name, disk_image, mac_address, template,
vcpu, ram_mb, disk_gb, network_bw_mbps, monthly_traffic_gb, traffic_mode, traffic_in_gb, vcpu, ram_mb, disk_gb, network_bw_mbps, network_down_mbps, network_up_mbps,
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date, io_speed_mbps, monthly_traffic_gb, traffic_mode, traffic_in_gb,
traffic_out_gb, traffic_used_rx, traffic_used_tx, traffic_reset_date,
io_speed_mbps, io_read_mbps, io_write_mbps,
status, ip, ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password, status, ip, ipv6, ipv6_prefix_len, ipv6_interface, vnc_port, ssh_port, ssh_password,
ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at, ssh_host_key, port_mapping_limit, snapshot_limit, created_at, expires_at,
snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time, snapshot_schedule_enabled, snapshot_schedule_interval_hours, snapshot_schedule_time,
@@ -810,8 +915,10 @@ func loadContainers() ([]Container, error) {
var firewallRulesJSON sql.NullString var firewallRulesJSON sql.NullString
if err := rows.Scan( if err := rows.Scan(
&c.ID, &c.UUID, &c.Name, &c.Virtualization, &c.LXCName, &c.KVMName, &c.DiskImage, &c.MACAddress, &c.Template, &c.ID, &c.UUID, &c.Name, &c.Virtualization, &c.LXCName, &c.KVMName, &c.DiskImage, &c.MACAddress, &c.Template,
&c.VCPU, &c.RAMMB, &c.DiskGB, &c.NetworkBWMbps, &c.MonthlyTrafficGB, &c.TrafficMode, &c.TrafficInGB, &c.VCPU, &c.RAMMB, &c.DiskGB, &c.NetworkBWMbps, &c.NetworkDownMbps, &c.NetworkUpMbps,
&c.TrafficOutGB, &c.TrafficUsedRX, &c.TrafficUsedTX, &c.TrafficResetDate, &c.IOSpeedMBps, &c.MonthlyTrafficGB, &c.TrafficMode, &c.TrafficInGB,
&c.TrafficOutGB, &c.TrafficUsedRX, &c.TrafficUsedTX, &c.TrafficResetDate,
&c.IOSpeedMBps, &c.IOReadMBps, &c.IOWriteMBps,
&c.Status, &c.IP, &c.IPv6, &c.IPv6PrefixLen, &c.IPv6Interface, &c.VNCPort, &c.SSHPort, &c.SSHPassword, &c.Status, &c.IP, &c.IPv6, &c.IPv6PrefixLen, &c.IPv6Interface, &c.VNCPort, &c.SSHPort, &c.SSHPassword,
&c.SSHHostKey, &c.PortMappingLimit, &c.SnapshotLimit, &c.CreatedAt, &c.ExpiresAt, &c.SSHHostKey, &c.PortMappingLimit, &c.SnapshotLimit, &c.CreatedAt, &c.ExpiresAt,
&scheduleEnabled, &c.SnapshotScheduleIntervalHours, &c.SnapshotScheduleTime, &scheduleEnabled, &c.SnapshotScheduleIntervalHours, &c.SnapshotScheduleTime,
@@ -828,6 +935,7 @@ func loadContainers() ([]Container, error) {
if firewallRulesJSON.Valid && strings.TrimSpace(firewallRulesJSON.String) != "" { if firewallRulesJSON.Valid && strings.TrimSpace(firewallRulesJSON.String) != "" {
_ = json.Unmarshal([]byte(firewallRulesJSON.String), &c.FirewallRules) _ = json.Unmarshal([]byte(firewallRulesJSON.String), &c.FirewallRules)
} }
NormalizeContainerResourceAliases(&c)
result = append(result, c) result = append(result, c)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
@@ -1021,8 +1129,10 @@ func loadTasks() ([]SavedTask, error) {
rows, err := db.Query(`SELECT rows, err := db.Query(`SELECT
id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent, id, type, container_id, container_name, status, error, created_at, template_id, user, ip, user_agent,
cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb, cfg_name, cfg_virtualization, cfg_template_id, cfg_vcpu, cfg_cpu_percent, cfg_ram_mb, cfg_disk_gb,
cfg_network_bw_mbps, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb, cfg_network_bw_mbps, cfg_network_down_mbps, cfg_network_up_mbps,
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit, cfg_monthly_traffic_gb, cfg_traffic_mode, cfg_traffic_in_gb,
cfg_traffic_out_gb, cfg_io_speed_mbps, cfg_io_read_mbps, cfg_io_write_mbps,
cfg_port_mapping_count, cfg_assign_nat, cfg_snapshot_limit,
cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses, cfg_assign_ipv4, cfg_ipv4_count, cfg_public_ipv4s, cfg_assign_ipv6, cfg_ipv6_count, cfg_ipv6_addresses,
cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_expires_at cfg_ssh_auth_mode, cfg_ssh_password, cfg_ssh_public_key, cfg_expires_at
FROM tasks ORDER BY created_at, id`) FROM tasks ORDER BY created_at, id`)
@@ -1042,8 +1152,10 @@ func loadTasks() ([]SavedTask, error) {
if err := rows.Scan( if err := rows.Scan(
&t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User, &ip, &userAgent, &t.ID, &t.Type, &t.ContainerID, &t.ContainerName, &t.Status, &t.Error, &t.CreatedAt, &t.TemplateID, &t.User, &ip, &userAgent,
&cfg.Name, &cfg.Virtualization, &cfg.TemplateID, &cfg.VCPU, &cfg.CPUPercent, &cfg.RAMMB, &cfg.DiskGB, &cfg.Name, &cfg.Virtualization, &cfg.TemplateID, &cfg.VCPU, &cfg.CPUPercent, &cfg.RAMMB, &cfg.DiskGB,
&cfg.NetworkBWMbps, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB, &cfg.NetworkBWMbps, &cfg.NetworkDownMbps, &cfg.NetworkUpMbps,
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.PortMappingCount, &assignNAT, &cfg.SnapshotLimit, &cfg.MonthlyTrafficGB, &cfg.TrafficMode, &cfg.TrafficInGB,
&cfg.TrafficOutGB, &cfg.IOSpeedMBps, &cfg.IOReadMBps, &cfg.IOWriteMBps,
&cfg.PortMappingCount, &assignNAT, &cfg.SnapshotLimit,
&assignIPv4, &ipv4Count, &publicIPv4s, &assignIPv6, &ipv6Count, &ipv6Addresses, &assignIPv4, &ipv4Count, &publicIPv4s, &assignIPv6, &ipv6Count, &ipv6Addresses,
&sshAuthMode, &sshPassword, &sshPublicKey, &cfg.ExpiresAt, &sshAuthMode, &sshPassword, &sshPublicKey, &cfg.ExpiresAt,
); err != nil { ); err != nil {
@@ -1068,6 +1180,7 @@ func loadTasks() ([]SavedTask, error) {
cfg.SSHAuthMode = sshAuthMode.String cfg.SSHAuthMode = sshAuthMode.String
cfg.SSHPassword = sshPassword.String cfg.SSHPassword = sshPassword.String
cfg.SSHPublicKey = sshPublicKey.String cfg.SSHPublicKey = sshPublicKey.String
normalizeSavedTaskConfigLimits(&cfg)
result = append(result, t) result = append(result, t)
configs = append(configs, cfg) configs = append(configs, cfg)
} }
+59 -26
View File
@@ -346,6 +346,7 @@ func normalizeQCOW2(ctx context.Context, src, target string) error {
} }
func (m *Manager) CreateContainer(cfg lxc.ContainerConfig) error { func (m *Manager) CreateContainer(cfg lxc.ContainerConfig) error {
cfg.NormalizeResourceAliases()
image := FindImage(cfg.TemplateID) image := FindImage(cfg.TemplateID)
if image == nil { if image == nil {
return fmt.Errorf("KVM image not found: %s", cfg.TemplateID) return fmt.Errorf("KVM image not found: %s", cfg.TemplateID)
@@ -456,7 +457,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, ipv6List, ipv4List); err != nil { if err := createWindowsUnattendISO(unattendPath, cfg.Name, winAdminPassword, ipv6List, ipv4List); err != nil {
return nil, err return nil, err
} }
xml = windowsDomainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, ImagePath(image.ID), unattendPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps) xml = windowsDomainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, ImagePath(image.ID), unattendPath, mac, cfg.IOReadMBps, cfg.IOWriteMBps, cfg.NetworkDownMbps, cfg.NetworkUpMbps)
} else { } else {
if image.Desktop != "" { if image.Desktop != "" {
if cfg.RAMMB < 2048 { if cfg.RAMMB < 2048 {
@@ -472,7 +473,7 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, sshPublicKey, mac, ipv6List, ipv4List, *image, sshAuthMode); err != nil { if err := createSeedISO(seedPath, vmName, cfg.Name, sshPassword, sshPublicKey, mac, ipv6List, ipv4List, *image, sshAuthMode); err != nil {
return nil, err return nil, err
} }
xml = domainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, seedPath, mac, cfg.IOSpeedMBps, cfg.NetworkBWMbps, image.Desktop != "") xml = domainXML(vmName, int(cfg.VCPU), cfg.RAMMB, diskPath, seedPath, mac, cfg.IOReadMBps, cfg.IOWriteMBps, cfg.NetworkDownMbps, cfg.NetworkUpMbps, image.Desktop != "")
} }
xmlPath := filepath.Join(m.instanceDir(vmName), "domain.xml") xmlPath := filepath.Join(m.instanceDir(vmName), "domain.xml")
if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil { if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil {
@@ -542,12 +543,16 @@ func (m *Manager) defineContainer(id int, vmName string, cfg lxc.ContainerConfig
RAMMB: cfg.RAMMB, RAMMB: cfg.RAMMB,
DiskGB: cfg.DiskGB, DiskGB: cfg.DiskGB,
NetworkBWMbps: cfg.NetworkBWMbps, NetworkBWMbps: cfg.NetworkBWMbps,
NetworkDownMbps: cfg.NetworkDownMbps,
NetworkUpMbps: cfg.NetworkUpMbps,
MonthlyTrafficGB: cfg.MonthlyTrafficGB, MonthlyTrafficGB: cfg.MonthlyTrafficGB,
TrafficMode: trafficMode, TrafficMode: trafficMode,
TrafficInGB: cfg.TrafficInGB, TrafficInGB: cfg.TrafficInGB,
TrafficOutGB: cfg.TrafficOutGB, TrafficOutGB: cfg.TrafficOutGB,
TrafficResetDate: now[:7], TrafficResetDate: now[:7],
IOSpeedMBps: cfg.IOSpeedMBps, IOSpeedMBps: cfg.IOSpeedMBps,
IOReadMBps: cfg.IOReadMBps,
IOWriteMBps: cfg.IOWriteMBps,
PublicIPv4s: publicIPv4s, PublicIPv4s: publicIPv4s,
IPv6Addresses: ipv6Assignments, IPv6Addresses: ipv6Assignments,
Status: "stopped", Status: "stopped",
@@ -781,11 +786,15 @@ func (m *Manager) ReinstallContainer(id int, templateID string, authConfig ...lx
RAMMB: c.RAMMB, RAMMB: c.RAMMB,
DiskGB: c.DiskGB, DiskGB: c.DiskGB,
NetworkBWMbps: c.NetworkBWMbps, NetworkBWMbps: c.NetworkBWMbps,
NetworkDownMbps: c.NetworkDownMbps,
NetworkUpMbps: c.NetworkUpMbps,
MonthlyTrafficGB: c.MonthlyTrafficGB, MonthlyTrafficGB: c.MonthlyTrafficGB,
TrafficMode: c.TrafficMode, TrafficMode: c.TrafficMode,
TrafficInGB: c.TrafficInGB, TrafficInGB: c.TrafficInGB,
TrafficOutGB: c.TrafficOutGB, TrafficOutGB: c.TrafficOutGB,
IOSpeedMBps: c.IOSpeedMBps, IOSpeedMBps: c.IOSpeedMBps,
IOReadMBps: c.IOReadMBps,
IOWriteMBps: c.IOWriteMBps,
PortMappingCount: c.PortMappingLimit, PortMappingCount: c.PortMappingLimit,
SnapshotLimit: c.SnapshotLimit, SnapshotLimit: c.SnapshotLimit,
ExpiresAt: c.ExpiresAt, ExpiresAt: c.ExpiresAt,
@@ -882,6 +891,7 @@ func (m *Manager) ApplyContainerLimits(c *config.Container) error {
if c == nil || !c.IsKVM() { if c == nil || !c.IsKVM() {
return nil return nil
} }
config.NormalizeContainerResourceAliases(c)
if c.Status == "running" { if c.Status == "running" {
// Config already saved; domain definition will be refreshed on next start // Config already saved; domain definition will be refreshed on next start
return nil return nil
@@ -893,10 +903,10 @@ func (m *Manager) ApplyContainerLimits(c *config.Container) error {
if IsWindowsImage(c.Template) { if IsWindowsImage(c.Template) {
winISO := ImagePath(c.Template) winISO := ImagePath(c.Template)
unattendISO := existingWindowsUnattendISO(m.instanceDir(c.VirshName())) unattendISO := existingWindowsUnattendISO(m.instanceDir(c.VirshName()))
xml = windowsDomainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, winISO, unattendISO, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps) xml = windowsDomainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, winISO, unattendISO, c.MACAddress, c.IOReadMBps, c.IOWriteMBps, c.NetworkDownMbps, c.NetworkUpMbps)
} else { } else {
seedPath := filepath.Join(m.instanceDir(c.VirshName()), "seed.iso") seedPath := filepath.Join(m.instanceDir(c.VirshName()), "seed.iso")
xml = domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps, isKVMDesktopTemplate(c.Template)) xml = domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOReadMBps, c.IOWriteMBps, c.NetworkDownMbps, c.NetworkUpMbps, isKVMDesktopTemplate(c.Template))
} }
xmlPath := filepath.Join(m.instanceDir(c.VirshName()), "domain.xml") xmlPath := filepath.Join(m.instanceDir(c.VirshName()), "domain.xml")
if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil { if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil {
@@ -913,15 +923,16 @@ func (m *Manager) ensureDomainDefinition(c *config.Container) error {
if c == nil || !c.IsKVM() || c.DiskImage == "" || c.MACAddress == "" { if c == nil || !c.IsKVM() || c.DiskImage == "" || c.MACAddress == "" {
return nil return nil
} }
config.NormalizeContainerResourceAliases(c)
var xml string var xml string
xmlPath := filepath.Join(m.instanceDir(c.VirshName()), "domain.xml") xmlPath := filepath.Join(m.instanceDir(c.VirshName()), "domain.xml")
if IsWindowsImage(c.Template) { if IsWindowsImage(c.Template) {
winISO := ImagePath(c.Template) winISO := ImagePath(c.Template)
unattendISO := existingWindowsUnattendISO(m.instanceDir(c.VirshName())) unattendISO := existingWindowsUnattendISO(m.instanceDir(c.VirshName()))
xml = windowsDomainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, winISO, unattendISO, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps) xml = windowsDomainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, winISO, unattendISO, c.MACAddress, c.IOReadMBps, c.IOWriteMBps, c.NetworkDownMbps, c.NetworkUpMbps)
} else { } else {
seedPath := filepath.Join(m.instanceDir(c.VirshName()), "seed.iso") seedPath := filepath.Join(m.instanceDir(c.VirshName()), "seed.iso")
xml = domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOSpeedMBps, c.NetworkBWMbps, isKVMDesktopTemplate(c.Template)) xml = domainXML(c.VirshName(), int(c.VCPU), c.RAMMB, c.DiskImage, seedPath, c.MACAddress, c.IOReadMBps, c.IOWriteMBps, c.NetworkDownMbps, c.NetworkUpMbps, isKVMDesktopTemplate(c.Template))
} }
if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil { if err := os.WriteFile(xmlPath, []byte(xml), 0644); err != nil {
return err return err
@@ -2079,7 +2090,7 @@ func isKVMDesktopTemplate(templateID string) bool {
return image != nil && image.Desktop != "" return image != nil && image.Desktop != ""
} }
func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string, ioSpeedMBps int, networkBWMbps int, desktop bool) string { func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string, ioReadMBps int, ioWriteMBps int, networkDownMbps int, networkUpMbps int, desktop bool) string {
if vcpu < 1 { if vcpu < 1 {
vcpu = 1 vcpu = 1
} }
@@ -2087,21 +2098,32 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string,
ramMB = 512 ramMB = 512
} }
iotune := "" iotune := ""
if ioSpeedMBps > 0 { if ioReadMBps > 0 || ioWriteMBps > 0 {
bytesPerSecond := int64(ioSpeedMBps) * 1024 * 1024 var parts []string
if ioReadMBps > 0 {
parts = append(parts, fmt.Sprintf(" <read_bytes_sec>%d</read_bytes_sec>", int64(ioReadMBps)*1024*1024))
}
if ioWriteMBps > 0 {
parts = append(parts, fmt.Sprintf(" <write_bytes_sec>%d</write_bytes_sec>", int64(ioWriteMBps)*1024*1024))
}
iotune = fmt.Sprintf(` iotune = fmt.Sprintf(`
<iotune> <iotune>
<total_bytes_sec>%d</total_bytes_sec> %s
</iotune>`, bytesPerSecond) </iotune>`, strings.Join(parts, "\n"))
} }
bandwidth := "" bandwidth := ""
if networkBWMbps > 0 { if networkDownMbps > 0 || networkUpMbps > 0 {
averageKiB := networkBWMbps * 128 var parts []string
if networkDownMbps > 0 {
parts = append(parts, fmt.Sprintf(" <inbound average='%d'/>", networkDownMbps*128))
}
if networkUpMbps > 0 {
parts = append(parts, fmt.Sprintf(" <outbound average='%d'/>", networkUpMbps*128))
}
bandwidth = fmt.Sprintf(` bandwidth = fmt.Sprintf(`
<bandwidth> <bandwidth>
<inbound average='%d'/> %s
<outbound average='%d'/> </bandwidth>`, strings.Join(parts, "\n"))
</bandwidth>`, averageKiB, averageKiB)
} }
video := "<video><model type='virtio'/></video>" video := "<video><model type='virtio'/></video>"
input := "" input := ""
@@ -2158,7 +2180,7 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string,
</domain>`, xmlEscape(name), domainUUIDXML(name), ramMB, ramMB, vcpu, vcpu, xmlEscape(diskPath), iotune, xmlEscape(seedPath), xmlEscape(mac), bandwidth, input, video) </domain>`, xmlEscape(name), domainUUIDXML(name), ramMB, ramMB, vcpu, vcpu, xmlEscape(diskPath), iotune, xmlEscape(seedPath), xmlEscape(mac), bandwidth, input, video)
} }
func windowsDomainXML(name string, vcpu int, ramMB int, diskPath, winISOPath, unattendISOPath, mac string, ioSpeedMBps int, networkBWMbps int) string { func windowsDomainXML(name string, vcpu int, ramMB int, diskPath, winISOPath, unattendISOPath, mac string, ioReadMBps int, ioWriteMBps int, networkDownMbps int, networkUpMbps int) string {
if vcpu < 1 { if vcpu < 1 {
vcpu = 1 vcpu = 1
} }
@@ -2166,21 +2188,32 @@ func windowsDomainXML(name string, vcpu int, ramMB int, diskPath, winISOPath, un
ramMB = 2048 ramMB = 2048
} }
iotune := "" iotune := ""
if ioSpeedMBps > 0 { if ioReadMBps > 0 || ioWriteMBps > 0 {
bytesPerSecond := int64(ioSpeedMBps) * 1024 * 1024 var parts []string
if ioReadMBps > 0 {
parts = append(parts, fmt.Sprintf(" <read_bytes_sec>%d</read_bytes_sec>", int64(ioReadMBps)*1024*1024))
}
if ioWriteMBps > 0 {
parts = append(parts, fmt.Sprintf(" <write_bytes_sec>%d</write_bytes_sec>", int64(ioWriteMBps)*1024*1024))
}
iotune = fmt.Sprintf(` iotune = fmt.Sprintf(`
<iotune> <iotune>
<total_bytes_sec>%d</total_bytes_sec> %s
</iotune>`, bytesPerSecond) </iotune>`, strings.Join(parts, "\n"))
} }
bandwidth := "" bandwidth := ""
if networkBWMbps > 0 { if networkDownMbps > 0 || networkUpMbps > 0 {
averageKiB := networkBWMbps * 128 var parts []string
if networkDownMbps > 0 {
parts = append(parts, fmt.Sprintf(" <inbound average='%d'/>", networkDownMbps*128))
}
if networkUpMbps > 0 {
parts = append(parts, fmt.Sprintf(" <outbound average='%d'/>", networkUpMbps*128))
}
bandwidth = fmt.Sprintf(` bandwidth = fmt.Sprintf(`
<bandwidth> <bandwidth>
<inbound average='%d'/> %s
<outbound average='%d'/> </bandwidth>`, strings.Join(parts, "\n"))
</bandwidth>`, averageKiB, averageKiB)
} }
virtioWinISO := virtioWinISOPath() virtioWinISO := virtioWinISOPath()
unattendDisk := "" unattendDisk := ""
+103 -34
View File
@@ -226,11 +226,15 @@ type ContainerConfig struct {
RAMMB int `json:"ram_mb"` RAMMB int `json:"ram_mb"`
DiskGB int `json:"disk_gb"` DiskGB int `json:"disk_gb"`
NetworkBWMbps int `json:"network_bw_mbps"` NetworkBWMbps int `json:"network_bw_mbps"`
NetworkDownMbps int `json:"network_down_mbps"`
NetworkUpMbps int `json:"network_up_mbps"`
MonthlyTrafficGB int `json:"monthly_traffic_gb"` MonthlyTrafficGB int `json:"monthly_traffic_gb"`
TrafficMode string `json:"traffic_mode"` // "total" or "in_out" TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
TrafficInGB int `json:"traffic_in_gb"` // 0=unlimited TrafficInGB int `json:"traffic_in_gb"` // 0=unlimited
TrafficOutGB int `json:"traffic_out_gb"` // 0=unlimited TrafficOutGB int `json:"traffic_out_gb"` // 0=unlimited
IOSpeedMBps int `json:"io_speed_mbps"` IOSpeedMBps int `json:"io_speed_mbps"`
IOReadMBps int `json:"io_read_mbps"`
IOWriteMBps int `json:"io_write_mbps"`
ExtraPorts []int `json:"extra_ports"` ExtraPorts []int `json:"extra_ports"`
PortMappingCount int `json:"port_mapping_count"` PortMappingCount int `json:"port_mapping_count"`
AssignNAT *bool `json:"assign_nat,omitempty"` AssignNAT *bool `json:"assign_nat,omitempty"`
@@ -247,12 +251,48 @@ type ContainerConfig struct {
ExpiresAt string `json:"expires_at"` ExpiresAt string `json:"expires_at"`
} }
func (cfg *ContainerConfig) NormalizeResourceAliases() {
if cfg == nil {
return
}
if cfg.NetworkBWMbps < 0 {
cfg.NetworkBWMbps = 0
}
if cfg.NetworkDownMbps < 0 {
cfg.NetworkDownMbps = 0
}
if cfg.NetworkUpMbps < 0 {
cfg.NetworkUpMbps = 0
}
if cfg.NetworkDownMbps == 0 && cfg.NetworkUpMbps == 0 && cfg.NetworkBWMbps > 0 {
cfg.NetworkDownMbps = cfg.NetworkBWMbps
cfg.NetworkUpMbps = cfg.NetworkBWMbps
}
cfg.NetworkBWMbps = config.LegacySymmetricLimit(cfg.NetworkDownMbps, cfg.NetworkUpMbps)
if cfg.IOSpeedMBps < 0 {
cfg.IOSpeedMBps = 0
}
if cfg.IOReadMBps < 0 {
cfg.IOReadMBps = 0
}
if cfg.IOWriteMBps < 0 {
cfg.IOWriteMBps = 0
}
if cfg.IOReadMBps == 0 && cfg.IOWriteMBps == 0 && cfg.IOSpeedMBps > 0 {
cfg.IOReadMBps = cfg.IOSpeedMBps
cfg.IOWriteMBps = cfg.IOSpeedMBps
}
cfg.IOSpeedMBps = config.LegacySymmetricLimit(cfg.IOReadMBps, cfg.IOWriteMBps)
}
func (cfg ContainerConfig) WantsNAT() bool { func (cfg ContainerConfig) WantsNAT() bool {
return cfg.AssignNAT == nil || *cfg.AssignNAT return cfg.AssignNAT == nil || *cfg.AssignNAT
} }
// CreateContainer creates a new LXC container. Uses ct-{id} as LXC name internally. // CreateContainer creates a new LXC container. Uses ct-{id} as LXC name internally.
func (m *Manager) CreateContainer(cfg ContainerConfig) error { func (m *Manager) CreateContainer(cfg ContainerConfig) error {
cfg.NormalizeResourceAliases()
tmpl := FindTemplate(cfg.TemplateID) tmpl := FindTemplate(cfg.TemplateID)
if tmpl == nil { if tmpl == nil {
return fmt.Errorf("template not found: %s", cfg.TemplateID) return fmt.Errorf("template not found: %s", cfg.TemplateID)
@@ -389,12 +429,16 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
RAMMB: cfg.RAMMB, RAMMB: cfg.RAMMB,
DiskGB: cfg.DiskGB, DiskGB: cfg.DiskGB,
NetworkBWMbps: cfg.NetworkBWMbps, NetworkBWMbps: cfg.NetworkBWMbps,
NetworkDownMbps: cfg.NetworkDownMbps,
NetworkUpMbps: cfg.NetworkUpMbps,
MonthlyTrafficGB: cfg.MonthlyTrafficGB, MonthlyTrafficGB: cfg.MonthlyTrafficGB,
TrafficMode: trafficMode, TrafficMode: trafficMode,
TrafficInGB: cfg.TrafficInGB, TrafficInGB: cfg.TrafficInGB,
TrafficOutGB: cfg.TrafficOutGB, TrafficOutGB: cfg.TrafficOutGB,
TrafficResetDate: trafficResetDate, TrafficResetDate: trafficResetDate,
IOSpeedMBps: cfg.IOSpeedMBps, IOSpeedMBps: cfg.IOSpeedMBps,
IOReadMBps: cfg.IOReadMBps,
IOWriteMBps: cfg.IOWriteMBps,
Status: "stopped", Status: "stopped",
IP: "", IP: "",
PublicIPv4s: publicIPv4s, PublicIPv4s: publicIPv4s,
@@ -531,6 +575,7 @@ func (m *Manager) preconfigureSSH(rootfsPath, templateID string, sshAuthMode str
// applyResourceLimits applies cgroup v2 limits and mandatory security hardening to container config. // applyResourceLimits applies cgroup v2 limits and mandatory security hardening to container config.
func (m *Manager) applyResourceLimits(lxcName string, cfg ContainerConfig) error { func (m *Manager) applyResourceLimits(lxcName string, cfg ContainerConfig) error {
cfg.NormalizeResourceAliases()
configFile := filepath.Join(m.LxcPath, lxcName, "config") configFile := filepath.Join(m.LxcPath, lxcName, "config")
data, err := os.ReadFile(configFile) data, err := os.ReadFile(configFile)
@@ -599,12 +644,12 @@ func (m *Manager) applyResourceLimits(lxcName string, cfg ContainerConfig) error
ramBytes := int64(cfg.RAMMB) * 1024 * 1024 ramBytes := int64(cfg.RAMMB) * 1024 * 1024
newLines = append(newLines, fmt.Sprintf("lxc.cgroup2.memory.max = %d", ramBytes)) newLines = append(newLines, fmt.Sprintf("lxc.cgroup2.memory.max = %d", ramBytes))
} }
if cfg.IOSpeedMBps > 0 { if cfg.IOReadMBps > 0 || cfg.IOWriteMBps > 0 {
// Note: lxc.cgroup2.io.max is skipped for unprivileged containers because // Note: lxc.cgroup2.io.max is skipped for unprivileged containers because
// LXC's cgfsng_setup_limits cannot resolve host device numbers (e.g. 8:1) // LXC's cgfsng_setup_limits cannot resolve host device numbers (e.g. 8:1)
// in the unprivileged namespace context. // in the unprivileged namespace context.
// IO limits are instead applied post-start via direct cgroup2 writes. // IO limits are instead applied post-start via direct cgroup2 writes.
fmt.Printf("Info: IO limit (%d MB/s) for %s will be applied post-start via cgroup2\n", cfg.IOSpeedMBps, lxcName) fmt.Printf("Info: IO limit (read=%d MB/s write=%d MB/s) for %s will be applied post-start via cgroup2\n", cfg.IOReadMBps, cfg.IOWriteMBps, lxcName)
} }
newContent := strings.Join(newLines, "\n") newContent := strings.Join(newLines, "\n")
@@ -614,18 +659,28 @@ func (m *Manager) applyResourceLimits(lxcName string, cfg ContainerConfig) error
return nil return nil
} }
func (m *Manager) ioLimitLines(lxcName string, mbps int) ([]string, error) { func (m *Manager) ioLimitLines(lxcName string, readMBps int, writeMBps int) ([]string, error) {
if mbps <= 0 { if readMBps < 0 {
return nil, nil readMBps = 0
}
if writeMBps < 0 {
writeMBps = 0
} }
devices, err := m.rootfsBlockDevices(lxcName) devices, err := m.rootfsBlockDevices(lxcName)
if err != nil { if err != nil {
return nil, err return nil, err
} }
ioBytes := mbps * 1024 * 1024 readValue := "max"
if readMBps > 0 {
readValue = strconv.Itoa(readMBps * 1024 * 1024)
}
writeValue := "max"
if writeMBps > 0 {
writeValue = strconv.Itoa(writeMBps * 1024 * 1024)
}
lines := make([]string, 0, len(devices)) lines := make([]string, 0, len(devices))
for _, device := range devices { for _, device := range devices {
lines = append(lines, fmt.Sprintf("%s rbps=%d wbps=%d", device, ioBytes, ioBytes)) lines = append(lines, fmt.Sprintf("%s rbps=%s wbps=%s", device, readValue, writeValue))
} }
return lines, nil return lines, nil
} }
@@ -1253,8 +1308,12 @@ func (m *Manager) StartContainer(id int) error {
RAMMB: c.RAMMB, RAMMB: c.RAMMB,
DiskGB: c.DiskGB, DiskGB: c.DiskGB,
NetworkBWMbps: c.NetworkBWMbps, NetworkBWMbps: c.NetworkBWMbps,
NetworkDownMbps: c.NetworkDownMbps,
NetworkUpMbps: c.NetworkUpMbps,
MonthlyTrafficGB: c.MonthlyTrafficGB, MonthlyTrafficGB: c.MonthlyTrafficGB,
IOSpeedMBps: c.IOSpeedMBps, IOSpeedMBps: c.IOSpeedMBps,
IOReadMBps: c.IOReadMBps,
IOWriteMBps: c.IOWriteMBps,
AssignIPv6: c.IPv6 != "" || len(c.IPv6Addresses) > 0, AssignIPv6: c.IPv6 != "" || len(c.IPv6Addresses) > 0,
ExpiresAt: c.ExpiresAt, ExpiresAt: c.ExpiresAt,
}); err != nil { }); err != nil {
@@ -1378,6 +1437,7 @@ func (m *Manager) ApplyContainerLimits(c *config.Container) error {
if c == nil || c.Status != "running" { if c == nil || c.Status != "running" {
return nil return nil
} }
config.NormalizeContainerResourceAliases(c)
lxcName := c.LxcName() lxcName := c.LxcName()
// CPU: write cpu.max // CPU: write cpu.max
@@ -1400,48 +1460,52 @@ func (m *Manager) ApplyContainerLimits(c *config.Container) error {
os.WriteFile(path, []byte(memLine), 0644) os.WriteFile(path, []byte(memLine), 0644)
} }
// IO speed: write io.max // IO speed: write io.max, including max values to clear old per-direction limits.
if c.IOSpeedMBps > 0 { ioLines, err := m.ioLimitLines(lxcName, c.IOReadMBps, c.IOWriteMBps)
ioLines, err := m.ioLimitLines(lxcName, c.IOSpeedMBps) if err != nil {
if err != nil { return err
return err }
} ioLine := strings.Join(ioLines, "\n")
ioLine := strings.Join(ioLines, "\n") for _, path := range []string{
for _, path := range []string{ fmt.Sprintf("/sys/fs/cgroup/lxc/%s/io.max", lxcName),
fmt.Sprintf("/sys/fs/cgroup/lxc/%s/io.max", lxcName), fmt.Sprintf("/sys/fs/cgroup/lxc.payload.%s/io.max", lxcName),
fmt.Sprintf("/sys/fs/cgroup/lxc.payload.%s/io.max", lxcName), } {
} { os.WriteFile(path, []byte(ioLine), 0644)
os.WriteFile(path, []byte(ioLine), 0644)
}
} }
// Network bandwidth // Network bandwidth
if c.NetworkBWMbps > 0 { m.applyBandwidthLimit(lxcName, c.NetworkDownMbps, c.NetworkUpMbps)
m.applyBandwidthLimit(lxcName, c.NetworkBWMbps)
} else {
m.cleanupBandwidthLimit(lxcName)
}
return nil return nil
} }
func (m *Manager) applyBandwidthLimit(lxcName string, mbps int) { func (m *Manager) applyBandwidthLimit(lxcName string, downMbps int, upMbps int) {
veth := m.getContainerVethByNS(lxcName) veth := m.getContainerVethByNS(lxcName)
if veth == "" { if veth == "" {
fmt.Printf("Warning: could not find veth for %s\n", lxcName) fmt.Printf("Warning: could not find veth for %s\n", lxcName)
return return
} }
rate := fmt.Sprintf("%dmbit", mbps)
burst := fmt.Sprintf("%dkbit", mbps*100)
exec.Command("tc", "qdisc", "del", "dev", veth, "root").Run() exec.Command("tc", "qdisc", "del", "dev", veth, "root").Run()
exec.Command("tc", "qdisc", "add", "dev", veth, "root", "handle", "1:", "htb", "default", "10").Run() exec.Command("tc", "qdisc", "del", "dev", veth, "ingress").Run()
exec.Command("tc", "class", "add", "dev", veth, "parent", "1:", "classid", "1:10", "htb", "rate", rate, "burst", burst).Run() if downMbps > 0 {
fmt.Printf("Bandwidth limit: %s = %d Mbps on %s\n", lxcName, mbps, veth) rate := fmt.Sprintf("%dmbit", downMbps)
burst := fmt.Sprintf("%dkbit", downMbps*100)
exec.Command("tc", "qdisc", "add", "dev", veth, "root", "handle", "1:", "htb", "default", "10").Run()
exec.Command("tc", "class", "add", "dev", veth, "parent", "1:", "classid", "1:10", "htb", "rate", rate, "burst", burst).Run()
}
if upMbps > 0 {
rate := fmt.Sprintf("%dmbit", upMbps)
burst := fmt.Sprintf("%dkbit", upMbps*100)
exec.Command("tc", "qdisc", "add", "dev", veth, "handle", "ffff:", "ingress").Run()
exec.Command("tc", "filter", "add", "dev", veth, "parent", "ffff:", "protocol", "all", "u32", "match", "u32", "0", "0", "police", "rate", rate, "burst", burst, "drop", "flowid", ":1").Run()
}
fmt.Printf("Bandwidth limit: %s down=%d Mbps up=%d Mbps on %s\n", lxcName, downMbps, upMbps, veth)
} }
func (m *Manager) cleanupBandwidthLimit(lxcName string) { func (m *Manager) cleanupBandwidthLimit(lxcName string) {
veth := m.getContainerVethByNS(lxcName) veth := m.getContainerVethByNS(lxcName)
if veth != "" { if veth != "" {
exec.Command("tc", "qdisc", "del", "dev", veth, "root").Run() exec.Command("tc", "qdisc", "del", "dev", veth, "root").Run()
exec.Command("tc", "qdisc", "del", "dev", veth, "ingress").Run()
} }
} }
@@ -2447,6 +2511,8 @@ func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) {
RAMMB: 512, RAMMB: 512,
DiskGB: 10, DiskGB: 10,
NetworkBWMbps: 100, NetworkBWMbps: 100,
NetworkDownMbps: 100,
NetworkUpMbps: 100,
MonthlyTrafficGB: 1000, MonthlyTrafficGB: 1000,
TrafficMode: "total", TrafficMode: "total",
Status: status, Status: status,
@@ -2610,8 +2676,12 @@ func (m *Manager) ReinstallContainer(id int, templateID string, authConfig ...Co
RAMMB: c.RAMMB, RAMMB: c.RAMMB,
DiskGB: c.DiskGB, DiskGB: c.DiskGB,
NetworkBWMbps: c.NetworkBWMbps, NetworkBWMbps: c.NetworkBWMbps,
NetworkDownMbps: c.NetworkDownMbps,
NetworkUpMbps: c.NetworkUpMbps,
MonthlyTrafficGB: c.MonthlyTrafficGB, MonthlyTrafficGB: c.MonthlyTrafficGB,
IOSpeedMBps: c.IOSpeedMBps, IOSpeedMBps: c.IOSpeedMBps,
IOReadMBps: c.IOReadMBps,
IOWriteMBps: c.IOWriteMBps,
AssignIPv6: c.IPv6 != "" || len(c.IPv6Addresses) > 0, AssignIPv6: c.IPv6 != "" || len(c.IPv6Addresses) > 0,
ExpiresAt: c.ExpiresAt, ExpiresAt: c.ExpiresAt,
} }
@@ -2694,9 +2764,8 @@ func (m *Manager) ReinstallContainer(id int, templateID string, authConfig ...Co
} }
} }
// Apply bandwidth limit after reinstall // Apply bandwidth limit after reinstall
if c.NetworkBWMbps > 0 { config.NormalizeContainerResourceAliases(c)
m.applyBandwidthLimit(c.LxcName(), c.NetworkBWMbps) m.applyBandwidthLimit(c.LxcName(), c.NetworkDownMbps, c.NetworkUpMbps)
}
if c.IPv6 != "" || len(c.IPv6Addresses) > 0 { if c.IPv6 != "" || len(c.IPv6Addresses) > 0 {
if err := m.ApplyIPv6(id); err != nil { if err := m.ApplyIPv6(id); err != nil {
fmt.Printf("Warning: failed to apply IPv6 after reinstall: %v\n", err) fmt.Printf("Warning: failed to apply IPv6 after reinstall: %v\n", err)
+1 -1
View File
@@ -1 +1 @@
+1 -1
View File
@@ -1,7 +1,7 @@
package version package version
var ( var (
Version = "1.1.18" Version = "1.1.19"
Repo = "MengMengCode/CLICD" Repo = "MengMengCode/CLICD"
) )
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "clicd-frontend", "name": "clicd-frontend",
"private": true, "private": true,
"version": "1.1.18", "version": "1.1.19",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+8 -1
View File
@@ -90,7 +90,7 @@ export default function ContainerCard({ container, onRefresh }: ContainerCardPro
</div> </div>
<div className="flex items-center gap-2 text-sm text-gray-600"> <div className="flex items-center gap-2 text-sm text-gray-600">
<Globe className="w-3.5 h-3.5" /> <Globe className="w-3.5 h-3.5" />
<span>{container.network_bw_mbps} Mbps</span> <span>{formatNetworkLimit(container)}</span>
</div> </div>
</div> </div>
@@ -140,3 +140,10 @@ export default function ContainerCard({ container, onRefresh }: ContainerCardPro
</div> </div>
) )
} }
function formatNetworkLimit(container: { network_bw_mbps?: number; network_down_mbps?: number; network_up_mbps?: number }) {
const down = Math.max(0, Number(container.network_down_mbps || container.network_bw_mbps || 0))
const up = Math.max(0, Number(container.network_up_mbps || container.network_bw_mbps || 0))
if (down === 0 && up === 0) return '不限速'
return `${down || '不限'} / 上 ${up || '不限'} Mbps`
}
@@ -21,11 +21,15 @@ const defaultForm: CreateContainerRequest = {
ram_mb: 512, ram_mb: 512,
disk_gb: 10, disk_gb: 10,
network_bw_mbps: 0, network_bw_mbps: 0,
network_down_mbps: 0,
network_up_mbps: 0,
monthly_traffic_gb: 0, monthly_traffic_gb: 0,
traffic_mode: 'total', traffic_mode: 'total',
traffic_in_gb: 0, traffic_in_gb: 0,
traffic_out_gb: 0, traffic_out_gb: 0,
io_speed_mbps: 0, io_speed_mbps: 0,
io_read_mbps: 0,
io_write_mbps: 0,
extra_ports: [], extra_ports: [],
port_mapping_count: 2, port_mapping_count: 2,
assign_nat: true, assign_nat: true,
@@ -498,7 +502,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</Field> </Field>
</div> </div>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<Field label="磁盘 (GB)"> <Field label="磁盘 (GB)">
<NumberInput <NumberInput
value={form.disk_gb} value={form.disk_gb}
@@ -509,12 +513,20 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
/> />
{resourceErrors.disk_gb && <p className="mt-1 text-xs text-red-500">{resourceErrors.disk_gb}</p>} {resourceErrors.disk_gb && <p className="mt-1 text-xs text-red-500">{resourceErrors.disk_gb}</p>}
</Field> </Field>
<Field label="带宽 (Mbps)"> <div className="grid grid-cols-2 gap-3 md:col-span-2">
<NumberInput value={form.network_bw_mbps} min={0} onChange={(value) => setForm({ ...form, network_bw_mbps: value })} /> <Field label="下行带宽 (Mbps)">
</Field> <NumberInput value={form.network_down_mbps} min={0} onChange={(value) => setForm({ ...form, network_down_mbps: value, network_bw_mbps: symmetricLimit(value, form.network_up_mbps) })} />
<Field label="IO 速度 (MB/s)"> </Field>
<NumberInput value={form.io_speed_mbps} min={0} onChange={(value) => setForm({ ...form, io_speed_mbps: value })} /> <Field label="上行带宽 (Mbps)">
</Field> <NumberInput value={form.network_up_mbps} min={0} onChange={(value) => setForm({ ...form, network_up_mbps: value, network_bw_mbps: symmetricLimit(form.network_down_mbps, value) })} />
</Field>
<Field label="读取 IO (MB/s)">
<NumberInput value={form.io_read_mbps} min={0} onChange={(value) => setForm({ ...form, io_read_mbps: value, io_speed_mbps: symmetricLimit(value, form.io_write_mbps) })} />
</Field>
<Field label="写入 IO (MB/s)">
<NumberInput value={form.io_write_mbps} min={0} onChange={(value) => setForm({ ...form, io_write_mbps: value, io_speed_mbps: symmetricLimit(form.io_read_mbps, value) })} />
</Field>
</div>
</div> </div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
@@ -779,5 +791,14 @@ function formatNATPortCount(count: number, language: Language) {
: `将分配 ${count} 个 NAT 端口` : `将分配 ${count} 个 NAT 端口`
} }
function symmetricLimit(a: number, b: number) {
const left = Math.max(0, Number(a) || 0)
const right = Math.max(0, Number(b) || 0)
if (left === right) return left
if (left === 0) return right
if (right === 0) return left
return Math.min(left, right)
}
const inputClass = const inputClass =
'w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white focus:outline-none focus:ring-2 focus:ring-black focus:border-black' 'w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white focus:outline-none focus:ring-2 focus:ring-black focus:border-black'
+30 -16
View File
@@ -14,6 +14,7 @@ import {
X, X,
} from 'lucide-react' } from 'lucide-react'
import api, { APIResponse, Container } from '../services/api' import api, { APIResponse, Container } from '../services/api'
import { useLanguage } from '../contexts/LanguageContext'
import { copyToClipboard } from '../utils/clipboard' import { copyToClipboard } from '../utils/clipboard'
interface ApiKeyItem { interface ApiKeyItem {
@@ -238,6 +239,7 @@ const emptyForm = (): ApiKeyForm => ({
}) })
export default function ApiIntegration() { export default function ApiIntegration() {
const { t } = useLanguage()
const [keys, setKeys] = useState<ApiKeyItem[]>([]) const [keys, setKeys] = useState<ApiKeyItem[]>([])
const [containers, setContainers] = useState<Container[]>([]) const [containers, setContainers] = useState<Container[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -329,7 +331,7 @@ export default function ApiIntegration() {
} }
const deleteKey = async (id: string) => { const deleteKey = async (id: string) => {
if (!window.confirm('确定删除这个 API Key 吗?')) return if (!window.confirm(t('确定删除这个 API Key 吗?'))) return
try { try {
await api.delete(`/api-keys/${id}`) await api.delete(`/api-keys/${id}`)
setKeys(prev => prev.filter(k => k.id !== id)) setKeys(prev => prev.filter(k => k.id !== id))
@@ -730,11 +732,15 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
ram_mb: 512, ram_mb: 512,
disk_gb: 10, disk_gb: 10,
network_bw_mbps: 0, network_bw_mbps: 0,
network_down_mbps: 100,
network_up_mbps: 20,
monthly_traffic_gb: 0, monthly_traffic_gb: 0,
traffic_mode: 'total', traffic_mode: 'total',
traffic_in_gb: 0, traffic_in_gb: 0,
traffic_out_gb: 0, traffic_out_gb: 0,
io_speed_mbps: 0, io_speed_mbps: 0,
io_read_mbps: 80,
io_write_mbps: 30,
extra_ports: [8080], extra_ports: [8080],
port_mapping_count: 2, port_mapping_count: 2,
assign_nat: true, assign_nat: true,
@@ -765,8 +771,12 @@ const requestBodySamples: Record<string, Record<string, unknown>> = {
'PUT /api/v1/containers/{id}/resource-limit': { 'PUT /api/v1/containers/{id}/resource-limit': {
vcpu: 1, vcpu: 1,
ram_mb: 512, ram_mb: 512,
io_speed_mbps: 0, network_down_mbps: 100,
network_bw_mbps: 0, network_up_mbps: 20,
io_read_mbps: 80,
io_write_mbps: 30,
network_bw_mbps: 20,
io_speed_mbps: 30,
}, },
'PUT /api/v1/containers/{id}/expiry': { expires_at: '2026-12-31 23:59:59' }, 'PUT /api/v1/containers/{id}/expiry': { expires_at: '2026-12-31 23:59:59' },
'POST /api/v1/containers/{id}/reset-password': { password: 'NewPass123456' }, 'POST /api/v1/containers/{id}/reset-password': { password: 'NewPass123456' },
@@ -1098,11 +1108,11 @@ const responseSamples: Record<string, unknown> = {
'GET /api/v1/security/settings': { success: true, data: { auto_shutdown: false } }, 'GET /api/v1/security/settings': { success: true, data: { auto_shutdown: false } },
'PUT /api/v1/security/settings': { success: true, data: { auto_shutdown: false } }, 'PUT /api/v1/security/settings': { success: true, data: { auto_shutdown: false } },
'GET /api/v1/swap': { success: true, data: { total_mb: 16383, used_mb: 0, free_mb: 16383, enabled: true, swap_file: '/swapfile' } }, 'GET /api/v1/swap': { success: true, data: { total_mb: 16383, used_mb: 0, free_mb: 16383, enabled: true, swap_file: '/swapfile' } },
'POST /api/v1/swap': { success: true, message: 'SWAP 已调整为 16384 MB', data: { total_mb: 16383, used_mb: 0, free_mb: 16383, enabled: true, swap_file: '/swapfile' } }, 'POST /api/v1/swap': { success: true, message: 'SWAP adjusted to 16384 MB', data: { total_mb: 16383, used_mb: 0, free_mb: 16383, enabled: true, swap_file: '/swapfile' } },
'POST /api/v1/batch-create': { success: true, data: ['task-12'] }, 'POST /api/v1/batch-create': { success: true, data: ['task-12'] },
'POST /api/v1/batch-action': { success: true, data: ['task-13'] }, 'POST /api/v1/batch-action': { success: true, data: ['task-13'] },
'POST /api/v1/ssh-ticket': { success: true, data: { ticket: '***60秒有效票据***' } }, 'POST /api/v1/ssh-ticket': { success: true, data: { ticket: '***60-second valid ticket***' } },
'POST /api/v1/vnc-ticket': { success: true, data: { ticket: '***60秒有效票据***' } }, 'POST /api/v1/vnc-ticket': { success: true, data: { ticket: '***60-second valid ticket***' } },
'POST /api/v1/sub-user/create': { 'POST /api/v1/sub-user/create': {
success: true, success: true,
message: 'Sub-user created', message: 'Sub-user created',
@@ -1167,29 +1177,33 @@ function examplePathFor(path: string) {
function endpointNoteFor(key: string) { function endpointNoteFor(key: string) {
const notes: string[] = [] const notes: string[] = []
if (key === 'POST /api/v1/containers') { if (key === 'POST /api/v1/containers') {
notes.push('Linux 创建支持 ssh_auth_mode=auto_password|password|key;公网 IPv4IPv6 与 NAT 可通过 assign_natassign_ipv4assign_ipv6 组合使用。') notes.push('Linux container creation supports ssh_auth_mode=auto_password|password|key. Public IPv4, IPv6, and NAT can be configured with assign_nat, assign_ipv4, and assign_ipv6.')
notes.push('Supports independent upload/download bandwidth limits and read/write I/O limits. network_bw_mbps and io_speed_mbps are deprecated symmetric compatibility aliases. New integrations should use network_down_mbps, network_up_mbps, io_read_mbps, and io_write_mbps.')
} }
if (key === 'POST /api/v1/containers/{id}/reinstall') { if (key === 'POST /api/v1/containers/{id}/reinstall') {
notes.push('重装支持 ssh_auth_mode=keep|auto_password|password|keykeep 仅用于重装,未传 SSH 字段时保持原有行为。') notes.push('Reinstall supports ssh_auth_mode=keep|auto_password|password|key. keep is only for reinstall requests; if SSH fields are omitted, the existing behavior is kept.')
} }
if (key === 'POST /api/v1/batch-create') { if (key === 'POST /api/v1/batch-create') {
notes.push('批量创建的单个 containers[] 项支持与 POST /api/v1/containers 相同的网络和 SSH 认证字段。') notes.push('Each containers[] item in batch creation supports the same network and SSH authentication fields as POST /api/v1/containers.')
}
if (key === 'PUT /api/v1/containers/{id}/resource-limit') {
notes.push('Supports independent download/upload bandwidth limits and read/write I/O limits. Omitted fields keep their current values, and explicitly passing 0 makes that direction unlimited. network_bw_mbps and io_speed_mbps are deprecated symmetric compatibility aliases.')
} }
if (key === 'PUT /api/v1/containers/{id}/firewall') { if (key === 'PUT /api/v1/containers/{id}/firewall') {
notes.push('兼容旧请求:default_action 可不传,不传时保留现有策略;rule.network 可不传,不传按 ipv4 处理。default_action: DROP=未命中规则时拒绝, ACCEPT=未命中规则时放行。network: ipv4=IPv4 NAT/公网 IPv4, ipv6=IPv6, all=同时应用到 IPv4 IPv6。NAT 入站规则的 port 填容器内端口,不是宿主机公网端口。') notes.push('Backward compatible: default_action is optional; if omitted, the existing policy is kept. rule.network is optional; if omitted, it is treated as ipv4. default_action: DROP=deny unmatched traffic, ACCEPT=allow unmatched traffic. network: ipv4=IPv4 NAT/public IPv4, ipv6=IPv6, all=apply to both IPv4 and IPv6. For NAT inbound rules, port is the container internal port, not the host public port.')
} }
if (key === 'POST /api/v1/batch-action') { if (key === 'POST /api/v1/batch-action') {
notes.push('action=reinstall 时可追加 template_idssh_auth_modessh_passwordssh_public_key;其他 action 会忽略这些重装字段。') notes.push('When action=reinstall, you can include template_id, ssh_auth_mode, ssh_password, and ssh_public_key. Other actions ignore these reinstall fields.')
} }
if (key === 'PUT /api/v1/routing') { if (key === 'PUT /api/v1/routing') {
notes.push('更新公网地址池需要 routing:write;已分配给容器的地址不能从池中移除。') notes.push('Updating public address pools requires routing:write. Addresses already assigned to containers cannot be removed from the pool.')
} }
if (key === 'POST /api/v1/routing/ipv4-scan') { if (key === 'POST /api/v1/routing/ipv4-scan') {
notes.push('扫描公网 IPv4 段需要 routing:writeverify=true 时会尝试校验地址可用性。') notes.push('Scanning public IPv4 prefixes requires routing:write. When verify=true, the API also attempts to check address availability.')
} }
if (key.includes('/vnc-ticket')) notes.push('WebVNC 仅适用于 KVM 虚拟机;LXC 容器会返回 VNC console is only available for KVM VMs') if (key.includes('/vnc-ticket')) notes.push('WebVNC only applies to KVM VMs; LXC containers return "VNC console is only available for KVM VMs".')
if (key.includes('/containers/{id}/delete') || key.includes('/batch-action')) notes.push('该接口会进入任务队列,请随后调用 GET /api/v1/tasks 查看执行状态。') if (key.includes('/containers/{id}/delete') || key.includes('/batch-action')) notes.push('This API enters the task queue. Call GET /api/v1/tasks afterward to check execution status.')
if (key.includes('/reset-password') || key.includes('/api-keys') || key.includes('/sub-user')) notes.push('样例中的密钥、密码和票据已脱敏;创建类接口的完整密钥只在创建响应中出现一次。') if (key.includes('/reset-password') || key.includes('/api-keys') || key.includes('/sub-user')) notes.push('Keys, passwords, and tickets in examples are masked. Full secrets from create APIs appear only once in the creation response.')
return notes.join(' ') return notes.join(' ')
} }
+71 -16
View File
@@ -149,7 +149,7 @@ export default function ContainerDetail() {
const [trafficEdit, setTrafficEdit] = useState({ mode: 'total', monthly: 0, inGB: 0, outGB: 0 }) const [trafficEdit, setTrafficEdit] = useState({ mode: 'total', monthly: 0, inGB: 0, outGB: 0 })
const [savingTraffic, setSavingTraffic] = useState(false) const [savingTraffic, setSavingTraffic] = useState(false)
const [showResourceEdit, setShowResourceEdit] = useState(false) const [showResourceEdit, setShowResourceEdit] = useState(false)
const [resourceEdit, setResourceEdit] = useState({ vcpu: 1, ramMb: 512, ioMbps: 500, bwMbps: 100 }) const [resourceEdit, setResourceEdit] = useState({ vcpu: 1, ramMb: 512, networkDownMbps: 0, networkUpMbps: 0, ioReadMbps: 0, ioWriteMbps: 0 })
const [savingResource, setSavingResource] = useState(false) const [savingResource, setSavingResource] = useState(false)
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const [showResetPassword, setShowResetPassword] = useState(false) const [showResetPassword, setShowResetPassword] = useState(false)
@@ -395,8 +395,10 @@ export default function ContainerDetail() {
setResourceEdit({ setResourceEdit({
vcpu: container.vcpu, vcpu: container.vcpu,
ramMb: container.ram_mb, ramMb: container.ram_mb,
ioMbps: container.io_speed_mbps || 0, networkDownMbps: resourceLimitValue(container.network_down_mbps, container.network_bw_mbps),
bwMbps: container.network_bw_mbps || 0, networkUpMbps: resourceLimitValue(container.network_up_mbps, container.network_bw_mbps),
ioReadMbps: resourceLimitValue(container.io_read_mbps, container.io_speed_mbps),
ioWriteMbps: resourceLimitValue(container.io_write_mbps, container.io_speed_mbps),
}) })
setShowResourceEdit(true) setShowResourceEdit(true)
} }
@@ -408,8 +410,12 @@ export default function ContainerDetail() {
await updateResourceLimit(container.id, { await updateResourceLimit(container.id, {
vcpu: resourceEdit.vcpu, vcpu: resourceEdit.vcpu,
ram_mb: resourceEdit.ramMb, ram_mb: resourceEdit.ramMb,
io_speed_mbps: resourceEdit.ioMbps, network_down_mbps: resourceEdit.networkDownMbps,
network_bw_mbps: resourceEdit.bwMbps, network_up_mbps: resourceEdit.networkUpMbps,
network_bw_mbps: symmetricLimit(resourceEdit.networkDownMbps, resourceEdit.networkUpMbps),
io_read_mbps: resourceEdit.ioReadMbps,
io_write_mbps: resourceEdit.ioWriteMbps,
io_speed_mbps: symmetricLimit(resourceEdit.ioReadMbps, resourceEdit.ioWriteMbps),
}) })
setShowResourceEdit(false) setShowResourceEdit(false)
fetchContainer() fetchContainer()
@@ -903,8 +909,19 @@ export default function ContainerDetail() {
const diskPct = container.disk_gb > 0 ? clamp(((usage?.disk_usage_bytes || 0) / (container.disk_gb * 1024 * 1024 * 1024)) * 100) : 0 const diskPct = container.disk_gb > 0 ? clamp(((usage?.disk_usage_bytes || 0) / (container.disk_gb * 1024 * 1024 * 1024)) * 100) : 0
const networkBps = (usage?.network_rx_bps || 0) + (usage?.network_tx_bps || 0) const networkBps = (usage?.network_rx_bps || 0) + (usage?.network_tx_bps || 0)
const rx = usage?.network_rx_bps || 0 const rx = usage?.network_rx_bps || 0
const netPct = Math.min(((usage?.network_rx_bps || 0) + (usage?.network_tx_bps || 0)) / (container.network_bw_mbps > 0 ? container.network_bw_mbps * 125000 : 125000000) * 100, 100) const networkDownLimit = resourceLimitValue(container.network_down_mbps, container.network_bw_mbps)
const networkUpLimit = resourceLimitValue(container.network_up_mbps, container.network_bw_mbps)
const netPct = Math.max(
directionUsagePercent(usage?.network_rx_bps || 0, networkDownLimit, 125000, 125000000),
directionUsagePercent(usage?.network_tx_bps || 0, networkUpLimit, 125000, 125000000),
)
const diskIOBps = (usage?.disk_read_bps || 0) + (usage?.disk_write_bps || 0) const diskIOBps = (usage?.disk_read_bps || 0) + (usage?.disk_write_bps || 0)
const ioReadLimit = resourceLimitValue(container.io_read_mbps, container.io_speed_mbps)
const ioWriteLimit = resourceLimitValue(container.io_write_mbps, container.io_speed_mbps)
const diskIOPct = Math.max(
directionUsagePercent(usage?.disk_read_bps || 0, ioReadLimit, 1024 * 1024, 1024 * 1024 * 1024),
directionUsagePercent(usage?.disk_write_bps || 0, ioWriteLimit, 1024 * 1024, 1024 * 1024 * 1024),
)
const mappingCount = container.port_mappings?.length || 0 const mappingCount = container.port_mappings?.length || 0
const mappingLimit = Math.max(container.port_mapping_limit || 0, mappingCount) const mappingLimit = Math.max(container.port_mapping_limit || 0, mappingCount)
const hasNATQuota = mappingLimit > 0 const hasNATQuota = mappingLimit > 0
@@ -951,7 +968,7 @@ export default function ContainerDetail() {
current: networkBps, current: networkBps,
points: toChartPoints(filtered, 'network'), points: toChartPoints(filtered, 'network'),
formatValue: formatRate, formatValue: formatRate,
detail: `${formatRate(usage?.network_rx_bps || 0)} / 出 ${formatRate(usage?.network_tx_bps || 0)},累计 ${formatBytes((usage?.network_rx_bytes || 0) + (usage?.network_tx_bytes || 0))}`, detail: `${formatRate(usage?.network_rx_bps || 0)} / 出 ${formatRate(usage?.network_tx_bps || 0)}限速占用 ${netPct.toFixed(1)}%累计 ${formatBytes((usage?.network_rx_bytes || 0) + (usage?.network_tx_bytes || 0))}`,
}, },
{ {
title: '磁盘IO', title: '磁盘IO',
@@ -959,7 +976,7 @@ export default function ContainerDetail() {
current: diskIOBps, current: diskIOBps,
points: toChartPoints(filtered, 'diskIO'), points: toChartPoints(filtered, 'diskIO'),
formatValue: formatRate, formatValue: formatRate,
detail: `${formatRate(usage?.disk_read_bps || 0)} / 写 ${formatRate(usage?.disk_write_bps || 0)},累计 ${formatBytes((usage?.disk_read_bytes || 0) + (usage?.disk_write_bytes || 0))},容量 ${diskPct.toFixed(1)}%`, detail: `${formatRate(usage?.disk_read_bps || 0)} / 写 ${formatRate(usage?.disk_write_bps || 0)}限速占用 ${diskIOPct.toFixed(1)}%累计 ${formatBytes((usage?.disk_read_bytes || 0) + (usage?.disk_write_bytes || 0))},容量 ${diskPct.toFixed(1)}%`,
}, },
] ]
@@ -1154,8 +1171,8 @@ export default function ContainerDetail() {
<PlainRow label="vCPU" value={`${container.vcpu}`} /> <PlainRow label="vCPU" value={`${container.vcpu}`} />
<PlainRow label="内存" value={`${container.ram_mb} MB`} /> <PlainRow label="内存" value={`${container.ram_mb} MB`} />
<PlainRow label="磁盘" value={`${container.disk_gb} GB`} /> <PlainRow label="磁盘" value={`${container.disk_gb} GB`} />
<PlainRow label="网络速率" value={container.network_bw_mbps > 0 ? `${container.network_bw_mbps} Mbps` : '不限制'} /> <PlainRow label="网络速率" value={formatDirectionalLimit('下行', networkDownLimit, '上行', networkUpLimit, 'Mbps')} />
<PlainRow label="IO 速度" value={container.io_speed_mbps > 0 ? `${container.io_speed_mbps} MB/s` : '不限制'} /> <PlainRow label="IO 速度" value={formatDirectionalLimit('读取', ioReadLimit, '写入', ioWriteLimit, 'MB/s')} />
</Panel> </Panel>
<Panel title="实时状态"> <Panel title="实时状态">
@@ -1982,15 +1999,27 @@ export default function ContainerDetail() {
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" /> className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div> </div>
<div> <div>
<label className="block text-xs text-gray-500 mb-1"> (Mbps0=)</label> <label className="block text-xs text-gray-500 mb-1"> (Mbps0=)</label>
<input type="number" min={0} value={resourceEdit.bwMbps} <input type="number" min={0} value={resourceEdit.networkDownMbps}
onChange={(e) => setResourceEdit({ ...resourceEdit, bwMbps: Math.max(0, Number(e.target.value) || 0) })} onChange={(e) => setResourceEdit({ ...resourceEdit, networkDownMbps: Math.max(0, Number(e.target.value) || 0) })}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" /> className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div> </div>
<div> <div>
<label className="block text-xs text-gray-500 mb-1">IO (MB/s0=)</label> <label className="block text-xs text-gray-500 mb-1"> (Mbps0=)</label>
<input type="number" min={0} value={resourceEdit.ioMbps} <input type="number" min={0} value={resourceEdit.networkUpMbps}
onChange={(e) => setResourceEdit({ ...resourceEdit, ioMbps: Math.max(0, Number(e.target.value) || 0) })} onChange={(e) => setResourceEdit({ ...resourceEdit, networkUpMbps: Math.max(0, Number(e.target.value) || 0) })}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label className="block text-xs text-gray-500 mb-1"> IO (MB/s0=)</label>
<input type="number" min={0} value={resourceEdit.ioReadMbps}
onChange={(e) => setResourceEdit({ ...resourceEdit, ioReadMbps: Math.max(0, Number(e.target.value) || 0) })}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label className="block text-xs text-gray-500 mb-1"> IO (MB/s0=)</label>
<input type="number" min={0} value={resourceEdit.ioWriteMbps}
onChange={(e) => setResourceEdit({ ...resourceEdit, ioWriteMbps: Math.max(0, Number(e.target.value) || 0) })}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" /> className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div> </div>
</div> </div>
@@ -2459,6 +2488,32 @@ function clampResourceInt(value: number, min: number, max?: number, fallback = m
return Math.min(Math.max(next, min), max ?? next) return Math.min(Math.max(next, min), max ?? next)
} }
function resourceLimitValue(value?: number, fallback?: number) {
return Math.max(0, Number(value || fallback || 0))
}
function symmetricLimit(a: number, b: number) {
const left = resourceLimitValue(a)
const right = resourceLimitValue(b)
if (left === right) return left
if (left === 0) return right
if (right === 0) return left
return Math.min(left, right)
}
function directionUsagePercent(bytesPerSecond: number, limit: number, bytesPerLimitUnit: number, fallbackBytesPerSecond: number) {
const denominator = limit > 0 ? limit * bytesPerLimitUnit : fallbackBytesPerSecond
return denominator > 0 ? clamp((bytesPerSecond / denominator) * 100) : 0
}
function formatLimit(value: number, unit: string) {
return value > 0 ? `${value} ${unit}` : '不限制'
}
function formatDirectionalLimit(firstLabel: string, firstValue: number, secondLabel: string, secondValue: number, unit: string) {
return `${firstLabel} ${formatLimit(firstValue, unit)} / ${secondLabel} ${formatLimit(secondValue, unit)}`
}
function toChartPoints<T extends keyof Omit<MetricPoint, 'ts'>>(history: MetricPoint[], key: T): ChartPoint[] { function toChartPoints<T extends keyof Omit<MetricPoint, 'ts'>>(history: MetricPoint[], key: T): ChartPoint[] {
return history.map((point) => ({ ts: point.ts, value: Number(point[key]) || 0 })) return history.map((point) => ({ ts: point.ts, value: Number(point[key]) || 0 }))
} }
+4
View File
@@ -704,6 +704,8 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
ram_mb: cfg.ram_mb, ram_mb: cfg.ram_mb,
disk_gb: cfg.disk_gb, disk_gb: cfg.disk_gb,
network_bw_mbps: cfg.network_bw_mbps, network_bw_mbps: cfg.network_bw_mbps,
network_down_mbps: cfg.network_down_mbps,
network_up_mbps: cfg.network_up_mbps,
monthly_traffic_gb: cfg.monthly_traffic_gb, monthly_traffic_gb: cfg.monthly_traffic_gb,
traffic_mode: cfg.traffic_mode || 'total', traffic_mode: cfg.traffic_mode || 'total',
traffic_in_gb: cfg.traffic_in_gb || 0, traffic_in_gb: cfg.traffic_in_gb || 0,
@@ -712,6 +714,8 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
traffic_used_tx: 0, traffic_used_tx: 0,
traffic_reset_date: '', traffic_reset_date: '',
io_speed_mbps: cfg.io_speed_mbps, io_speed_mbps: cfg.io_speed_mbps,
io_read_mbps: cfg.io_read_mbps,
io_write_mbps: cfg.io_write_mbps,
status: 'creating', status: 'creating',
ip: '', ip: '',
public_ipv4s: [], public_ipv4s: [],
+1 -1
View File
@@ -128,7 +128,7 @@ export default function Login() {
</form> </form>
</div> </div>
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.18</p> <p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.19</p>
</div> </div>
</div> </div>
) )
+14 -2
View File
@@ -80,6 +80,8 @@ export interface Container {
ram_mb: number ram_mb: number
disk_gb: number disk_gb: number
network_bw_mbps: number network_bw_mbps: number
network_down_mbps: number
network_up_mbps: number
monthly_traffic_gb: number monthly_traffic_gb: number
traffic_mode: string traffic_mode: string
traffic_in_gb: number traffic_in_gb: number
@@ -88,6 +90,8 @@ export interface Container {
traffic_used_tx: number traffic_used_tx: number
traffic_reset_date: string traffic_reset_date: string
io_speed_mbps: number io_speed_mbps: number
io_read_mbps: number
io_write_mbps: number
status: string status: string
ip: string ip: string
public_ipv4s?: PublicIPv4Assignment[] public_ipv4s?: PublicIPv4Assignment[]
@@ -138,11 +142,15 @@ export interface CreateContainerRequest {
ram_mb: number ram_mb: number
disk_gb: number disk_gb: number
network_bw_mbps: number network_bw_mbps: number
network_down_mbps: number
network_up_mbps: number
monthly_traffic_gb: number monthly_traffic_gb: number
traffic_mode: string traffic_mode: string
traffic_in_gb: number traffic_in_gb: number
traffic_out_gb: number traffic_out_gb: number
io_speed_mbps: number io_speed_mbps: number
io_read_mbps: number
io_write_mbps: number
extra_ports: number[] extra_ports: number[]
port_mapping_count: number port_mapping_count: number
assign_nat?: boolean assign_nat?: boolean
@@ -488,8 +496,12 @@ export const updateTrafficLimit = (id: ContainerIdentifier, data: {
export const updateResourceLimit = (id: ContainerIdentifier, data: { export const updateResourceLimit = (id: ContainerIdentifier, data: {
vcpu: number vcpu: number
ram_mb: number ram_mb: number
io_speed_mbps: number io_speed_mbps?: number
network_bw_mbps: number io_read_mbps?: number
io_write_mbps?: number
network_bw_mbps?: number
network_down_mbps?: number
network_up_mbps?: number
}) => }) =>
api.put<APIResponse>(`/containers/${id}/resource-limit`, data) api.put<APIResponse>(`/containers/${id}/resource-limit`, data)
+73 -1
View File
@@ -92,6 +92,22 @@ const exact: Record<string, string> = {
'创建时间': 'Created At', '创建时间': 'Created At',
'网络速率': 'Network Speed', '网络速率': 'Network Speed',
'IO 速度': 'IO Speed', 'IO 速度': 'IO Speed',
'下行带宽': 'Download Bandwidth',
'上行带宽': 'Upload Bandwidth',
'读取 IO': 'Read IO',
'写入 IO': 'Write IO',
'下行带宽 (Mbps)': 'Download Bandwidth (Mbps)',
'上行带宽 (Mbps)': 'Upload Bandwidth (Mbps)',
'读取 IO (MB/s)': 'Read IO (MB/s)',
'写入 IO (MB/s)': 'Write IO (MB/s)',
'下行带宽 (Mbps0=不限制)': 'Download Bandwidth (Mbps, 0=unlimited)',
'上行带宽 (Mbps0=不限制)': 'Upload Bandwidth (Mbps, 0=unlimited)',
'读取 IO (MB/s0=不限制)': 'Read IO (MB/s, 0=unlimited)',
'写入 IO (MB/s0=不限制)': 'Write IO (MB/s, 0=unlimited)',
'限速占用': 'Limit Usage',
'支持独立限制上行/下行带宽和读/写 I/O 操作。': 'Supports independent upload/download bandwidth limits and read/write I/O limits.',
'支持独立限制上行/下行带宽和读/写 I/O 操作。network_bw_mbps 与 io_speed_mbps 为旧版对称限制兼容别名,建议新对接使用 network_down_mbps、network_up_mbps、io_read_mbps、io_write_mbps。': 'Supports independent upload/download bandwidth limits and read/write I/O limits. network_bw_mbps and io_speed_mbps are deprecated symmetric compatibility aliases. New integrations should use network_down_mbps, network_up_mbps, io_read_mbps, and io_write_mbps.',
'支持独立限制下行/上行带宽和读取/写入 I/O。未传字段保持原值,显式传 0 表示该方向不限速;network_bw_mbps 与 io_speed_mbps 为旧版对称限制兼容别名。': 'Supports independent download/upload bandwidth limits and read/write I/O limits. Omitted fields keep their current values, and explicitly passing 0 makes that direction unlimited. network_bw_mbps and io_speed_mbps are deprecated symmetric compatibility aliases.',
'月流量': 'Monthly Traffic', '月流量': 'Monthly Traffic',
'统计信息': 'Statistics', '统计信息': 'Statistics',
'CPU 使用率': 'CPU Usage', 'CPU 使用率': 'CPU Usage',
@@ -578,7 +594,11 @@ const exact: Record<string, string> = {
'更新 Key': 'Update Key', '更新 Key': 'Update Key',
'删除 Key': 'Delete Key', '删除 Key': 'Delete Key',
'总览': 'Overview', '总览': 'Overview',
'NAT/IPv4/IPv6 路由': 'NAT / IPv4 / IPv6 Routing',
'NAT/IPv6 路由': 'NAT / IPv6 Routing', 'NAT/IPv6 路由': 'NAT / IPv6 Routing',
'更新公网 IPv4/IPv6 池': 'Update Public IPv4 / IPv6 Pools',
'扫描公网 IPv4 段': 'Scan Public IPv4 Prefixes',
'公网 IPv4/IPv6 池': 'Public IPv4 / IPv6 Pools',
'任务队列': 'Task Queue', '任务队列': 'Task Queue',
'任务列表': 'Task List', '任务列表': 'Task List',
'操作记录': 'audit records', '操作记录': 'audit records',
@@ -588,6 +608,7 @@ const exact: Record<string, string> = {
'管理员接口': 'Admin API', '管理员接口': 'Admin API',
'控制面板统计': 'Dashboard Stats', '控制面板统计': 'Dashboard Stats',
'立即安全检查': 'Run Security Check', '立即安全检查': 'Run Security Check',
'路由配置': 'Routing Configuration',
'返回响应样例': 'Response Example', '返回响应样例': 'Response Example',
'请求参数': 'Request Parameters', '请求参数': 'Request Parameters',
'响应字段': 'Response Fields', '响应字段': 'Response Fields',
@@ -616,11 +637,14 @@ const exact: Record<string, string> = {
'添加端口映射': 'Add Port Mapping', '添加端口映射': 'Add Port Mapping',
'更新端口映射': 'Update Port Mapping', '更新端口映射': 'Update Port Mapping',
'删除端口映射': 'Delete Port Mapping', '删除端口映射': 'Delete Port Mapping',
'获取防火墙设置': 'Get Firewall Settings',
'更新防火墙设置': 'Update Firewall Settings',
'快照总览': 'Snapshot Overview', '快照总览': 'Snapshot Overview',
'容器快照': 'Container Snapshots', '容器快照': 'Container Snapshots',
'计划快照': 'Scheduled Snapshots', '计划快照': 'Scheduled Snapshots',
'快照配额': 'Snapshot Quota', '快照配额': 'Snapshot Quota',
'模板列表': 'Template List', '模板列表': 'Template List',
'镜像管理列表': 'Image Management List',
'取消镜像下载': 'Cancel Image Download', '取消镜像下载': 'Cancel Image Download',
'启用/禁用镜像': 'Enable / Disable Image', '启用/禁用镜像': 'Enable / Disable Image',
'安全连接日志': 'Security Connection Logs', '安全连接日志': 'Security Connection Logs',
@@ -650,7 +674,6 @@ const exact: Record<string, string> = {
'WebVNC 票据': 'WebVNC Ticket', 'WebVNC 票据': 'WebVNC Ticket',
'容器列表(兼容 POST 写法)': 'Container List (compatible POST form)', '容器列表(兼容 POST 写法)': 'Container List (compatible POST form)',
'调整到期时间': 'Adjust Expiration Time', '调整到期时间': 'Adjust Expiration Time',
'镜像管理列表': 'Image Management List',
'批量创建容器': 'Batch Create Containers', '批量创建容器': 'Batch Create Containers',
'创建 WebSSH 票据': 'Create WebSSH Ticket', '创建 WebSSH 票据': 'Create WebSSH Ticket',
'创建 WebVNC 票据': 'Create WebVNC Ticket', '创建 WebVNC 票据': 'Create WebVNC Ticket',
@@ -677,6 +700,12 @@ const exact: Record<string, string> = {
'CI/CD、计费系统、自动化脚本': 'CI/CD, billing systems, automation scripts', 'CI/CD、计费系统、自动化脚本': 'CI/CD, billing systems, automation scripts',
'SWAP 已调整为 16384 MB': 'SWAP adjusted to 16384 MB', 'SWAP 已调整为 16384 MB': 'SWAP adjusted to 16384 MB',
'***60秒有效票据***': '***60-second valid ticket***', '***60秒有效票据***': '***60-second valid ticket***',
'Linux 创建支持 ssh_auth_mode=auto_password|password|key;公网 IPv4、IPv6 与 NAT 可通过 assign_nat、assign_ipv4、assign_ipv6 组合使用。': 'Linux container creation supports ssh_auth_mode=auto_password|password|key. Public IPv4, IPv6, and NAT can be configured with assign_nat, assign_ipv4, and assign_ipv6.',
'重装支持 ssh_auth_mode=keep|auto_password|password|keykeep 仅用于重装,未传 SSH 字段时保持原有行为。': 'Reinstall supports ssh_auth_mode=keep|auto_password|password|key. keep is only for reinstall requests; if SSH fields are omitted, the existing behavior is kept.',
'批量创建的单个 containers[] 项支持与 POST /api/v1/containers 相同的网络和 SSH 认证字段。': 'Each containers[] item in batch creation supports the same network and SSH authentication fields as POST /api/v1/containers.',
'action=reinstall 时可追加 template_id、ssh_auth_mode、ssh_password、ssh_public_key;其他 action 会忽略这些重装字段。': 'When action=reinstall, you can include template_id, ssh_auth_mode, ssh_password, and ssh_public_key. Other actions ignore these reinstall fields.',
'更新公网地址池需要 routing:write;已分配给容器的地址不能从池中移除。': 'Updating public address pools requires routing:write. Addresses already assigned to containers cannot be removed from the pool.',
'扫描公网 IPv4 段需要 routing:writeverify=true 时会尝试校验地址可用性。': 'Scanning public IPv4 prefixes requires routing:write. When verify=true, the API also attempts to check address availability.',
'WebVNC 仅适用于 KVM 虚拟机;LXC 容器会返回 VNC console is only available for KVM VMs。': 'WebVNC only applies to KVM VMs; LXC containers return "VNC console is only available for KVM VMs".', 'WebVNC 仅适用于 KVM 虚拟机;LXC 容器会返回 VNC console is only available for KVM VMs。': 'WebVNC only applies to KVM VMs; LXC containers return "VNC console is only available for KVM VMs".',
'该接口会进入任务队列,请随后调用 GET /api/v1/tasks 查看执行状态。': 'This API enters the task queue. Call GET /api/v1/tasks afterward to check execution status.', '该接口会进入任务队列,请随后调用 GET /api/v1/tasks 查看执行状态。': 'This API enters the task queue. Call GET /api/v1/tasks afterward to check execution status.',
'样例中的密钥、密码和票据已脱敏;创建类接口的完整密钥只在创建响应中出现一次。': 'Keys, passwords, and tickets in examples are masked. Full secrets from create APIs appear only once in the creation response.', '样例中的密钥、密码和票据已脱敏;创建类接口的完整密钥只在创建响应中出现一次。': 'Keys, passwords, and tickets in examples are masked. Full secrets from create APIs appear only once in the creation response.',
@@ -793,7 +822,32 @@ const exact: Record<string, string> = {
'独立 IPv4': 'Dedicated IPv4', '独立 IPv4': 'Dedicated IPv4',
'添加规则': 'Add Rule', '添加规则': 'Add Rule',
'启用后默认拒绝所有入站和出站流量,仅放行下方规则': 'When enabled, all inbound and outbound traffic is blocked by default. Only the rules below are allowed.', '启用后默认拒绝所有入站和出站流量,仅放行下方规则': 'When enabled, all inbound and outbound traffic is blocked by default. Only the rules below are allowed.',
'已启用,未匹配规则的流量将被拒绝': 'Enabled. Traffic that does not match a rule will be denied.',
'已启用,未匹配规则的流量将被放行': 'Enabled. Traffic that does not match a rule will be allowed.',
'未启用时不接管该容器流量': 'Disabled. Container traffic is not managed by this firewall.',
'默认动作': 'Default Action',
'没有命中下方规则时如何处理': 'How to handle traffic that does not match the rules below',
'未匹配拒绝': 'Deny unmatched',
'未匹配放行': 'Allow unmatched',
'网络范围': 'Network Scope',
'可配置:': 'Configurable: ',
'可配置:IPv4(公网 IPv4)。 IPv4 规则覆盖独立公网 IPv4。': 'Configurable: IPv4 (Public IPv4). IPv4 rules apply to the dedicated public IPv4.',
'可配置:IPv4NAT)。 IPv4 规则覆盖IPv4 NAT 端口映射。 NAT 入站端口按容器内部端口匹配,不是宿主机公网端口。': 'Configurable: IPv4 (NAT). IPv4 rules apply to IPv4 NAT port mappings. NAT inbound ports are matched by the container internal port, not the host public port.',
'可配置:IPv6。 IPv6 规则覆盖该容器已分配的 IPv6 地址。': 'Configurable: IPv6. IPv6 rules apply to the IPv6 addresses assigned to this container.',
'可配置:IPv4(公网 IPv4)、IPv6。 IPv4 规则覆盖独立公网 IPv4。 IPv6 规则覆盖该容器已分配的 IPv6 地址。': 'Configurable: IPv4 (Public IPv4), IPv6. IPv4 rules apply to the dedicated public IPv4. IPv6 rules apply to the IPv6 addresses assigned to this container.',
'可配置:IPv4NAT)、IPv6。 IPv4 规则覆盖IPv4 NAT 端口映射。 NAT 入站端口按容器内部端口匹配,不是宿主机公网端口。 IPv6 规则覆盖该容器已分配的 IPv6 地址。': 'Configurable: IPv4 (NAT), IPv6. IPv4 rules apply to IPv4 NAT port mappings. NAT inbound ports are matched by the container internal port, not the host public port. IPv6 rules apply to the IPv6 addresses assigned to this container.',
'当前容器未分配 IPv4 NAT、独立公网 IPv4 或 IPv6,暂无可配置网络。': 'This container has no IPv4 NAT, dedicated public IPv4, or IPv6 assigned, so no firewall network can be configured.',
'当前容器没有可配置的 NAT、公网 IPv4 或 IPv6': 'This container has no configurable NAT, public IPv4, or IPv6',
'当前容器没有可配置网络': 'This container has no configurable network',
'IPv4 规则覆盖独立公网 IPv4。': 'IPv4 rules apply to the dedicated public IPv4.',
'IPv4 规则覆盖IPv4 NAT 端口映射。': 'IPv4 rules apply to IPv4 NAT port mappings.',
'NAT 入站端口按容器内部端口匹配,不是宿主机公网端口。': 'NAT inbound ports are matched by the container internal port, not the host public port.',
'IPv6 规则覆盖该容器已分配的 IPv6 地址。': 'IPv6 rules apply to the IPv6 addresses assigned to this container.',
'IPv4(公网 IPv4': 'IPv4 (Public IPv4)',
'IPv4NAT': 'IPv4 (NAT)',
'全部网络': 'All Networks',
'方向': 'Direction', '方向': 'Direction',
'网络': 'Network',
'来源/目标 IP': 'Source / Destination IP', '来源/目标 IP': 'Source / Destination IP',
'动作': 'Action', '动作': 'Action',
'入站': 'Inbound', '入站': 'Inbound',
@@ -802,18 +856,31 @@ const exact: Record<string, string> = {
'放行': 'Allow', '放行': 'Allow',
'拒绝': 'Deny', '拒绝': 'Deny',
'暂无防火墙规则': 'No firewall rules', '暂无防火墙规则': 'No firewall rules',
'防火墙设置已保存并应用': 'Firewall settings saved and applied',
'保存防火墙设置失败': 'Failed to save firewall settings',
'编辑规则': 'Edit Rule', '编辑规则': 'Edit Rule',
'入站 (Inbound)': 'Inbound', '入站 (Inbound)': 'Inbound',
'出站 (Outbound)': 'Outbound', '出站 (Outbound)': 'Outbound',
'留空为全部端口,支持: 22 | 80,443 | 8000-9000': 'Leave empty for all ports. Supports: 22 | 80,443 | 8000-9000', '留空为全部端口,支持: 22 | 80,443 | 8000-9000': 'Leave empty for all ports. Supports: 22 | 80,443 | 8000-9000',
'入站填容器服务端口;留空为全部端口,支持: 22 | 80,443 | 8000-9000': 'For inbound rules, enter the container service port. Leave empty for all ports. Supports: 22 | 80,443 | 8000-9000',
'出站填远端目标端口;留空为全部端口,支持: 22 | 80,443 | 8000-9000': 'For outbound rules, enter the remote destination port. Leave empty for all ports. Supports: 22 | 80,443 | 8000-9000',
'NAT 入站填容器内部端口,例如公网 22023 -> 容器 22,这里填 22': 'For NAT inbound rules, enter the container internal port. For example, public 22023 -> container 22 means enter 22 here.',
'端口仅适用于 TCP/UDP': 'Ports only apply to TCP/UDP',
'如: 22 或 80,443 或 8000-9000': 'e.g. 22 or 80,443 or 8000-9000', '如: 22 或 80,443 或 8000-9000': 'e.g. 22 or 80,443 or 8000-9000',
'当前协议不使用端口': 'This protocol does not use ports',
'来源 IP': 'Source IP', '来源 IP': 'Source IP',
'目标 IP': 'Destination IP', '目标 IP': 'Destination IP',
'留空为任意 IP,支持 CIDR: 192.168.1.0/24': 'Leave empty for any IP. Supports CIDR: 192.168.1.0/24', '留空为任意 IP,支持 CIDR: 192.168.1.0/24': 'Leave empty for any IP. Supports CIDR: 192.168.1.0/24',
'留空为任意 IPv4,支持 CIDR: 192.168.1.0/24': 'Leave empty for any IPv4. Supports CIDR: 192.168.1.0/24',
'留空为任意 IPv6,支持 CIDR: 2001:db8::/64': 'Leave empty for any IPv6. Supports CIDR: 2001:db8::/64',
'留空为任意 IP,支持 IPv4/IPv6 CIDR': 'Leave empty for any IP. Supports IPv4/IPv6 CIDR',
'如: 192.168.1.0/24': 'e.g. 192.168.1.0/24', '如: 192.168.1.0/24': 'e.g. 192.168.1.0/24',
'如: 2001:db8::/64': 'e.g. 2001:db8::/64',
'如: 192.168.1.0/24 或 2001:db8::/64': 'e.g. 192.168.1.0/24 or 2001:db8::/64',
'放行 (ACCEPT)': 'Allow (ACCEPT)', '放行 (ACCEPT)': 'Allow (ACCEPT)',
'拒绝 (DROP)': 'Deny (DROP)', '拒绝 (DROP)': 'Deny (DROP)',
'规则描述': 'Rule description', '规则描述': 'Rule description',
'兼容旧请求:default_action 可不传,不传时保留现有策略;rule.network 可不传,不传按 ipv4 处理。default_action: DROP=未命中规则时拒绝, ACCEPT=未命中规则时放行。network: ipv4=IPv4 NAT/公网 IPv4, ipv6=IPv6, all=同时应用到 IPv4 和 IPv6。NAT 入站规则的 port 填容器内端口,不是宿主机公网端口。': 'Backward compatible: default_action is optional; if omitted, the existing policy is kept. rule.network is optional; if omitted, it is treated as ipv4. default_action: DROP=deny unmatched traffic, ACCEPT=allow unmatched traffic. network: ipv4=IPv4 NAT/public IPv4, ipv6=IPv6, all=apply to both IPv4 and IPv6. For NAT inbound rules, port is the container internal port, not the host public port.',
'登录方式': 'SSH Auth Method', '登录方式': 'SSH Auth Method',
'保留当前密码': 'Keep current password', '保留当前密码': 'Keep current password',
'生成新密码': 'Generate new password', '生成新密码': 'Generate new password',
@@ -905,7 +972,12 @@ const replacements: Array<[RegExp, string]> = [
[/当前证书:/g, 'Current certificate: '], [/当前证书:/g, 'Current certificate: '],
[/第\s*(\d+)\s*页/g, 'Page $1'], [/第\s*(\d+)\s*页/g, 'Page $1'],
[/入\s*([^/]+)\s*\/\s*出\s*([^]+),累计\s*(.+)$/g, 'In $1 / Out $2, total $3'], [/入\s*([^/]+)\s*\/\s*出\s*([^]+),累计\s*(.+)$/g, 'In $1 / Out $2, total $3'],
[/入\s*([^/]+)\s*\/\s*出\s*([^]+),限速占用\s*([^]+),累计\s*(.+)$/g, 'In $1 / Out $2, limit usage $3, total $4'],
[/下\s*([^/]+)\s*\/\s*上\s*(.+)$/g, 'Down $1 / Up $2'],
[/下行\s*([^/]+)\s*\/\s*上行\s*(.+)$/g, 'Download $1 / Upload $2'],
[/读取\s*([^/]+)\s*\/\s*写入\s*(.+)$/g, 'Read $1 / Write $2'],
[/读\s*([^/]+)\s*\/\s*写\s*([^]+),累计\s*([^]+),容量\s*(.+)$/g, 'Read $1 / Write $2, total $3, capacity $4'], [/读\s*([^/]+)\s*\/\s*写\s*([^]+),累计\s*([^]+),容量\s*(.+)$/g, 'Read $1 / Write $2, total $3, capacity $4'],
[/读\s*([^/]+)\s*\/\s*写\s*([^]+),限速占用\s*([^]+),累计\s*([^]+),容量\s*(.+)$/g, 'Read $1 / Write $2, limit usage $3, total $4, capacity $5'],
[/(.+?),筛选后\s*(\d+)\s*items/g, '$1, filtered $2 items'], [/(.+?),筛选后\s*(\d+)\s*items/g, '$1, filtered $2 items'],
[/(.+?),已选\s*(\d+)\s*items/g, '$1, selected $2 items'], [/(.+?),已选\s*(\d+)\s*items/g, '$1, selected $2 items'],
[/将创建\s*(\d+)\s*个容器:(.+?)\s*至\s*(.+)$/g, 'Will create $1 containers: $2 to $3'], [/将创建\s*(\d+)\s*个容器:(.+?)\s*至\s*(.+)$/g, 'Will create $1 containers: $2 to $3'],