修复了一些已知问题

This commit is contained in:
MengMengCode
2026-06-07 13:15:59 +08:00
parent f8d16ca792
commit 2df92be501
12 changed files with 120 additions and 20 deletions
+1
View File
@@ -64,3 +64,4 @@ backend/tmp/
Thumbs.db
linux.txt
push-release.ps1
deploy.ps1
+5 -1
View File
@@ -298,7 +298,11 @@ func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
}
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Resource limits updated"})
msg := "Resource limits updated"
if c.IsKVM() && c.Status == "running" {
msg = "资源已保存,请关机重启虚拟机后生效"
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: msg})
}
func getRandomPort(w http.ResponseWriter, r *http.Request, id int) {
+17 -2
View File
@@ -716,14 +716,29 @@ func UpdateVNC(containers []Container) {
SaveConfig()
}
// AllocateSSHPort allocates a new SSH port
// AllocateSSHPort allocates a new SSH port, skipping ports already used by any container
func AllocateSSHPort() int {
used := collectAllHostPorts()
port := AppConfig.NextSSHPort
AppConfig.NextSSHPort++
for used[port] {
port++
}
AppConfig.NextSSHPort = port + 1
SaveConfig()
return port
}
// collectAllHostPorts collects all host ports used by any container (LXC + KVM)
func collectAllHostPorts() map[int]bool {
used := map[int]bool{}
for _, c := range AppConfig.Containers {
for _, pm := range c.PortMappings {
used[pm.HostPort] = true
}
}
return used
}
// IsValidContainerName checks if container name is valid (no duplicate check needed, ID is primary key)
func IsValidContainerName(name string) bool {
return IsValidContainerNameSyntax(name)
+15 -1
View File
@@ -503,7 +503,8 @@ func (m *Manager) ApplyContainerLimits(c *config.Container) error {
return nil
}
if c.Status == "running" {
return fmt.Errorf("KVM resource changes require shutdown and start")
// Config already saved; domain definition will be refreshed on next start
return nil
}
if c.DiskImage == "" || c.MACAddress == "" {
return nil
@@ -901,6 +902,9 @@ func (m *Manager) GetResourceUsage(id int) (map[string]interface{}, error) {
"disk_write_bytes": writeBytes,
"disk_read_bps": 0.0,
"disk_write_bps": 0.0,
"load1": 0.0,
"load5": 0.0,
"load15": 0.0,
}
if c.DiskImage != "" {
if info, err := os.Stat(c.DiskImage); err == nil {
@@ -2264,10 +2268,20 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int {
return nil
}
used := map[int]bool{}
// Mark current container's ports
for _, pm := range c.PortMappings {
used[pm.HostPort] = true
used[pm.ContainerPort] = true
}
// Also mark all other containers' host ports (LXC + KVM)
for _, oc := range config.AppConfig.Containers {
if oc.ID == c.ID {
continue
}
for _, pm := range oc.PortMappings {
used[pm.HostPort] = true
}
}
ports := make([]int, 0, count)
for next := 20000; next <= 65535 && len(ports) < count; next++ {
if !used[next] {
+24
View File
@@ -2272,6 +2272,11 @@ func (m *Manager) GetResourceUsage(id int) (map[string]interface{}, error) {
usage := make(map[string]interface{})
// Read raw values
load1, load5, load15 := m.getContainerLoadAvg(lxcName)
usage["load1"] = load1
usage["load5"] = load5
usage["load15"] = load15
memUsage := readIntCommand(fmt.Sprintf(
"cat /sys/fs/cgroup/lxc/%[1]s/memory.current 2>/dev/null || "+
"cat /sys/fs/cgroup/lxc.payload.%[1]s/memory.current 2>/dev/null || "+
@@ -2319,6 +2324,25 @@ func (m *Manager) GetResourceUsage(id int) (map[string]interface{}, error) {
return usage, nil
}
func (m *Manager) getContainerLoadAvg(lxcName string) (float64, float64, float64) {
pid := m.getContainerInitPID(lxcName)
if pid == "" {
return 0, 0, 0
}
out, err := exec.Command("nsenter", "-t", pid, "-m", "-p", "cat", "/proc/loadavg").Output()
if err != nil {
return 0, 0, 0
}
parts := strings.Fields(string(out))
if len(parts) < 3 {
return 0, 0, 0
}
load1, _ := strconv.ParseFloat(parts[0], 64)
load5, _ := strconv.ParseFloat(parts[1], 64)
load15, _ := strconv.ParseFloat(parts[2], 64)
return load1, load5, load15
}
func (m *Manager) getContainerNetworkBytes(lxcName string) (uint64, uint64) {
pid := m.getContainerInitPID(lxcName)
if pid == "" {
+23 -1
View File
@@ -174,12 +174,24 @@ func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapp
if pm.HostPort <= 0 {
pm.HostPort = pm.ContainerPort
}
// Check current container's own mappings
for i, existing := range c.PortMappings {
if i == skipIndex {
continue
}
if existing.HostPort == pm.HostPort && existing.Protocol == pm.Protocol {
return pm, fmt.Errorf("host port %d/%s already mapped", pm.HostPort, pm.Protocol)
return pm, fmt.Errorf("host port %d/%s already mapped in this container", pm.HostPort, pm.Protocol)
}
}
// Check all other containers (LXC + KVM) for port conflicts
for _, oc := range config.AppConfig.Containers {
if oc.ID == c.ID {
continue
}
for _, existing := range oc.PortMappings {
if existing.HostPort == pm.HostPort && existing.Protocol == pm.Protocol {
return pm, fmt.Errorf("host port %d/%s already used by container %s (ID: %d)", pm.HostPort, pm.Protocol, oc.Name, oc.ID)
}
}
}
return pm, nil
@@ -190,10 +202,20 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int {
return nil
}
used := map[int]bool{}
// Mark current container's ports
for _, pm := range c.PortMappings {
used[pm.HostPort] = true
used[pm.ContainerPort] = true
}
// Also mark all other containers' host ports (LXC + KVM)
for _, oc := range config.AppConfig.Containers {
if oc.ID == c.ID {
continue
}
for _, pm := range oc.PortMappings {
used[pm.HostPort] = true
}
}
ports := make([]int, 0, count)
next := 20000
for len(ports) < count {
+1
View File
@@ -0,0 +1 @@
+4 -6
View File
@@ -16,8 +16,8 @@ export function RingStat({ value, max = 100, label, subLabel, size = 120, stroke
const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI
const percentage = Math.min(Math.max(value / max * 100, 0), 100)
const strokeDashoffset = circumference - (percentage / 100) * circumference
const percentage = max === Infinity ? Math.max(value, 0) : Math.min(Math.max(value / max * 100, 0), 100)
const strokeDashoffset = circumference - (Math.min(percentage, 100) / 100) * circumference
const bgStroke = isDark ? '#374151' : '#f3f4f6'
const progressStroke = isDark ? '#f9fafb' : '#000000'
@@ -51,7 +51,7 @@ export function RingStat({ value, max = 100, label, subLabel, size = 120, stroke
</svg>
{/* Center value */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-2xl font-bold text-black dark:text-white">{value.toFixed(percentage < 1 ? 2 : 1)}%</span>
<span className="text-2xl font-bold text-black dark:text-white">{percentage.toFixed(percentage < 1 ? 2 : 1)}%</span>
</div>
</div>
<div className="mt-2 text-center">
@@ -73,7 +73,6 @@ interface RingStatsProps {
swapUsed?: number
swapTotal?: number
loadPercent: number
loadStatus: string
diskPercent: number
diskUsed: number
diskTotal: number
@@ -90,7 +89,6 @@ export default function RingStats({
swapUsed = 0,
swapTotal = 0,
loadPercent,
loadStatus,
diskPercent,
diskUsed,
diskTotal,
@@ -125,8 +123,8 @@ export default function RingStats({
)}
<RingStat
value={loadPercent}
max={Infinity}
label="负载"
subLabel={loadStatus}
/>
<RingStat
value={diskPercent}
+3 -2
View File
@@ -654,6 +654,7 @@ export default function ContainerDetail() {
const filtered = filterHistory(history, range)
const cpuPct = clamp(usage?.cpu_usage_pct || 0)
const ramPct = container.ram_mb > 0 ? clamp(((usage?.memory_usage_bytes || 0) / (container.ram_mb * 1024 * 1024)) * 100) : 0
const loadPct = container.vcpu > 0 ? ((usage?.load1 || 0) / container.vcpu) * 100 : 0
const diskPct = container.disk_gb > 0 ? clamp(((usage?.disk_usage_bytes || 0) / (container.disk_gb * 1024 * 1024 * 1024)) * 100) : 0
const networkBps = (usage?.network_rx_bps || 0) + (usage?.network_tx_bps || 0)
const rx = usage?.network_rx_bps || 0
@@ -873,9 +874,9 @@ export default function ContainerDetail() {
subLabel={`${formatMB(usage?.memory_usage_bytes || 0)} / ${formatMB(container.ram_mb * 1024 * 1024)}`}
/>
<RingStat
value={Math.min(cpuPct, 100)}
value={loadPct}
max={Infinity}
label="负载"
subLabel={cpuPct < 70 ? '正常' : cpuPct < 90 ? '中等' : '高'}
/>
<RingStat
value={diskPct}
+1 -2
View File
@@ -119,8 +119,7 @@ export default function Dashboard() {
ramPercent={host.ram.total_mb > 0 ? (host.ram.used_mb / host.ram.total_mb) * 100 : 0}
ramUsed={host.ram.used_mb}
ramTotal={host.ram.total_mb}
loadPercent={Math.min((host.load.load1 / host.cpu.cores) * 100, 100)}
loadStatus={host.load.load1 < host.cpu.cores * 0.7 ? '正常' : host.load.load1 < host.cpu.cores * 1.0 ? '中等' : '高'}
loadPercent={(host.load.load1 / Math.max(host.cpu.cores, 1)) * 100}
diskPercent={host.disk.total_gb > 0 ? (host.disk.used_gb / host.disk.total_gb) * 100 : 0}
diskUsed={host.disk.used_gb * 1024}
diskTotal={host.disk.total_gb * 1024}
+23 -5
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Network, RefreshCw, Route, Search, Server, X } from 'lucide-react'
import { RefreshCw, Search, Server, X } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { getRoutingInfo, RoutingInfo, NAT4Route, IPv6Route } from '../services/api'
@@ -96,7 +96,7 @@ export default function Routing() {
<div className="grid gap-4 md:grid-cols-2">
<CapacityCard
title="NAT4 端口"
icon={<Route className="h-5 w-5 text-gray-600" />}
icon={<Nat4Icon />}
remaining={routing?.nat4.remaining || '0'}
total={routing?.nat4.total || '0'}
used={routing?.nat4.used || 0}
@@ -104,7 +104,7 @@ export default function Routing() {
/>
<CapacityCard
title="IPv6 地址"
icon={<Network className="h-5 w-5 text-gray-600" />}
icon={<IPv6Icon />}
remaining={formatCapacity(routing?.ipv6.remaining || '0')}
total={formatCapacity(routing?.ipv6.total || '0')}
used={routing?.ipv6.used || 0}
@@ -137,7 +137,7 @@ export default function Routing() {
</div>
</div>
{nat4Mappings.length === 0 ? (
<EmptyState icon={<Route className="h-7 w-7 text-gray-400" />} text="暂无 NAT4 端口映射" />
<EmptyState icon={<Nat4Icon className="h-7 w-7" />} text="暂无 NAT4 端口映射" />
) : (
<>
<div className="overflow-x-auto">
@@ -214,7 +214,7 @@ export default function Routing() {
</div>
</div>
{ipv6Assignments.length === 0 ? (
<EmptyState icon={<Network className="h-7 w-7 text-gray-400" />} text="暂无 IPv6 地址分配" />
<EmptyState icon={<IPv6Icon className="h-7 w-7" />} text="暂无 IPv6 地址分配" />
) : (
<>
<div className="overflow-x-auto">
@@ -357,3 +357,21 @@ function formatCapacity(value: string): string {
if (value === 'large') return '充足'
return value
}
function Nat4Icon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
<path d="M797.866667 128c64 0 115.2 51.2 119.466666 110.933333v558.933334c0 64-51.2 115.2-110.933333 119.466666H243.2c-59.733333 0-110.933333-51.2-115.2-110.933333V247.466667C128 187.733333 174.933333 136.533333 234.666667 128h563.2z m38.4 473.6H204.8v196.266667c0 21.333333 17.066667 38.4 38.4 38.4h554.666667c21.333333 0 38.4-17.066667 38.4-38.4v-196.266667z m-315.733334 76.8c21.333333 0 38.4 17.066667 38.4 42.666667 0 17.066667-12.8 34.133333-34.133333 38.4H320c-21.333333 0-38.4-17.066667-38.4-42.666667 0-17.066667 12.8-34.133333 34.133333-38.4h204.8z m157.866667 0c21.333333 0 38.4 17.066667 38.4 42.666667 0 17.066667-12.8 34.133333-34.133333 38.4h-46.933334c-21.333333 0-38.4-17.066667-38.4-42.666667 0-17.066667 12.8-34.133333 34.133334-38.4h46.933333z m119.466667-473.6h-554.666667c-21.333333 0-38.4 17.066667-38.4 38.4v277.333333h631.466667V243.2c0-17.066667-17.066667-34.133333-38.4-38.4z" />
<path d="M277.333333 426.666667V243.2h34.133334V426.666667h-34.133334zM426.666667 358.4h-34.133334V426.666667h-34.133333V243.2h72.533333c38.4 0 59.733333 25.6 59.733334 55.466667s-25.6 59.733333-64 59.733333z m-4.266667-81.066667h-34.133333v51.2h34.133333c17.066667 0 25.6-8.533333 25.6-25.6s-8.533333-25.6-25.6-25.6zM571.733333 426.666667h-25.6l-51.2-132.266667h34.133334l25.6 81.066667 25.6-81.066667h34.133333l-42.666667 132.266667zM733.866667 401.066667v25.6h-34.133334v-25.6h-72.533333v-29.866667l64-123.733333h38.4l-64 123.733333h38.4v-34.133333h34.133333v34.133333h17.066667v29.866667h-21.333333z" />
</svg>
)
}
function IPv6Icon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
<path d="M797.866667 128c64 0 115.2 51.2 119.466666 110.933333v558.933334c0 64-51.2 115.2-110.933333 119.466666H243.2c-59.733333 0-110.933333-51.2-115.2-110.933333V247.466667C128 187.733333 174.933333 136.533333 234.666667 128h563.2z m38.4 473.6H204.8v196.266667c0 21.333333 17.066667 38.4 38.4 38.4h554.666667c21.333333 0 38.4-17.066667 38.4-38.4v-196.266667z m-315.733334 76.8c21.333333 0 38.4 17.066667 38.4 42.666667 0 17.066667-12.8 34.133333-34.133333 38.4H320c-21.333333 0-38.4-17.066667-38.4-42.666667 0-17.066667 12.8-34.133333 34.133333-38.4h204.8z m157.866667 0c21.333333 0 38.4 17.066667 38.4 42.666667 0 17.066667-12.8 34.133333-34.133333 38.4h-46.933334c-21.333333 0-38.4-17.066667-38.4-42.666667 0-17.066667 12.8-34.133333 34.133334-38.4h46.933333z m119.466667-473.6h-554.666667c-21.333333 0-38.4 17.066667-38.4 38.4v277.333333h631.466667V243.2c0-17.066667-17.066667-34.133333-38.4-38.4z" />
<path d="M277.333333 426.666667V243.2h34.133334V426.666667h-34.133334zM426.666667 358.4h-34.133334V426.666667h-34.133333V243.2h72.533333c38.4 0 59.733333 25.6 59.733334 55.466667s-25.6 59.733333-64 59.733333z m-4.266667-81.066667h-34.133333v51.2h34.133333c17.066667 0 25.6-8.533333 25.6-25.6s-8.533333-25.6-25.6-25.6zM571.733333 426.666667h-25.6l-51.2-132.266667h34.133334l25.6 81.066667 25.6-81.066667h34.133333l-42.666667 132.266667zM691.2 426.666667c-34.133333 0-55.466667-21.333333-55.466667-55.466667 0-17.066667 8.533333-34.133333 17.066667-46.933333l38.4-76.8h38.4l-38.4 76.8c4.266667 0 8.533333-4.266667 12.8-4.266667 25.6 0 46.933333 21.333333 46.933333 55.466667-4.266667 29.866667-29.866667 51.2-59.733333 51.2z m0-81.066667c-12.8 0-25.6 8.533333-25.6 25.6 0 17.066667 8.533333 25.6 25.6 25.6s25.6-8.533333 25.6-25.6c-4.266667-17.066667-12.8-25.6-25.6-25.6z" />
</svg>
)
}
+3
View File
@@ -170,6 +170,9 @@ export interface ContainerUsage {
disk_write_bytes: number
disk_read_bps: number
disk_write_bps: number
load1: number
load5: number
load15: number
}
export interface APIResponse<T = unknown> {