mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-04 21:31:23 +08:00
FIX ##30
This commit is contained in:
@@ -172,6 +172,16 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
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/"):
|
||||
handleContainerSnapshots(w, r, id, action)
|
||||
case action == "port-mappings" && r.Method == http.MethodPost:
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package api
|
||||
|
||||
import "net/http"
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func HandleIPv6Status(w http.ResponseWriter, r *http.Request) {
|
||||
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})
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -115,6 +115,22 @@ func assignIPv6ByRuntime(id int) (*config.Container, error) {
|
||||
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) {
|
||||
c := config.FindContainer(id)
|
||||
if c != nil && c.IsKVM() {
|
||||
|
||||
@@ -506,13 +506,17 @@ func ensureSchemaMigrations() error {
|
||||
}
|
||||
}
|
||||
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_gateway = COALESCE(lan_ipv4_gateway, '')`); err != nil {
|
||||
return err
|
||||
}
|
||||
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_gateway = COALESCE(cfg_lan_ipv4_gateway, '')`); err != nil {
|
||||
return err
|
||||
@@ -975,6 +979,7 @@ func loadContainers() ([]Container, error) {
|
||||
var scheduleEnabled, policyBlocked, firewallEnabled, imageLimitConfigured int
|
||||
var firewallDefaultAction string
|
||||
var firewallRulesJSON, allowedImageIDs sql.NullString
|
||||
var lanIPv4Mode, lanInterface sql.NullString
|
||||
var lanIPv4Address, lanIPv4Gateway sql.NullString
|
||||
var lanIPv4PrefixLen sql.NullInt64
|
||||
if err := rows.Scan(
|
||||
@@ -983,7 +988,7 @@ func loadContainers() ([]Container, error) {
|
||||
&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.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.SSHHostKey, &c.PortMappingLimit, &c.SnapshotLimit, &c.CreatedAt, &c.ExpiresAt,
|
||||
&scheduleEnabled, &c.SnapshotScheduleIntervalHours, &c.SnapshotScheduleTime,
|
||||
@@ -993,6 +998,8 @@ func loadContainers() ([]Container, error) {
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.LANIPv4Mode = lanIPv4Mode.String
|
||||
c.LANInterface = lanInterface.String
|
||||
c.LANIPv4Address = lanIPv4Address.String
|
||||
if lanIPv4PrefixLen.Valid {
|
||||
c.LANIPv4PrefixLen = int(lanIPv4PrefixLen.Int64)
|
||||
|
||||
@@ -3363,6 +3363,109 @@ func (m *Manager) AssignIPv6(id int) (*config.Container, error) {
|
||||
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 {
|
||||
if c == nil || (c.IPv6 == "" && len(c.IPv6Addresses) == 0) {
|
||||
return nil
|
||||
|
||||
@@ -1515,6 +1515,75 @@ func (m *Manager) AssignIPv6(id int) (*config.Container, error) {
|
||||
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 {
|
||||
configFile := filepath.Join(m.LxcPath, lxcName, "config")
|
||||
data, err := os.ReadFile(configFile)
|
||||
@@ -1705,6 +1774,25 @@ exit 0
|
||||
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 {
|
||||
servicePath := filepath.Join(rootfsPath, "etc", "systemd", "system", "clicd-ipv6.service")
|
||||
if err := os.MkdirAll(filepath.Dir(servicePath), 0755); err != nil {
|
||||
@@ -1873,6 +1961,21 @@ func containerIPv6ConnectivityOK(lxcName string) bool {
|
||||
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) {
|
||||
if ipv6 == "" || uplink == "" {
|
||||
return
|
||||
|
||||
@@ -399,6 +399,64 @@ func persistAndReloadMappings(m *Manager, c *config.Container) error {
|
||||
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) {
|
||||
if pm.ContainerPort < 1 || pm.ContainerPort > 65535 {
|
||||
return pm, fmt.Errorf("container port must be 1-65535")
|
||||
|
||||
@@ -131,7 +131,8 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
const natEnabled = form.assign_nat !== false && !lanIPv4Enabled
|
||||
const lanInterfaces = useMemo(() => getLANDHCPInterfaces(hostReport), [hostReport])
|
||||
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 sshAuthMode = (form.ssh_auth_mode || 'auto_password') as SSHAuthMode
|
||||
|
||||
@@ -140,6 +141,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
const count = natPortCount
|
||||
return Array.from({ length: count - 1 }, (_, index) => 22002 + index)
|
||||
}, [natEnabled, natPortCount])
|
||||
const natPreviewPorts = customNATPorts.length > 0 ? customNATPorts : autoPorts
|
||||
|
||||
// SSH port preview (will be allocated sequentially, starting around 22000+)
|
||||
const sshPortPreview = 22000
|
||||
@@ -213,11 +215,11 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
...boundedForm,
|
||||
name,
|
||||
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),
|
||||
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,
|
||||
extra_ports: [],
|
||||
extra_ports: wantsNAT ? (boundedForm.extra_ports || []) : [],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -240,7 +242,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
|
||||
return (
|
||||
<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">
|
||||
<h2 className="text-lg font-semibold text-black">创建新容器</h2>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="px-5 py-4 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="容器名称">
|
||||
<input
|
||||
type="text"
|
||||
@@ -399,6 +401,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
</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'}`}>
|
||||
<label className="flex items-start gap-3">
|
||||
<input
|
||||
@@ -595,6 +598,39 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
</span>
|
||||
)}
|
||||
</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 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>
|
||||
</label>
|
||||
{natEnabled && (
|
||||
{natEnabled && customNATPorts.length === 0 && (
|
||||
<span className="block w-24 shrink-0">
|
||||
<NumberInput
|
||||
value={natPortCount}
|
||||
min={2}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
{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">
|
||||
<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} -> {isWindowsTemplate(form.template_id) ? 3389 : 22}
|
||||
</span>
|
||||
{autoPorts.map((port) => (
|
||||
<span key={port} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
|
||||
{natPreviewPorts.map((port, index) => (
|
||||
<span key={`${port}-${index}`} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
|
||||
{port} -> {port}
|
||||
</span>
|
||||
))}
|
||||
@@ -648,8 +716,9 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
</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">
|
||||
<NumberInput
|
||||
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>}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
<Field label="磁盘 (GB)">
|
||||
<NumberInput
|
||||
value={form.disk_gb}
|
||||
@@ -685,26 +751,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>}
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3 md:col-span-2">
|
||||
<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) })} />
|
||||
</Field>
|
||||
<Field label="上行带宽 (Mbps)">
|
||||
<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 className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{/* Traffic control */}
|
||||
<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) })} />
|
||||
</Field>
|
||||
<Field label="上行带宽 (Mbps)">
|
||||
<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 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>
|
||||
<select
|
||||
value={form.traffic_mode}
|
||||
@@ -718,10 +778,10 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
{form.traffic_mode === 'total' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<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 className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="入站 (GB)">
|
||||
<NumberInput value={form.traffic_in_gb} min={0} onChange={(value) => setForm({ ...form, traffic_in_gb: value || 0 })} />
|
||||
</Field>
|
||||
@@ -731,7 +791,6 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Field label="子用户快照上限">
|
||||
<NumberInput
|
||||
value={form.snapshot_limit}
|
||||
@@ -740,21 +799,20 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
onChange={(value) => setForm({ ...form, snapshot_limit: Math.max(1, Math.round(value || 1)) })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="到期时间">
|
||||
<div className="relative">
|
||||
<CalendarClock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="date"
|
||||
value={form.expires_at}
|
||||
onChange={(event) => setForm({ ...form, expires_at: event.target.value })}
|
||||
min={new Date().toISOString().slice(0, 10)}
|
||||
className={`${inputClass} pl-10`}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] leading-4 text-gray-400">不选则长期有效</p>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="到期时间">
|
||||
<div className="relative">
|
||||
<CalendarClock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="date"
|
||||
value={form.expires_at}
|
||||
onChange={(event) => setForm({ ...form, expires_at: event.target.value })}
|
||||
min={new Date().toISOString().slice(0, 10)}
|
||||
className={`${inputClass} pl-10`}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1.5">不选择则长期有效;选择日期后,到期会自动关机。</p>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200">
|
||||
@@ -877,6 +935,8 @@ function normalizeCreateForm(form: CreateContainerRequest): CreateContainerReque
|
||||
const wantsIPv6 = !!normalized.assign_ipv6
|
||||
// IPv4 and NAT are mutually exclusive
|
||||
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 sshAuthMode = linuxTemplate ? (normalized.ssh_auth_mode || 'auto_password') : 'auto_password'
|
||||
return {
|
||||
@@ -885,7 +945,8 @@ function normalizeCreateForm(form: CreateContainerRequest): CreateContainerReque
|
||||
ram_mb: Math.round(normalized.ram_mb),
|
||||
disk_gb: Math.round(normalized.disk_gb),
|
||||
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_interface: wantsLANIPv4 ? (normalized.lan_interface || '').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 || []) : [],
|
||||
assign_ipv6: wantsIPv6,
|
||||
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_password: linuxTemplate && sshAuthMode === 'password' ? (normalized.ssh_password || '').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)
|
||||
}
|
||||
|
||||
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) {
|
||||
const parts = value.trim().split('.')
|
||||
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) {
|
||||
if (!Number.isFinite(prefixLen) || prefixLen < 0 || prefixLen > 32) return '255.255.255.0'
|
||||
const mask = prefixLen === 0 ? 0 : (0xffffffff << (32 - prefixLen)) >>> 0
|
||||
|
||||
@@ -50,7 +50,10 @@ import {
|
||||
getEnabledImages,
|
||||
getFirewall,
|
||||
PortMapping,
|
||||
PublicIPv4Info,
|
||||
FirewallRule,
|
||||
updatePublicIPv4Assignments,
|
||||
updateIPv6Assignments,
|
||||
reinstallContainer,
|
||||
resetSSHPassword,
|
||||
restartContainer,
|
||||
@@ -107,6 +110,8 @@ type MappingDraft = {
|
||||
protocol: string
|
||||
}
|
||||
|
||||
type IPAssignMode = 'clear' | 'random' | 'custom'
|
||||
|
||||
const emptyDraft: MappingDraft = {
|
||||
index: null,
|
||||
description: '',
|
||||
@@ -135,6 +140,14 @@ export default function ContainerDetail() {
|
||||
const vncFullscreenRef = useRef<HTMLDivElement>(null)
|
||||
const [vncFullscreen, setVncFullscreen] = 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 [showExpiryEdit, setShowExpiryEdit] = useState(false)
|
||||
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 = () => {
|
||||
if (isSubUser && container?.policy_blocked) return
|
||||
setDraft(emptyDraft)
|
||||
@@ -864,6 +913,7 @@ export default function ContainerDetail() {
|
||||
const policyBlockedText = container.policy_blocked_reason || '虚拟机被策略临时封禁'
|
||||
const publicIPv4s = container.public_ipv4s || []
|
||||
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 ipv6List = (container.ipv6_addresses || [])
|
||||
.map((item) => item.address)
|
||||
@@ -1189,14 +1239,27 @@ export default function ContainerDetail() {
|
||||
<PlainRow label="识别码" value={container.uuid || '-'} mono copyValue={container.uuid} onCopy={copyText} />
|
||||
<PlainRow label="状态" value={isRunning ? '运行中' : '已停止'} />
|
||||
<PlainRow label="内网 IP" value={container.ip || '-'} mono />
|
||||
<PlainRow label="Public IPv4" value={assignedIPv4List.length ? assignedIPv4List.join(', ') : '-'} mono copyValue={assignedIPv4List[0]} onCopy={copyText} />
|
||||
<PlainRow label="IPv6" value={ipv6List.length ? ipv6List.join(', ') : '-'} mono copyValue={ipv6List[0]} onCopy={copyText}>
|
||||
{!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">
|
||||
Assign
|
||||
<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}>
|
||||
{!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">
|
||||
Assign
|
||||
</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 label="CPU 累计时间" value={formatCPU(usage?.cpu_usage_usec || 0)} />
|
||||
<PlainRow label="创建时间" value={container.created_at} />
|
||||
<PlainRow label="到期时间" value={formatExpiration(container.expires_at)}>
|
||||
@@ -1817,6 +1880,88 @@ export default function ContainerDetail() {
|
||||
</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 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 && (
|
||||
<Modal title="IPv4 NAT 端口管理" onClose={() => { setShowNat(false); setDraft(emptyDraft); setShowMappingEditor(false) }} wide extra={
|
||||
!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 }) {
|
||||
return (
|
||||
<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) {
|
||||
return `clicd_container_metric_history:${containerName}`
|
||||
}
|
||||
|
||||
@@ -578,6 +578,18 @@ export const getIPv6Status = () =>
|
||||
export const assignIPv6 = (id: ContainerIdentifier) =>
|
||||
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 {
|
||||
used: number
|
||||
remaining: string
|
||||
|
||||
Reference in New Issue
Block a user