This commit is contained in:
MengMengCode
2026-07-17 00:27:31 +08:00
parent d05ca8cc4c
commit 8bad52bd9e
10 changed files with 707 additions and 62 deletions
+10
View File
@@ -172,6 +172,16 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
return return
} }
assignIPv6(w, r, id) assignIPv6(w, r, id)
case action == "public-ipv4" && r.Method == http.MethodPut:
if !requireScope(w, r, "container:network") {
return
}
updatePublicIPv4(w, r, id)
case action == "ipv6-addresses" && r.Method == http.MethodPut:
if !requireScope(w, r, "ipv6:assign") {
return
}
updateIPv6Addresses(w, r, id)
case action == "snapshots" || strings.HasPrefix(action, "snapshots/"): case action == "snapshots" || strings.HasPrefix(action, "snapshots/"):
handleContainerSnapshots(w, r, id, action) handleContainerSnapshots(w, r, id, action)
case action == "port-mappings" && r.Method == http.MethodPost: case action == "port-mappings" && r.Method == http.MethodPost:
+55 -1
View File
@@ -1,6 +1,9 @@
package api package api
import "net/http" import (
"encoding/json"
"net/http"
)
func HandleIPv6Status(w http.ResponseWriter, r *http.Request) { func HandleIPv6Status(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
@@ -22,3 +25,54 @@ func assignIPv6(w http.ResponseWriter, r *http.Request, id int) {
} }
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "IPv6 assigned", Data: c}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "IPv6 assigned", Data: c})
} }
type ipAssignmentRequest struct {
Mode string `json:"mode"`
Auto *bool `json:"auto,omitempty"`
Count int `json:"count,omitempty"`
Addresses []string `json:"addresses,omitempty"`
}
func (req ipAssignmentRequest) allocation() ([]string, int, bool) {
auto := req.Mode == "random" || req.Mode == "auto"
if req.Mode == "custom" {
auto = false
}
if req.Mode == "clear" || req.Mode == "none" {
return nil, 0, false
}
if req.Auto != nil {
auto = *req.Auto
}
return req.Addresses, req.Count, auto
}
func updatePublicIPv4(w http.ResponseWriter, r *http.Request, id int) {
var req ipAssignmentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
addresses, count, auto := req.allocation()
c, err := updatePublicIPv4ByRuntime(id, addresses, count, auto)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Public IPv4 assignments updated", Data: c})
}
func updateIPv6Addresses(w http.ResponseWriter, r *http.Request, id int) {
var req ipAssignmentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
addresses, count, auto := req.allocation()
c, err := updateIPv6ByRuntime(id, addresses, count, auto)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "IPv6 assignments updated", Data: c})
}
+16
View File
@@ -115,6 +115,22 @@ func assignIPv6ByRuntime(id int) (*config.Container, error) {
return lxcManager.AssignIPv6(id) return lxcManager.AssignIPv6(id)
} }
func updatePublicIPv4ByRuntime(id int, requested []string, count int, auto bool) (*config.Container, error) {
c := config.FindContainer(id)
if c != nil && c.IsKVM() {
return kvmManager.UpdatePublicIPv4Assignments(id, requested, count, auto)
}
return lxcManager.UpdatePublicIPv4Assignments(id, requested, count, auto)
}
func updateIPv6ByRuntime(id int, requested []string, count int, auto bool) (*config.Container, error) {
c := config.FindContainer(id)
if c != nil && c.IsKVM() {
return kvmManager.UpdateIPv6Assignments(id, requested, count, auto)
}
return lxcManager.UpdateIPv6Assignments(id, requested, count, auto)
}
func usageByRuntime(id int) (map[string]interface{}, error) { func usageByRuntime(id int) (map[string]interface{}, error) {
c := config.FindContainer(id) c := config.FindContainer(id)
if c != nil && c.IsKVM() { if c != nil && c.IsKVM() {
+10 -3
View File
@@ -506,13 +506,17 @@ func ensureSchemaMigrations() error {
} }
} }
if _, err := db.Exec(`UPDATE containers if _, err := db.Exec(`UPDATE containers
SET lan_ipv4_address = COALESCE(lan_ipv4_address, ''), SET lan_ipv4_mode = COALESCE(lan_ipv4_mode, ''),
lan_interface = COALESCE(lan_interface, ''),
lan_ipv4_address = COALESCE(lan_ipv4_address, ''),
lan_ipv4_prefix_len = COALESCE(lan_ipv4_prefix_len, 0), lan_ipv4_prefix_len = COALESCE(lan_ipv4_prefix_len, 0),
lan_ipv4_gateway = COALESCE(lan_ipv4_gateway, '')`); err != nil { lan_ipv4_gateway = COALESCE(lan_ipv4_gateway, '')`); err != nil {
return err return err
} }
if _, err := db.Exec(`UPDATE tasks if _, err := db.Exec(`UPDATE tasks
SET cfg_lan_ipv4_address = COALESCE(cfg_lan_ipv4_address, ''), SET cfg_lan_ipv4_mode = COALESCE(cfg_lan_ipv4_mode, ''),
cfg_lan_interface = COALESCE(cfg_lan_interface, ''),
cfg_lan_ipv4_address = COALESCE(cfg_lan_ipv4_address, ''),
cfg_lan_ipv4_prefix_len = COALESCE(cfg_lan_ipv4_prefix_len, 0), cfg_lan_ipv4_prefix_len = COALESCE(cfg_lan_ipv4_prefix_len, 0),
cfg_lan_ipv4_gateway = COALESCE(cfg_lan_ipv4_gateway, '')`); err != nil { cfg_lan_ipv4_gateway = COALESCE(cfg_lan_ipv4_gateway, '')`); err != nil {
return err return err
@@ -975,6 +979,7 @@ func loadContainers() ([]Container, error) {
var scheduleEnabled, policyBlocked, firewallEnabled, imageLimitConfigured int var scheduleEnabled, policyBlocked, firewallEnabled, imageLimitConfigured int
var firewallDefaultAction string var firewallDefaultAction string
var firewallRulesJSON, allowedImageIDs sql.NullString var firewallRulesJSON, allowedImageIDs sql.NullString
var lanIPv4Mode, lanInterface sql.NullString
var lanIPv4Address, lanIPv4Gateway sql.NullString var lanIPv4Address, lanIPv4Gateway sql.NullString
var lanIPv4PrefixLen sql.NullInt64 var lanIPv4PrefixLen sql.NullInt64
if err := rows.Scan( if err := rows.Scan(
@@ -983,7 +988,7 @@ func loadContainers() ([]Container, error) {
&c.MonthlyTrafficGB, &c.TrafficMode, &c.TrafficInGB, &c.MonthlyTrafficGB, &c.TrafficMode, &c.TrafficInGB,
&c.TrafficOutGB, &c.TrafficUsedRX, &c.TrafficUsedTX, &c.TrafficResetDate, &c.TrafficOutGB, &c.TrafficUsedRX, &c.TrafficUsedTX, &c.TrafficResetDate,
&c.IOSpeedMBps, &c.IOReadMBps, &c.IOWriteMBps, &c.IOSpeedMBps, &c.IOReadMBps, &c.IOWriteMBps,
&c.Status, &c.IP, &c.LANIPv4Mode, &c.LANInterface, &lanIPv4Address, &lanIPv4PrefixLen, &lanIPv4Gateway, &c.Status, &c.IP, &lanIPv4Mode, &lanInterface, &lanIPv4Address, &lanIPv4PrefixLen, &lanIPv4Gateway,
&c.IPv6, &c.IPv6PrefixLen, &c.IPv6Interface, &c.VNCPort, &c.SSHPort, &c.SSHPassword, &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,
@@ -993,6 +998,8 @@ func loadContainers() ([]Container, error) {
); err != nil { ); err != nil {
return nil, err return nil, err
} }
c.LANIPv4Mode = lanIPv4Mode.String
c.LANInterface = lanInterface.String
c.LANIPv4Address = lanIPv4Address.String c.LANIPv4Address = lanIPv4Address.String
if lanIPv4PrefixLen.Valid { if lanIPv4PrefixLen.Valid {
c.LANIPv4PrefixLen = int(lanIPv4PrefixLen.Int64) c.LANIPv4PrefixLen = int(lanIPv4PrefixLen.Int64)
+103
View File
@@ -3363,6 +3363,109 @@ func (m *Manager) AssignIPv6(id int) (*config.Container, error) {
return c, nil return c, nil
} }
func (m *Manager) UpdatePublicIPv4Assignments(id int, requested []string, count int, auto bool) (*config.Container, error) {
c := config.FindContainer(id)
if c == nil {
return nil, fmt.Errorf("container not found: %d", id)
}
if !c.IsKVM() {
return nil, fmt.Errorf("container is not a KVM VM: %d", id)
}
assignments := []config.PublicIPv4Assignment{}
if auto || len(requested) > 0 {
allocated, err := lxc.AllocatePublicIPv4Assignments(id, requested, count, auto)
if err != nil {
return nil, err
}
assignments = allocated
}
c.PublicIPv4s = assignments
reconcileKVMPortMappingHostIPs(c)
c.NormalizeNetworkAssignments()
config.SaveConfig()
lxcManager := lxc.NewManager()
_ = lxcManager.CleanPortMappings(id)
lxc.EnsureAssignedPublicIPv4s(c.PublicIPv4s)
if c.Status == "running" && c.IP != "" {
if err := lxcManager.ApplyPortMappings(id); err != nil {
return nil, err
}
}
return c, nil
}
func reconcileKVMPortMappingHostIPs(c *config.Container) {
if c == nil {
return
}
assigned := map[string]bool{}
for _, item := range c.PublicIPv4s {
if addr := strings.TrimSpace(item.Address); addr != "" {
assigned[addr] = true
}
}
replacement := ""
if len(assigned) == 1 {
for addr := range assigned {
replacement = addr
}
}
for i := range c.PortMappings {
hostIP := strings.TrimSpace(c.PortMappings[i].HostIP)
if hostIP == "" || assigned[hostIP] {
continue
}
c.PortMappings[i].HostIP = replacement
}
}
func (m *Manager) UpdateIPv6Assignments(id int, requested []string, count int, auto bool) (*config.Container, error) {
c := config.FindContainer(id)
if c == nil {
return nil, fmt.Errorf("container not found: %d", id)
}
if !c.IsKVM() {
return nil, fmt.Errorf("container is not a KVM VM: %d", id)
}
old := *c
old.IPv6Addresses = append([]config.IPv6Assignment(nil), c.IPv6Addresses...)
removeKVMIPv6Runtime(&old)
assignments := []config.IPv6Assignment{}
if auto || len(requested) > 0 {
allocated, err := m.allocateIPv6AssignmentsForContainer(id, requested, count, auto)
if err != nil {
if old.IPv6 != "" || len(old.IPv6Addresses) > 0 {
_ = m.applyIPv6Runtime(&old)
}
return nil, err
}
assignments = allocated
}
c.IPv6 = ""
c.IPv6PrefixLen = 0
c.IPv6Interface = ""
c.IPv6Addresses = assignments
c.NormalizeNetworkAssignments()
config.SaveConfig()
if len(c.IPv6Addresses) > 0 {
if err := m.applyIPv6Runtime(c); err != nil {
return nil, err
}
} else if c.Status == "running" {
if err := lxc.ApplyFirewallRules(c.ID); err != nil {
fmt.Printf("Warning: failed to re-apply firewall rules after KVM IPv6 removal for %s: %v\n", c.Name, err)
}
}
return c, nil
}
func (m *Manager) applyIPv6Runtime(c *config.Container) error { func (m *Manager) applyIPv6Runtime(c *config.Container) error {
if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) { if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) {
return nil return nil
+103
View File
@@ -1515,6 +1515,75 @@ func (m *Manager) AssignIPv6(id int) (*config.Container, error) {
return c, nil return c, nil
} }
func (m *Manager) UpdateIPv6Assignments(id int, requested []string, count int, auto bool) (*config.Container, error) {
c := config.FindContainer(id)
if c == nil {
return nil, fmt.Errorf("container not found: %d", id)
}
oldAssignments := append([]config.IPv6Assignment(nil), c.IPv6Addresses...)
oldPrimary := c.IPv6
oldPrimaryPrefixLen := c.IPv6PrefixLen
oldPrimaryInterface := c.IPv6Interface
assignments := []config.IPv6Assignment{}
if auto || len(requested) > 0 {
allocated, err := m.allocateIPv6AssignmentsForContainer(id, requested, count, auto)
if err != nil {
return nil, err
}
assignments = allocated
}
for _, assignment := range oldAssignments {
uplink := assignment.Interface
if uplink == "" {
uplink = oldPrimaryInterface
}
removeHostIPv6Routing(assignment.Address, uplink)
}
if len(oldAssignments) == 0 && oldPrimary != "" {
removeHostIPv6Routing(oldPrimary, oldPrimaryInterface)
oldAssignments = append(oldAssignments, config.IPv6Assignment{Address: oldPrimary, PrefixLen: oldPrimaryPrefixLen, Interface: oldPrimaryInterface})
}
c.IPv6 = ""
c.IPv6PrefixLen = 0
c.IPv6Interface = ""
c.IPv6Addresses = assignments
c.NormalizeNetworkAssignments()
config.SaveConfig()
if err := m.applyIPv6Config(c.LxcName(), c.IPv6AddressStrings()...); err != nil {
return nil, err
}
rootfsPath := filepath.Join(m.LxcPath, c.LxcName(), "rootfs")
if _, err := os.Stat(rootfsPath); err == nil {
if len(c.IPv6Addresses) == 0 {
if err := removeContainerIPv6Init(rootfsPath); err != nil {
fmt.Printf("Warning: failed to remove IPv6 init in %s: %v\n", c.LxcName(), err)
}
} else if err := installContainerIPv6Init(rootfsPath, c.IPv6AddressStrings()...); err != nil {
fmt.Printf("Warning: failed to install IPv6 init in %s: %v\n", c.LxcName(), err)
}
}
status, _ := m.GetContainerStatus(c.LxcName())
if status == "running" {
m.removeGuestIPv6Addresses(c.LxcName(), oldAssignments)
}
if len(c.IPv6Addresses) > 0 {
if err := m.ApplyIPv6(id); err != nil {
return nil, err
}
} else if status == "running" {
m.removeGuestIPv6DefaultRoute(c.LxcName())
if err := ApplyFirewallRules(c.ID); err != nil {
fmt.Printf("Warning: failed to re-apply firewall rules after IPv6 removal for %s: %v\n", c.Name, err)
}
}
return c, nil
}
func (m *Manager) applyIPv6Config(lxcName string, ipv6s ...string) error { func (m *Manager) applyIPv6Config(lxcName string, ipv6s ...string) error {
configFile := filepath.Join(m.LxcPath, lxcName, "config") configFile := filepath.Join(m.LxcPath, lxcName, "config")
data, err := os.ReadFile(configFile) data, err := os.ReadFile(configFile)
@@ -1705,6 +1774,25 @@ exit 0
return nil return nil
} }
func removeContainerIPv6Init(rootfsPath string) error {
paths := []string{
filepath.Join(rootfsPath, "usr", "local", "sbin", "clicd-ipv6-init"),
filepath.Join(rootfsPath, "etc", "systemd", "system", "clicd-ipv6.service"),
filepath.Join(rootfsPath, "etc", "systemd", "system", "multi-user.target.wants", "clicd-ipv6.service"),
filepath.Join(rootfsPath, "etc", "init.d", "clicd-ipv6"),
filepath.Join(rootfsPath, "etc", "runlevels", "default", "clicd-ipv6"),
}
for _, level := range []string{"2", "3", "4", "5"} {
paths = append(paths, filepath.Join(rootfsPath, "etc", "rc"+level+".d", "S99clicd-ipv6"))
}
for _, path := range paths {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
}
return nil
}
func installContainerIPv6Systemd(rootfsPath string) error { func installContainerIPv6Systemd(rootfsPath string) error {
servicePath := filepath.Join(rootfsPath, "etc", "systemd", "system", "clicd-ipv6.service") servicePath := filepath.Join(rootfsPath, "etc", "systemd", "system", "clicd-ipv6.service")
if err := os.MkdirAll(filepath.Dir(servicePath), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(servicePath), 0755); err != nil {
@@ -1873,6 +1961,21 @@ func containerIPv6ConnectivityOK(lxcName string) bool {
return false return false
} }
func (m *Manager) removeGuestIPv6Addresses(lxcName string, assignments []config.IPv6Assignment) {
addrs := ipv6AssignmentAddresses(assignments)
if len(addrs) == 0 {
return
}
quoted := shellQuotedIPv6List(addrs)
_ = exec.Command("lxc-attach", "-n", lxcName, "--", "sh", "-c",
fmt.Sprintf("for ip in %s; do ip -6 addr del \"$ip/128\" dev eth0 2>/dev/null || true; done", quoted)).Run()
}
func (m *Manager) removeGuestIPv6DefaultRoute(lxcName string) {
_ = exec.Command("lxc-attach", "-n", lxcName, "--", "sh", "-c",
fmt.Sprintf("ip -6 route del default via %s dev eth0 2>/dev/null || true", shellQuote(ipv6GatewayLinkLocal))).Run()
}
func ensureIPv6NAT66(ipv6, uplink string) { func ensureIPv6NAT66(ipv6, uplink string) {
if ipv6 == "" || uplink == "" { if ipv6 == "" || uplink == "" {
return return
+58
View File
@@ -399,6 +399,64 @@ func persistAndReloadMappings(m *Manager, c *config.Container) error {
return nil return nil
} }
func (m *Manager) UpdatePublicIPv4Assignments(id int, requested []string, count int, auto bool) (*config.Container, error) {
c := config.FindContainer(id)
if c == nil {
return nil, fmt.Errorf("container not found: %d", id)
}
if c.UsesLANIPv4() {
return nil, fmt.Errorf("public IPv4 cannot be assigned while LAN IPv4 mode is enabled")
}
assignments := []config.PublicIPv4Assignment{}
if auto || len(requested) > 0 {
allocated, err := AllocatePublicIPv4Assignments(id, requested, count, auto)
if err != nil {
return nil, err
}
assignments = allocated
}
c.PublicIPv4s = assignments
reconcilePortMappingHostIPs(c)
c.NormalizeNetworkAssignments()
config.SaveConfig()
_ = m.CleanPortMappings(id)
EnsureAssignedPublicIPv4s(c.PublicIPv4s)
if c.Status == "running" && c.IP != "" {
if err := m.ApplyPortMappings(id); err != nil {
return nil, err
}
}
return c, nil
}
func reconcilePortMappingHostIPs(c *config.Container) {
if c == nil {
return
}
assigned := map[string]bool{}
for _, item := range c.PublicIPv4s {
if addr := strings.TrimSpace(item.Address); addr != "" {
assigned[addr] = true
}
}
replacement := ""
if len(assigned) == 1 {
for addr := range assigned {
replacement = addr
}
}
for i := range c.PortMappings {
hostIP := strings.TrimSpace(c.PortMappings[i].HostIP)
if hostIP == "" || assigned[hostIP] {
continue
}
c.PortMappings[i].HostIP = replacement
}
}
func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapping) (config.PortMapping, error) { func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapping) (config.PortMapping, error) {
if pm.ContainerPort < 1 || pm.ContainerPort > 65535 { if pm.ContainerPort < 1 || pm.ContainerPort > 65535 {
return pm, fmt.Errorf("container port must be 1-65535") return pm, fmt.Errorf("container port must be 1-65535")
+120 -30
View File
@@ -131,7 +131,8 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
const natEnabled = form.assign_nat !== false && !lanIPv4Enabled const natEnabled = form.assign_nat !== false && !lanIPv4Enabled
const lanInterfaces = useMemo(() => getLANDHCPInterfaces(hostReport), [hostReport]) const lanInterfaces = useMemo(() => getLANDHCPInterfaces(hostReport), [hostReport])
const defaultLANInterface = lanInterfaces[0]?.name || '' const defaultLANInterface = lanInterfaces[0]?.name || ''
const natPortCount = natEnabled ? Math.max(2, form.port_mapping_count || 2) : 0 const customNATPorts = form.extra_ports || []
const natPortCount = natEnabled ? Math.max(2, form.port_mapping_count || 2, customNATPorts.length + 1) : 0
const linuxTemplate = !isWindowsTemplate(form.template_id) const linuxTemplate = !isWindowsTemplate(form.template_id)
const sshAuthMode = (form.ssh_auth_mode || 'auto_password') as SSHAuthMode const sshAuthMode = (form.ssh_auth_mode || 'auto_password') as SSHAuthMode
@@ -140,6 +141,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
const count = natPortCount const count = natPortCount
return Array.from({ length: count - 1 }, (_, index) => 22002 + index) return Array.from({ length: count - 1 }, (_, index) => 22002 + index)
}, [natEnabled, natPortCount]) }, [natEnabled, natPortCount])
const natPreviewPorts = customNATPorts.length > 0 ? customNATPorts : autoPorts
// SSH port preview (will be allocated sequentially, starting around 22000+) // SSH port preview (will be allocated sequentially, starting around 22000+)
const sshPortPreview = 22000 const sshPortPreview = 22000
@@ -213,11 +215,11 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
...boundedForm, ...boundedForm,
name, name,
assign_nat: wantsNAT, assign_nat: wantsNAT,
port_mapping_count: wantsNAT ? Math.max(2, boundedForm.port_mapping_count || 2) : 0, port_mapping_count: wantsNAT ? Math.max(2, boundedForm.port_mapping_count || 2, (boundedForm.extra_ports || []).length + 1) : 0,
snapshot_limit: Math.max(1, boundedForm.snapshot_limit || 3), snapshot_limit: Math.max(1, boundedForm.snapshot_limit || 3),
ipv4_count: boundedForm.assign_ipv4 ? Math.max(1, boundedForm.ipv4_count || 1) : 0, ipv4_count: boundedForm.assign_ipv4 ? Math.max(1, boundedForm.ipv4_count || 1) : 0,
ipv6_count: boundedForm.assign_ipv6 ? Math.max(1, boundedForm.ipv6_count || 1) : 0, ipv6_count: boundedForm.assign_ipv6 ? Math.max(1, boundedForm.ipv6_count || 1) : 0,
extra_ports: [], extra_ports: wantsNAT ? (boundedForm.extra_ports || []) : [],
}) })
} }
@@ -240,7 +242,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
return ( return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg border border-gray-200 shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto"> <div className="bg-white rounded-lg border border-gray-200 shadow-xl w-full max-w-3xl max-h-[92vh] overflow-y-auto">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200"> <div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-black"></h2> <h2 className="text-lg font-semibold text-black"></h2>
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded text-gray-500" title="关闭"> <button onClick={onClose} className="p-1 hover:bg-gray-100 rounded text-gray-500" title="关闭">
@@ -248,8 +250,8 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</button> </button>
</div> </div>
<div className="px-6 py-4 space-y-4"> <div className="px-5 py-4 space-y-3">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-3">
<Field label="容器名称"> <Field label="容器名称">
<input <input
type="text" type="text"
@@ -399,6 +401,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</div> </div>
)} )}
<div className="grid gap-3 lg:grid-cols-2">
<div className={`rounded-md border px-3 py-2 text-sm ${ipv4Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}> <div className={`rounded-md border px-3 py-2 text-sm ${ipv4Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
<label className="flex items-start gap-3"> <label className="flex items-start gap-3">
<input <input
@@ -595,6 +598,39 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</span> </span>
)} )}
</div> </div>
{form.assign_ipv6 && (
<div className="mt-3 space-y-3 pl-6">
<div className="grid grid-cols-2 gap-3">
<label className="flex items-center gap-2 text-xs text-gray-600">
<input
type="radio"
checked={(form.ipv6_addresses || []).length === 0}
onChange={() => setForm({ ...form, ipv6_addresses: [] })}
/>
Random assign
</label>
<label className="flex items-center gap-2 text-xs text-gray-600">
<input
type="radio"
checked={(form.ipv6_addresses || []).length > 0}
onChange={() => setForm({ ...form, ipv6_addresses: [''], ipv6_count: 1 })}
/>
Custom assign
</label>
</div>
{(form.ipv6_addresses || []).length > 0 && (
<textarea
value={(form.ipv6_addresses || []).join('\n')}
onChange={(event) => {
const next = splitAddressLines(event.target.value)
setForm({ ...form, ipv6_addresses: next.length ? next : [''], ipv6_count: Math.max(1, next.length || 1) })
}}
className={`${inputClass} min-h-20 font-mono text-xs`}
placeholder="2001:db8:100::100"
/>
)}
</div>
)}
</div> </div>
<div className="rounded-md border border-gray-200 bg-white px-3 py-2 text-sm"> <div className="rounded-md border border-gray-200 bg-white px-3 py-2 text-sm">
@@ -622,25 +658,57 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</span> </span>
</span> </span>
</label> </label>
{natEnabled && ( {natEnabled && customNATPorts.length === 0 && (
<span className="block w-24 shrink-0"> <span className="block w-24 shrink-0">
<NumberInput <NumberInput
value={natPortCount} value={natPortCount}
min={2} min={2}
max={64} max={64}
onChange={(value) => setForm({ ...form, port_mapping_count: Math.max(2, value || 2), assign_nat: true })} onChange={(value) => setForm({ ...form, port_mapping_count: Math.max(2, value || 2), assign_nat: true, extra_ports: [] })}
/> />
</span> </span>
)} )}
</div> </div>
{natEnabled && ( {natEnabled && (
<div className="mt-2 pl-6"> <div className="mt-2 space-y-2 pl-6">
<div className="grid grid-cols-2 gap-2">
<label className="flex items-center gap-2 text-xs text-gray-600">
<input
type="radio"
checked={customNATPorts.length === 0}
onChange={() => setForm({ ...form, extra_ports: [], port_mapping_count: Math.max(2, form.port_mapping_count || 2) })}
/>
Auto ports
</label>
<label className="flex items-center gap-2 text-xs text-gray-600">
<input
type="radio"
checked={customNATPorts.length > 0}
onChange={() => {
const next = customNATPorts.length > 0 ? customNATPorts : [22002]
setForm({ ...form, extra_ports: next, port_mapping_count: Math.max(2, next.length + 1), assign_nat: true })
}}
/>
Custom ports
</label>
</div>
{customNATPorts.length > 0 && (
<textarea
value={customNATPorts.join('\n')}
onChange={(event) => {
const next = parsePortList(event.target.value)
setForm({ ...form, extra_ports: next, port_mapping_count: Math.max(2, next.length + 1), assign_nat: true })
}}
className={`${inputClass} min-h-16 font-mono text-xs`}
placeholder={'22002\n8080\n8443'}
/>
)}
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
<span className="inline-flex px-2 py-1 bg-emerald-50 text-emerald-700 rounded text-xs font-mono"> <span className="inline-flex px-2 py-1 bg-emerald-50 text-emerald-700 rounded text-xs font-mono">
{isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview} -&gt; {isWindowsTemplate(form.template_id) ? 3389 : 22} {isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview} -&gt; {isWindowsTemplate(form.template_id) ? 3389 : 22}
</span> </span>
{autoPorts.map((port) => ( {natPreviewPorts.map((port, index) => (
<span key={port} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono"> <span key={`${port}-${index}`} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
{port} -&gt; {port} {port} -&gt; {port}
</span> </span>
))} ))}
@@ -648,8 +716,9 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</div> </div>
)} )}
</div> </div>
</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<Field label="vCPU"> <Field label="vCPU">
<NumberInput <NumberInput
value={form.vcpu} value={form.vcpu}
@@ -672,9 +741,6 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
/> />
{resourceErrors.ram_mb && <p className="mt-1 text-xs text-red-500">{resourceErrors.ram_mb}</p>} {resourceErrors.ram_mb && <p className="mt-1 text-xs text-red-500">{resourceErrors.ram_mb}</p>}
</Field> </Field>
</div>
<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}
@@ -685,7 +751,6 @@ 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>
<div className="grid grid-cols-2 gap-3 md:col-span-2">
<Field label="下行带宽 (Mbps)"> <Field label="下行带宽 (Mbps)">
<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) })} /> <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> </Field>
@@ -698,13 +763,8 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
<Field label="写入 IO (MB/s)"> <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) })} /> <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> </Field>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{/* Traffic control */}
<div> <div>
<div className="flex items-center gap-3 mb-2"> <div className="mb-1.5 flex items-center justify-between gap-2">
<label className="text-sm font-medium text-gray-700"></label> <label className="text-sm font-medium text-gray-700"></label>
<select <select
value={form.traffic_mode} value={form.traffic_mode}
@@ -718,10 +778,10 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
{form.traffic_mode === 'total' ? ( {form.traffic_mode === 'total' ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<NumberInput value={form.monthly_traffic_gb} min={0} onChange={(value) => setForm({ ...form, monthly_traffic_gb: value })} /> <NumberInput value={form.monthly_traffic_gb} min={0} onChange={(value) => setForm({ ...form, monthly_traffic_gb: value })} />
<span className="text-xs text-gray-400">GB (0=)</span> <span className="shrink-0 text-xs text-gray-400">GB</span>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-2">
<Field label="入站 (GB)"> <Field label="入站 (GB)">
<NumberInput value={form.traffic_in_gb} min={0} onChange={(value) => setForm({ ...form, traffic_in_gb: value || 0 })} /> <NumberInput value={form.traffic_in_gb} min={0} onChange={(value) => setForm({ ...form, traffic_in_gb: value || 0 })} />
</Field> </Field>
@@ -731,7 +791,6 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
</div> </div>
)} )}
</div> </div>
<Field label="子用户快照上限"> <Field label="子用户快照上限">
<NumberInput <NumberInput
value={form.snapshot_limit} value={form.snapshot_limit}
@@ -740,8 +799,6 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
onChange={(value) => setForm({ ...form, snapshot_limit: Math.max(1, Math.round(value || 1)) })} onChange={(value) => setForm({ ...form, snapshot_limit: Math.max(1, Math.round(value || 1)) })}
/> />
</Field> </Field>
</div>
<Field label="到期时间"> <Field label="到期时间">
<div className="relative"> <div className="relative">
<CalendarClock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" /> <CalendarClock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
@@ -753,9 +810,10 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
className={`${inputClass} pl-10`} className={`${inputClass} pl-10`}
/> />
</div> </div>
<p className="text-xs text-gray-400 mt-1.5"></p> <p className="mt-1 text-[11px] leading-4 text-gray-400"></p>
</Field> </Field>
</div> </div>
</div>
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200"> <div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-md transition-colors"> <button onClick={onClose} className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-md transition-colors">
@@ -877,6 +935,8 @@ function normalizeCreateForm(form: CreateContainerRequest): CreateContainerReque
const wantsIPv6 = !!normalized.assign_ipv6 const wantsIPv6 = !!normalized.assign_ipv6
// IPv4 and NAT are mutually exclusive // IPv4 and NAT are mutually exclusive
const wantsNAT = wantsLANIPv4 || wantsIPv4 ? false : normalized.assign_nat !== false const wantsNAT = wantsLANIPv4 || wantsIPv4 ? false : normalized.assign_nat !== false
const extraPorts = wantsNAT ? normalizePortList(normalized.extra_ports || []) : []
const portMappingCount = wantsNAT ? clampInt(Math.max(normalized.port_mapping_count || 2, extraPorts.length + 1), 2, 64, 2) : 0
const linuxTemplate = !isWindowsTemplate(normalized.template_id) const linuxTemplate = !isWindowsTemplate(normalized.template_id)
const sshAuthMode = linuxTemplate ? (normalized.ssh_auth_mode || 'auto_password') : 'auto_password' const sshAuthMode = linuxTemplate ? (normalized.ssh_auth_mode || 'auto_password') : 'auto_password'
return { return {
@@ -885,7 +945,8 @@ function normalizeCreateForm(form: CreateContainerRequest): CreateContainerReque
ram_mb: Math.round(normalized.ram_mb), ram_mb: Math.round(normalized.ram_mb),
disk_gb: Math.round(normalized.disk_gb), disk_gb: Math.round(normalized.disk_gb),
assign_nat: wantsNAT, assign_nat: wantsNAT,
port_mapping_count: wantsNAT ? clampInt(normalized.port_mapping_count, 2, 64, 2) : 0, port_mapping_count: portMappingCount,
extra_ports: extraPorts,
lan_ipv4_mode: wantsLANDHCP ? 'dhcp' : (wantsLANStatic ? 'static' : ''), lan_ipv4_mode: wantsLANDHCP ? 'dhcp' : (wantsLANStatic ? 'static' : ''),
lan_interface: wantsLANIPv4 ? (normalized.lan_interface || '').trim() : '', lan_interface: wantsLANIPv4 ? (normalized.lan_interface || '').trim() : '',
lan_ipv4_address: wantsLANStatic ? (normalized.lan_ipv4_address || '').trim() : '', lan_ipv4_address: wantsLANStatic ? (normalized.lan_ipv4_address || '').trim() : '',
@@ -896,7 +957,7 @@ function normalizeCreateForm(form: CreateContainerRequest): CreateContainerReque
public_ipv4s: wantsIPv4 ? (normalized.public_ipv4s || []) : [], public_ipv4s: wantsIPv4 ? (normalized.public_ipv4s || []) : [],
assign_ipv6: wantsIPv6, assign_ipv6: wantsIPv6,
ipv6_count: wantsIPv6 ? clampInt(normalized.ipv6_count || 1, 1, 64, 1) : 0, ipv6_count: wantsIPv6 ? clampInt(normalized.ipv6_count || 1, 1, 64, 1) : 0,
ipv6_addresses: wantsIPv6 ? (normalized.ipv6_addresses || []) : [], ipv6_addresses: wantsIPv6 ? (normalized.ipv6_addresses || []).map((item) => item.trim()).filter(Boolean) : [],
ssh_auth_mode: sshAuthMode, ssh_auth_mode: sshAuthMode,
ssh_password: linuxTemplate && sshAuthMode === 'password' ? (normalized.ssh_password || '').trim() : '', ssh_password: linuxTemplate && sshAuthMode === 'password' ? (normalized.ssh_password || '').trim() : '',
ssh_public_key: linuxTemplate && sshAuthMode === 'key' ? (normalized.ssh_public_key || '').trim() : '', ssh_public_key: linuxTemplate && sshAuthMode === 'key' ? (normalized.ssh_public_key || '').trim() : '',
@@ -948,6 +1009,28 @@ function clampInt(value: number, min: number, max?: number, fallback = min) {
return Math.min(Math.max(next, min), max ?? next) return Math.min(Math.max(next, min), max ?? next)
} }
function parsePortList(value: string) {
return normalizePortList(
value
.split(/[\s,;]+/)
.map((item) => Number(item.trim()))
)
}
function normalizePortList(ports: number[]) {
const seen = new Set<number>()
const result: number[] = []
for (const port of ports) {
if (!Number.isFinite(port)) continue
const next = Math.round(port)
if (next < 1 || next > 65535 || seen.has(next)) continue
seen.add(next)
result.push(next)
if (result.length >= 63) break
}
return result
}
function isIPv4Address(value: string) { function isIPv4Address(value: string) {
const parts = value.trim().split('.') const parts = value.trim().split('.')
return parts.length === 4 && parts.every((part) => { return parts.length === 4 && parts.every((part) => {
@@ -957,6 +1040,13 @@ function isIPv4Address(value: string) {
}) })
} }
function splitAddressLines(value: string) {
return value
.split(/[\n,\s]+/)
.map((item) => item.trim())
.filter(Boolean)
}
function subnetMaskFromPrefixLen(prefixLen: number) { function subnetMaskFromPrefixLen(prefixLen: number) {
if (!Number.isFinite(prefixLen) || prefixLen < 0 || prefixLen > 32) return '255.255.255.0' if (!Number.isFinite(prefixLen) || prefixLen < 0 || prefixLen > 32) return '255.255.255.0'
const mask = prefixLen === 0 ? 0 : (0xffffffff << (32 - prefixLen)) >>> 0 const mask = prefixLen === 0 ? 0 : (0xffffffff << (32 - prefixLen)) >>> 0
+194 -2
View File
@@ -50,7 +50,10 @@ import {
getEnabledImages, getEnabledImages,
getFirewall, getFirewall,
PortMapping, PortMapping,
PublicIPv4Info,
FirewallRule, FirewallRule,
updatePublicIPv4Assignments,
updateIPv6Assignments,
reinstallContainer, reinstallContainer,
resetSSHPassword, resetSSHPassword,
restartContainer, restartContainer,
@@ -107,6 +110,8 @@ type MappingDraft = {
protocol: string protocol: string
} }
type IPAssignMode = 'clear' | 'random' | 'custom'
const emptyDraft: MappingDraft = { const emptyDraft: MappingDraft = {
index: null, index: null,
description: '', description: '',
@@ -135,6 +140,14 @@ export default function ContainerDetail() {
const vncFullscreenRef = useRef<HTMLDivElement>(null) const vncFullscreenRef = useRef<HTMLDivElement>(null)
const [vncFullscreen, setVncFullscreen] = useState(false) const [vncFullscreen, setVncFullscreen] = useState(false)
const [showNat, setShowNat] = useState(false) const [showNat, setShowNat] = useState(false)
const [showIPAssign, setShowIPAssign] = useState(false)
const [savingIPAssign, setSavingIPAssign] = useState(false)
const [ipv4AssignMode, setIPv4AssignMode] = useState<IPAssignMode>('clear')
const [ipv4AssignCount, setIPv4AssignCount] = useState(1)
const [ipv4Selected, setIPv4Selected] = useState<string[]>([])
const [ipv6AssignMode, setIPv6AssignMode] = useState<IPAssignMode>('clear')
const [ipv6AssignCount, setIPv6AssignCount] = useState(1)
const [ipv6DraftText, setIPv6DraftText] = useState('')
const [showMappingEditor, setShowMappingEditor] = useState(false) const [showMappingEditor, setShowMappingEditor] = useState(false)
const [showExpiryEdit, setShowExpiryEdit] = useState(false) const [showExpiryEdit, setShowExpiryEdit] = useState(false)
const [editExpiry, setEditExpiry] = useState('') const [editExpiry, setEditExpiry] = useState('')
@@ -630,6 +643,42 @@ export default function ContainerDetail() {
} }
} }
const openIPAssign = () => {
const currentIPv4 = (container?.public_ipv4s || []).map((item) => item.address).filter(Boolean)
const currentIPv6 = (container?.ipv6_addresses || []).map((item) => item.address).filter(Boolean)
setIPv4Selected(currentIPv4)
setIPv4AssignMode(currentIPv4.length > 0 ? 'custom' : 'clear')
setIPv4AssignCount(Math.max(1, currentIPv4.length || 1))
setIPv6DraftText(currentIPv6.join('\n'))
setIPv6AssignMode(currentIPv6.length > 0 ? 'custom' : 'clear')
setIPv6AssignCount(Math.max(1, currentIPv6.length || 1))
setShowIPAssign(true)
}
const submitIPAssign = async () => {
if (!containerIdentifier) return
setSavingIPAssign(true)
try {
await updatePublicIPv4Assignments(containerIdentifier, {
mode: ipv4AssignMode,
count: Math.max(1, Math.round(ipv4AssignCount || 1)),
addresses: ipv4AssignMode === 'custom' ? ipv4Selected : [],
})
await updateIPv6Assignments(containerIdentifier, {
mode: ipv6AssignMode,
count: Math.max(1, Math.round(ipv6AssignCount || 1)),
addresses: ipv6AssignMode === 'custom' ? splitAddressLines(ipv6DraftText) : [],
})
await fetchContainer()
setShowIPAssign(false)
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
dialog.alert('公网 IP 分配失败', error.response?.data?.message || '请检查地址是否可用或已被占用')
} finally {
setSavingIPAssign(false)
}
}
const openAddMapping = () => { const openAddMapping = () => {
if (isSubUser && container?.policy_blocked) return if (isSubUser && container?.policy_blocked) return
setDraft(emptyDraft) setDraft(emptyDraft)
@@ -864,6 +913,7 @@ export default function ContainerDetail() {
const policyBlockedText = container.policy_blocked_reason || '虚拟机被策略临时封禁' const policyBlockedText = container.policy_blocked_reason || '虚拟机被策略临时封禁'
const publicIPv4s = container.public_ipv4s || [] const publicIPv4s = container.public_ipv4s || []
const assignedIPv4List = publicIPv4s.map((item) => item.address).filter(Boolean) const assignedIPv4List = publicIPv4s.map((item) => item.address).filter(Boolean)
const allocatableIPv4s = mergeIPv4Choices(hostInfo?.network.public_ipv4_addresses || [], publicIPv4s)
const publicHost = assignedIPv4List[0] || hostInfo?.network.public_ipv4 || PUBLIC_HOST const publicHost = assignedIPv4List[0] || hostInfo?.network.public_ipv4 || PUBLIC_HOST
const ipv6List = (container.ipv6_addresses || []) const ipv6List = (container.ipv6_addresses || [])
.map((item) => item.address) .map((item) => item.address)
@@ -1189,13 +1239,26 @@ export default function ContainerDetail() {
<PlainRow label="识别码" value={container.uuid || '-'} mono copyValue={container.uuid} onCopy={copyText} /> <PlainRow label="识别码" value={container.uuid || '-'} mono copyValue={container.uuid} onCopy={copyText} />
<PlainRow label="状态" value={isRunning ? '运行中' : '已停止'} /> <PlainRow label="状态" value={isRunning ? '运行中' : '已停止'} />
<PlainRow label="内网 IP" value={container.ip || '-'} mono /> <PlainRow label="内网 IP" value={container.ip || '-'} mono />
<PlainRow label="Public IPv4" value={assignedIPv4List.length ? assignedIPv4List.join(', ') : '-'} mono copyValue={assignedIPv4List[0]} onCopy={copyText} /> <PlainRow label="Public IPv4" value={assignedIPv4List.length ? assignedIPv4List.join(', ') : '-'} mono copyValue={assignedIPv4List[0]} onCopy={copyText}>
{!isSubUser && (
<button onClick={openIPAssign} className="ml-1 p-0.5 text-gray-400 hover:text-black rounded" title="修改公网 IP 分配">
<Pencil className="w-3 h-3" />
</button>
)}
</PlainRow>
<PlainRow label="IPv6" value={ipv6List.length ? ipv6List.join(', ') : '-'} mono copyValue={ipv6List[0]} onCopy={copyText}> <PlainRow label="IPv6" value={ipv6List.length ? ipv6List.join(', ') : '-'} mono copyValue={ipv6List[0]} onCopy={copyText}>
{!isSubUser && ipv6List.length === 0 && ( {!isSubUser && (
<>
{ipv6List.length === 0 && (
<button onClick={handleAssignIPv6} disabled={actionLoading === 'ipv6'} className="ml-1 px-1.5 py-0.5 text-[10px] text-gray-600 border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-50"> <button onClick={handleAssignIPv6} disabled={actionLoading === 'ipv6'} className="ml-1 px-1.5 py-0.5 text-[10px] text-gray-600 border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-50">
Assign Assign
</button> </button>
)} )}
<button onClick={openIPAssign} className="ml-1 p-0.5 text-gray-400 hover:text-black rounded" title="修改公网 IP 分配">
<Pencil className="w-3 h-3" />
</button>
</>
)}
</PlainRow> </PlainRow>
<PlainRow label="CPU 累计时间" value={formatCPU(usage?.cpu_usage_usec || 0)} /> <PlainRow label="CPU 累计时间" value={formatCPU(usage?.cpu_usage_usec || 0)} />
<PlainRow label="创建时间" value={container.created_at} /> <PlainRow label="创建时间" value={container.created_at} />
@@ -1817,6 +1880,88 @@ export default function ContainerDetail() {
</Modal> </Modal>
)} )}
{showIPAssign && (
<Modal title="公网 IP 分配" onClose={() => setShowIPAssign(false)} wide>
<div className="grid gap-5 md:grid-cols-2">
<div className="space-y-3">
<div>
<h3 className="text-sm font-medium text-gray-900"> IPv4</h3>
<p className="mt-1 text-xs text-gray-500">SNAT </p>
</div>
<Segmented value={ipv4AssignMode} onChange={setIPv4AssignMode} />
{ipv4AssignMode === 'random' && (
<Field label="随机数量">
<input type="number" min={1} max={64} value={ipv4AssignCount} onChange={(e) => setIPv4AssignCount(parseInt(e.target.value || '1', 10))} className={inputClass} />
</Field>
)}
{ipv4AssignMode === 'custom' && (
<div className="space-y-2">
{allocatableIPv4s.length === 0 ? (
<div className="rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-500"> IPv4 IPv4 </div>
) : (
<div className="grid gap-2">
{allocatableIPv4s.map((ip) => (
<label key={`${ip.interface}-${ip.address}`} className="flex min-w-0 items-center gap-2 rounded-md border border-gray-200 px-3 py-2 text-xs text-gray-700">
<input
type="checkbox"
checked={ipv4Selected.includes(ip.address)}
onChange={(event) => {
const next = event.target.checked
? Array.from(new Set([...ipv4Selected, ip.address]))
: ipv4Selected.filter((value) => value !== ip.address)
setIPv4Selected(next)
setIPv4AssignCount(Math.max(1, next.length || 1))
}}
/>
<span className="truncate font-mono">{ip.address}</span>
<span className="shrink-0 text-gray-400">{ip.interface}</span>
{ip.gateway && <span className="shrink-0 text-gray-400">gw {ip.gateway}</span>}
</label>
))}
</div>
)}
</div>
)}
</div>
<div className="space-y-3">
<div>
<h3 className="text-sm font-medium text-gray-900"> IPv6</h3>
<p className="mt-1 text-xs text-gray-500"> IPv6 </p>
</div>
<Segmented value={ipv6AssignMode} onChange={setIPv6AssignMode} />
{ipv6AssignMode === 'random' && (
<Field label="随机数量">
<input type="number" min={1} max={64} value={ipv6AssignCount} onChange={(e) => setIPv6AssignCount(parseInt(e.target.value || '1', 10))} className={inputClass} />
</Field>
)}
{ipv6AssignMode === 'custom' && (
<Field label="IPv6 地址">
<textarea
value={ipv6DraftText}
onChange={(e) => {
setIPv6DraftText(e.target.value)
setIPv6AssignCount(Math.max(1, splitAddressLines(e.target.value).length || 1))
}}
className={`${inputClass} min-h-32 font-mono text-xs`}
placeholder="2001:db8:100::100&#10;2001:db8:100::101"
/>
</Field>
)}
</div>
</div>
<div className="mt-5 flex justify-end gap-2 border-t border-gray-200 pt-4">
<button onClick={() => setShowIPAssign(false)} disabled={savingIPAssign} className="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50">
</button>
<button onClick={submitIPAssign} disabled={savingIPAssign} className="inline-flex items-center gap-1.5 rounded-md bg-black px-3 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50">
<Save className="h-4 w-4" />
{savingIPAssign ? '保存中...' : '保存'}
</button>
</div>
</Modal>
)}
{showNat && !hasIndependentIPv4 && ( {showNat && !hasIndependentIPv4 && (
<Modal title="IPv4 NAT 端口管理" onClose={() => { setShowNat(false); setDraft(emptyDraft); setShowMappingEditor(false) }} wide extra={ <Modal title="IPv4 NAT 端口管理" onClose={() => { setShowNat(false); setDraft(emptyDraft); setShowMappingEditor(false) }} wide extra={
!isSubUser && canAddMapping && ( !isSubUser && canAddMapping && (
@@ -2442,6 +2587,28 @@ function Field({ label, children, hint }: { label: string; children: ReactNode;
) )
} }
function Segmented({ value, onChange }: { value: IPAssignMode; onChange: (value: IPAssignMode) => void }) {
const items: Array<{ value: IPAssignMode; label: string }> = [
{ value: 'clear', label: '不分配' },
{ value: 'random', label: '随机分配' },
{ value: 'custom', label: '自定义' },
]
return (
<div className="grid grid-cols-3 gap-1 rounded-md bg-gray-100 p-1">
{items.map((item) => (
<button
key={item.value}
type="button"
onClick={() => onChange(item.value)}
className={`rounded px-2 py-1.5 text-xs font-medium ${value === item.value ? 'bg-white text-black shadow-sm' : 'text-gray-600 hover:text-black'}`}
>
{item.label}
</button>
))}
</div>
)
}
function Modal({ title, children, onClose, wide = false, extra, flush = false }: { title: string; children: ReactNode; onClose: () => void; wide?: boolean; extra?: ReactNode; flush?: boolean }) { function Modal({ title, children, onClose, wide = false, extra, flush = false }: { title: string; children: ReactNode; onClose: () => void; wide?: boolean; extra?: ReactNode; flush?: boolean }) {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
@@ -2493,6 +2660,31 @@ function normalizeContainerMetricSample(point: ContainerMetricSample): MetricPoi
} }
} }
function splitAddressLines(value: string) {
return value
.split(/[\n,\s]+/)
.map((item) => item.trim())
.filter(Boolean)
}
function mergeIPv4Choices(candidates: PublicIPv4Info[], assigned: { address: string; interface?: string; prefix_len?: number; gateway?: string }[]) {
const byAddress = new Map<string, PublicIPv4Info>()
for (const item of candidates) {
if (item.address) byAddress.set(item.address, item)
}
for (const item of assigned) {
if (!item.address || byAddress.has(item.address)) continue
byAddress.set(item.address, {
address: item.address,
interface: item.interface || '',
prefix: item.prefix_len ? `${item.address}/${item.prefix_len}` : item.address,
prefix_len: item.prefix_len,
gateway: item.gateway,
})
}
return Array.from(byAddress.values()).sort((a, b) => a.address.localeCompare(b.address, undefined, { numeric: true }))
}
function historyKey(containerName: string) { function historyKey(containerName: string) {
return `clicd_container_metric_history:${containerName}` return `clicd_container_metric_history:${containerName}`
} }
+12
View File
@@ -578,6 +578,18 @@ export const getIPv6Status = () =>
export const assignIPv6 = (id: ContainerIdentifier) => export const assignIPv6 = (id: ContainerIdentifier) =>
api.post<APIResponse<Container>>(`/containers/${id}/ipv6`) api.post<APIResponse<Container>>(`/containers/${id}/ipv6`)
export interface IPAssignmentUpdateRequest {
mode: 'clear' | 'random' | 'custom'
count?: number
addresses?: string[]
}
export const updatePublicIPv4Assignments = (id: ContainerIdentifier, data: IPAssignmentUpdateRequest) =>
api.put<APIResponse<Container>>(`/containers/${id}/public-ipv4`, data)
export const updateIPv6Assignments = (id: ContainerIdentifier, data: IPAssignmentUpdateRequest) =>
api.put<APIResponse<Container>>(`/containers/${id}/ipv6-addresses`, data)
export interface RouteCapacity { export interface RouteCapacity {
used: number used: number
remaining: string remaining: string