diff --git a/README.md b/README.md index 0cb851d..fc4fd64 100644 --- a/README.md +++ b/README.md @@ -19,14 +19,13 @@ CLICD 是一个面向 LXC 的轻量容器管理面板,提供 Web 控制台、C 1. 支持 Ubuntu、Debian、Alpine、CentOS、Arch Linux、Fedora、Rocky Linux 等系统镜像。镜像可以在镜像管理中按需下载;如果宿主机资源比较小,建议优先选择 Alpine 这类轻量镜像。 2. 支持 WebSSH 管理,可以在浏览器里一键进入容器终端,不需要手动复制 SSH 密码。 -3. 支持子用户管理链接,管理员可以把指定容器分发给拼车用户,子用户只能管理自己被授权的容器。 -4. 支持设置 NAT4 端口数量、NAT 端口映射和协议限制,并支持分配公网 IPv6。IPv6 分配要求宿主机本身拥有可路由的 IPv6 地址段。 -5. 支持超售容量估算。宿主机控制页提供 KSM 合并、Swap 倾向和 cgroup v2 `memory.reclaim` 一次性回收能力;不会展示 LXC 下无实际通用效果的内存气球回收开关。 -6. 支持 API 接入,可以通过 API 完成容器、任务、镜像、端口、流量、安全告警等功能的自动化控制。 -7. 支持仅使用 CLI 管理。需要关闭 Web 控制台时,可以停止并禁用 systemd 服务,然后使用 `clicd cli --no-web` 进入命令行模式。 -8. 支持设置容器有效期。到期后容器会自动关机,子用户无法继续操作,只有管理员重新设置延期日期后才能恢复使用。 -9. 支持单向和双向网络流量限制。达到限制后容器会自动关机,避免流量超额。 -10. 内置基于 conntrack 的轻量安全告警。系统不会保存完整正常连接日志,但会对端口扫描、横向扫描、爆破倾向、SMTP 滥用、UDP 反射、挖矿端口、代理/VPN/Tor 等可疑行为生成告警并写入审计日志。 +3. 支持设置 NAT4 端口数量、NAT 端口映射和协议限制,并支持分配公网 IPv6。IPv6 分配要求宿主机本身拥有可路由的 IPv6 地址段。 +4. 支持单向和双向网络流量限制。达到限制后容器会自动关机,避免流量超额。 +5. 支持设置容器有效期。到期后容器会自动关机,子用户无法继续操作,只有管理员重新设置延期日期后才能恢复使用。 +6. 内置基于 conntrack 的轻量安全告警。系统不会保存完整正常连接日志,但会对端口扫描、横向扫描、爆破倾向、SMTP 滥用、UDP 反射、挖矿端口、代理/VPN/Tor 等可疑行为生成告警并写入审计日志。 +7. 支持子用户管理链接,管理员可以把指定容器分发给拼车用户,子用户只能管理自己被授权的容器。 +8. 支持 API 接入,可以通过 API 完成容器、任务、镜像、端口、流量、安全告警等功能的自动化控制。 +9. 支持仅使用 CLI 管理。需要关闭 Web 控制台时,可以停止并禁用 systemd 服务,然后使用 `clicd cli --no-web` 进入命令行模式。 ## 技术栈 @@ -61,4 +60,8 @@ curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh Star History Chart - \ No newline at end of file + + +## 鸣谢 + +- [Linux.do](https://linux.do) — 一个充满灵感的科技社区 \ No newline at end of file diff --git a/backend/internal/api/oversell.go b/backend/internal/api/oversell.go deleted file mode 100644 index da0eb82..0000000 --- a/backend/internal/api/oversell.go +++ /dev/null @@ -1,227 +0,0 @@ -package api - -import ( - "encoding/json" - "fmt" - "net/http" - "os" - "os/exec" - "strconv" - "strings" - - "clicd/internal/config" -) - -// HandleOversell handles GET/POST for oversell config -func HandleOversell(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - getOversell(w, r) - case http.MethodPost: - updateOversell(w, r) - default: - jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) - } -} - -func getOversell(w http.ResponseWriter, r *http.Request) { - jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: config.AppConfig.Oversell}) -} - -func updateOversell(w http.ResponseWriter, r *http.Request) { - var cfg config.OversellConfig - if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { - jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) - return - } - if cfg.SubUserSnapshotLimit <= 0 { - cfg.SubUserSnapshotLimit = 3 - } - - // Apply KSM - if cfg.KSMEnabled { - exec.Command("sh", "-c", "echo 1 > /sys/kernel/mm/ksm/run 2>/dev/null").Run() - exec.Command("sh", "-c", "echo 1000 > /sys/kernel/mm/ksm/sleep_millisecs 2>/dev/null").Run() - } else { - exec.Command("sh", "-c", "echo 0 > /sys/kernel/mm/ksm/run 2>/dev/null").Run() - } - - // Apply swappiness - if cfg.Swappiness >= 0 && cfg.Swappiness <= 100 { - exec.Command("sh", "-c", fmt.Sprintf("echo %d > /proc/sys/vm/swappiness", cfg.Swappiness)).Run() - } - - // Oversell multipliers are capacity-planning values. They must not increase - // an individual container's CPU or RAM limits. - reapplyContainerLimits() - - config.AppConfig.Oversell = cfg - if err := config.SaveConfig(); err != nil { - jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save config"}) - return - } - - jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Oversell config updated", Data: cfg}) -} - -// reapplyContainerLimits restores cgroup limits for all running containers from -// their assigned container resources. -func reapplyContainerLimits() { - for _, c := range config.AppConfig.Containers { - if c.Status != "running" { - continue - } - if err := lxcManager.ApplyContainerLimits(&c); err != nil { - fmt.Printf("Warning: failed to reapply resource limits for %s: %v\n", c.LxcName(), err) - } - } -} - -// HandleOversellStatus returns current oversell resource usage -func HandleOversellStatus(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) - return - } - - status := map[string]interface{}{ - "ksm_active": isKSMEnabled(), - "ksm_pages": getKSMPages(), - "ksm_supported": isKSMSupported(), - "swappiness": getSwappiness(), - "reclaim_supported": isMemoryReclaimSupported(), - "allocated_cpu": getAllocatedCPU(), - "allocated_ram_mb": getAllocatedRAM(), - "allocated_disk_gb": getAllocatedDisk(), - } - - jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: status}) -} - -// HandleOversellReclaim triggers one cgroup v2 memory.reclaim pass for running containers. -func HandleOversellReclaim(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) - return - } - - result := reclaimContainerMemory() - jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Memory reclaim triggered", Data: result}) -} - -func reclaimContainerMemory() map[string]interface{} { - attempted := 0 - reclaimed := 0 - unsupported := 0 - errors := make([]string, 0) - - for _, c := range config.AppConfig.Containers { - if c.Status != "running" { - continue - } - attempted++ - reclaimPath := findMemoryReclaimPath(c.LxcName()) - if reclaimPath == "" { - unsupported++ - continue - } - if err := os.WriteFile(reclaimPath, []byte("64M"), 0644); err != nil { - errors = append(errors, fmt.Sprintf("%s: %v", c.Name, err)) - continue - } - reclaimed++ - } - - return map[string]interface{}{ - "attempted": attempted, - "reclaimed": reclaimed, - "unsupported": unsupported, - "errors": errors, - } -} - -func isKSMEnabled() bool { - data, err := os.ReadFile("/sys/kernel/mm/ksm/run") - if err != nil { - return false - } - return strings.TrimSpace(string(data)) == "1" -} - -func isKSMSupported() bool { - if _, err := os.Stat("/sys/kernel/mm/ksm/run"); err != nil { - return false - } - return true -} - -func getKSMPages() int64 { - data, err := os.ReadFile("/sys/kernel/mm/ksm/pages_shared") - if err != nil { - return 0 - } - val, _ := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64) - return val -} - -func getSwappiness() int { - data, err := os.ReadFile("/proc/sys/vm/swappiness") - if err != nil { - return 60 - } - val, _ := strconv.Atoi(strings.TrimSpace(string(data))) - return val -} - -func isMemoryReclaimSupported() bool { - if _, err := os.Stat("/sys/fs/cgroup/memory.reclaim"); err == nil { - return true - } - for _, c := range config.AppConfig.Containers { - if c.Status != "running" { - continue - } - if findMemoryReclaimPath(c.LxcName()) != "" { - return true - } - } - return false -} - -func findMemoryReclaimPath(lxcName string) string { - candidates := []string{ - fmt.Sprintf("/sys/fs/cgroup/lxc/%s/memory.reclaim", lxcName), - fmt.Sprintf("/sys/fs/cgroup/lxc.payload.%s/memory.reclaim", lxcName), - fmt.Sprintf("/sys/fs/cgroup/system.slice/lxc@%s.service/memory.reclaim", lxcName), - } - for _, path := range candidates { - if _, err := os.Stat(path); err == nil { - return path - } - } - return "" -} - -func getAllocatedCPU() float64 { - total := 0.0 - for _, c := range config.AppConfig.Containers { - total += c.VCPU - } - return total -} - -func getAllocatedRAM() int64 { - total := int64(0) - for _, c := range config.AppConfig.Containers { - total += int64(c.RAMMB) - } - return total -} - -func getAllocatedDisk() int64 { - total := int64(0) - for _, c := range config.AppConfig.Containers { - total += int64(c.DiskGB) - } - return total -} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 34f6d8d..3827a85 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -58,16 +58,6 @@ type AuditLog struct { Error string `json:"error,omitempty"` } -// OversellConfig controls host-level overselling behavior -type OversellConfig struct { - CPUOvercommit int `json:"cpu_overcommit"` // multiplier, e.g. 4 means 4x oversell - RAMOvercommit int `json:"ram_overcommit"` // multiplier - DiskOvercommit int `json:"disk_overcommit"` // multiplier - KSMEnabled bool `json:"ksm_enabled"` // kernel same-page merging - Swappiness int `json:"swappiness"` // 0-100, lower = less swap - SubUserSnapshotLimit int `json:"sub_user_snapshot_limit"` // legacy default for migrating old containers -} - // Container represents an LXC container configuration type Container struct { ID int `json:"id"` @@ -211,7 +201,6 @@ type ClicdConfig struct { NextVNCPort int `json:"next_vnc_port"` NextSSHPort int `json:"next_ssh_port"` SetupComplete bool `json:"setup_complete"` - Oversell OversellConfig `json:"oversell"` SubUsers []SubUser `json:"sub_users"` ApiKeys []ApiKeyConfig `json:"api_keys"` AuditLogs []AuditLog `json:"audit_logs"` @@ -312,14 +301,6 @@ func InitConfig() (*ClicdConfig, error) { AuditLogs: []AuditLog{}, Tasks: []SavedTask{}, LoginLogs: []SavedLoginLog{}, - Oversell: OversellConfig{ - CPUOvercommit: 4, - RAMOvercommit: 1, - DiskOvercommit: 2, - KSMEnabled: true, - Swappiness: 10, - SubUserSnapshotLimit: 3, - }, Snapshots: []Snapshot{}, } @@ -373,9 +354,6 @@ func InitConfig() (*ClicdConfig, error) { if AppConfig.Snapshots == nil { AppConfig.Snapshots = make([]Snapshot, 0) } - if AppConfig.Oversell.SubUserSnapshotLimit <= 0 { - AppConfig.Oversell.SubUserSnapshotLimit = 3 - } changed := ensureContainerUUIDs() if ensureContainerVirtualization() { changed = true @@ -468,13 +446,9 @@ func ensureContainerPortMappingLimits() bool { func ensureContainerSnapshotLimits() bool { changed := false - legacyLimit := AppConfig.Oversell.SubUserSnapshotLimit - if legacyLimit <= 0 { - legacyLimit = DefaultSnapshotLimit - } for i := range AppConfig.Containers { if AppConfig.Containers[i].SnapshotLimit <= 0 { - AppConfig.Containers[i].SnapshotLimit = legacyLimit + AppConfig.Containers[i].SnapshotLimit = DefaultSnapshotLimit changed = true } } diff --git a/backend/internal/kvm/templates.go b/backend/internal/kvm/templates.go index 0c2a187..97e895f 100644 --- a/backend/internal/kvm/templates.go +++ b/backend/internal/kvm/templates.go @@ -41,10 +41,10 @@ func GetImages() []Image { URL: "https://cloud.debian.org/images/cloud/bullseye/latest/debian-11-genericcloud-amd64.qcow2", }, { - ID: "kvm-rockylinux-9", Name: "Rocky Linux 9 KVM", - Distro: "rockylinux", Release: "9", Arch: "amd64", - Description: "Rocky Linux 9 GenericCloud image for KVM", - URL: "https://dl.rockylinux.org/pub/rocky/9/images/x86_64/Rocky-9-GenericCloud-Base.latest.x86_64.qcow2", + ID: "kvm-alpine-3.23", Name: "Alpine 3.23 KVM", + Distro: "alpine", Release: "3.23", Arch: "amd64", + Description: "Alpine Linux 3.23 NoCloud cloud-init image for KVM", + URL: "https://dev.alpinelinux.org/~tomalok/alpine-cloud-images/v3.23/nocloud/x86_64/nocloud_alpine-3.23.4-x86_64-bios-cloudinit-r0.qcow2", }, { ID: "kvm-centos-9-stream", Name: "CentOS Stream 9 KVM", @@ -53,10 +53,22 @@ func GetImages() []Image { URL: "https://cloud.centos.org/centos/9-stream/x86_64/images/CentOS-Stream-GenericCloud-9-latest.x86_64.qcow2", }, { - ID: "kvm-alpine-3.23", Name: "Alpine 3.23 KVM", - Distro: "alpine", Release: "3.23", Arch: "amd64", - Description: "Alpine Linux 3.23 NoCloud cloud-init image for KVM", - URL: "https://dev.alpinelinux.org/~tomalok/alpine-cloud-images/v3.23/nocloud/x86_64/nocloud_alpine-3.23.4-x86_64-bios-cloudinit-r0.qcow2", + ID: "kvm-archlinux-current", Name: "Arch Linux KVM", + Distro: "archlinux", Release: "current", Arch: "amd64", + Description: "Arch Linux (Rolling) cloud image for KVM", + URL: "https://geo.mirror.pkgbuild.com/images/latest/Arch-Linux-x86_64-cloudimg.qcow2", + }, + { + ID: "kvm-fedora-44", Name: "Fedora 44 KVM", + Distro: "fedora", Release: "44", Arch: "amd64", + Description: "Fedora 44 GenericCloud image for KVM", + URL: "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-44-1.7.x86_64.qcow2", + }, + { + ID: "kvm-rockylinux-9", Name: "Rocky Linux 9 KVM", + Distro: "rockylinux", Release: "9", Arch: "amd64", + Description: "Rocky Linux 9 GenericCloud image for KVM", + URL: "https://dl.rockylinux.org/pub/rocky/9/images/x86_64/Rocky-9-GenericCloud-Base.latest.x86_64.qcow2", }, } } diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index f5fab42..5360faf 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -88,9 +88,6 @@ func setupRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots))) mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting))) mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status))) - mux.HandleFunc("/api/oversell", corsMiddleware(api.AdminMiddleware(api.HandleOversell))) - mux.HandleFunc("/api/oversell/status", corsMiddleware(api.AdminMiddleware(api.HandleOversellStatus))) - mux.HandleFunc("/api/oversell/reclaim", corsMiddleware(api.AdminMiddleware(api.HandleOversellReclaim))) mux.HandleFunc("/api/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks)))) mux.HandleFunc("/api/tasks/", corsMiddleware(api.AuthMiddleware(api.AdminMiddleware(api.HandleTaskDelete)))) mux.HandleFunc("/api/batch-create", corsMiddleware(api.AdminMiddleware(api.HandleBatchCreate))) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fc31aad..7d07f9a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,7 +4,7 @@ import Login from './pages/Login' import Dashboard from './pages/Dashboard' import Containers from './pages/Containers' import ContainerDetail from './pages/ContainerDetail' -import Oversell from './pages/Oversell' + import Security from './pages/Security' import AuditLogs from './pages/AuditLogs' import ApiIntegration from './pages/ApiIntegration' @@ -58,7 +58,7 @@ function App() { } /> } /> } /> - } /> + } /> } /> } /> diff --git a/frontend/src/components/CreateContainerModal.tsx b/frontend/src/components/CreateContainerModal.tsx index cf84b13..1531d69 100644 --- a/frontend/src/components/CreateContainerModal.tsx +++ b/frontend/src/components/CreateContainerModal.tsx @@ -26,7 +26,7 @@ const defaultForm: CreateContainerRequest = { io_speed_mbps: 0, extra_ports: [], port_mapping_count: 2, - snapshot_limit: 3, + snapshot_limit: 1, assign_ipv6: false, expires_at: '', } diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index a9f6273..397ba3d 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -12,7 +12,7 @@ import { Route, ScrollText, Server, - Settings2, + ShieldAlert, Sun, UserCog, @@ -49,7 +49,7 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) { location.pathname.startsWith('/container') const isImagesPage = location.pathname.startsWith('/images') - const isOversellPage = location.pathname.startsWith('/oversell') + const isSnapshotsPage = location.pathname.startsWith('/snapshots') const isRoutingPage = location.pathname.startsWith('/routing') const isAuditLogsPage = location.pathname.startsWith('/audit-logs') @@ -133,18 +133,6 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) { {!isSubUser && ( <> - - - + {error && ( @@ -128,6 +117,58 @@ export default function ImageManagement() { )} + img.downloaded).length} + totalCount={lxcImages.length} + onDownload={handleDownload} + onDelete={handleDelete} + onToggle={handleToggle} + /> + + img.downloaded).length} + totalCount={kvmImages.length} + onDownload={handleDownload} + onDelete={handleDelete} + onToggle={handleToggle} + /> + + ) +} + +function ImageTable({ + title, + images, + actionLoading, + downloadedCount, + totalCount, + onDownload, + onDelete, + onToggle, +}: { + title: string + images: ImageInfo[] + actionLoading: string | null + downloadedCount: number + totalCount: number + onDownload: (id: string) => void + onDelete: (id: string) => void + onToggle: (id: string, enabled: boolean) => void +}) { + return ( +
+
+

{title}

+ + 已下载 {downloadedCount}/{totalCount} + +
@@ -139,9 +180,6 @@ export default function ImageManagement() { - @@ -157,7 +195,7 @@ export default function ImageManagement() { - {visibleImages.map((img) => { + {images.map((img) => { const isBusy = actionLoading === img.id return ( @@ -175,11 +213,6 @@ export default function ImageManagement() { - @@ -193,7 +226,7 @@ export default function ImageManagement() {
{!img.downloaded && !img.downloading && ( -
- -
- } - label="已分配 vCPU" - value={String(status?.allocated_cpu || 0)} - hint={`超售倍数: ${config.cpu_overcommit}x`} - /> - } - label="已分配内存" - value={formatMB(status?.allocated_ram_mb || 0)} - hint={`超售倍数: ${config.ram_overcommit}x`} - /> - } - label="已分配磁盘" - value={`${status?.allocated_disk_gb || 0} GB`} - hint={`超售倍数: ${config.disk_overcommit}x`} - /> -
- -
-

超售倍数

-
- setConfig({ ...config, cpu_overcommit: v })} - hint="只用于容量估算,不改变单台容器限制" - /> - setConfig({ ...config, ram_overcommit: v })} - hint="只用于容量估算,不改变单台容器限制" - /> - setConfig({ ...config, disk_overcommit: v })} - hint="用于容量预估,实际写入仍受文件系统限制" - /> -
-
- -
-
-

容量预估

- 按单台容器配置计算 -
- -
-
- setEstimateSpec({ ...estimateSpec, vcpu: value })} - /> - setEstimateSpec({ ...estimateSpec, ramMb: value })} - /> - setEstimateSpec({ ...estimateSpec, diskGb: value })} - /> -
- - {estimate && ( -
-
-
预计最多可开
-
{estimate.remainingCount}
-
- 理论上限 {estimate.totalCount} 台,当前受 {estimate.bottleneckLabel} 限制 -
-
-
-
发行版 - 类型 - 架构
{img.distro} {img.release} - - {(img.type || 'lxc').toUpperCase()} - - {img.arch}
- - - - - - - - - - - {estimate.rows.map((row) => ( - - - - - - - - ))} - -
资源实际超售后已分配剩余可开
{row.label}{row.actual}{row.capacity}{row.allocated}{row.remainingCount}
-
-
- )} -
- - -
-

内存优化

-
- setConfig({ ...config, ksm_enabled: v })} - extra={ksmSupported ? `已合并 ${status?.ksm_pages || 0} 页` : '当前内核不支持 KSM'} - /> - setConfig({ ...config, swappiness: v })} - hint="写入 /proc/sys/vm/swappiness,值越低越少使用 swap" - /> - -
-
- -
- -
- - ) -} - -function ResourceCard({ icon, label, value, hint }: { - icon: ReactNode - label: string - value: string - hint: string -}) { - return ( -
-
- {icon}{label} -
-
{value}
-
{hint}
-
- ) -} - -function SliderField({ label, value, min, max, suffix, onChange, hint }: { - label: string - value: number - min: number - max: number - suffix: string - onChange: (v: number) => void - hint?: string -}) { - return ( -
-
- {label} - {value}{suffix} -
- onChange(parseInt(e.target.value, 10) || min)} - className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-black" - /> -
- {min}{suffix}{max}{suffix} -
- {hint &&
{hint}
} -
- ) -} - -function NumberField({ label, value, min, step = 1, onChange }: { - label: string - value: number - min: number - step?: number - onChange: (value: number) => void -}) { - return ( - - ) -} - -function ToggleRow({ label, desc, value, disabled = false, onChange, extra }: { - label: string - desc: string - value: boolean - disabled?: boolean - onChange: (v: boolean) => void - extra?: string -}) { - return ( -
-
-
{label}
-
{desc}
- {extra &&
{extra}
} -
- -
- ) -} - -function ActionRow({ title, desc, disabled, busy, onClick }: { - title: string - desc: string - disabled: boolean - busy: boolean - onClick: () => void -}) { - return ( -
-
-
{title}
-
{desc}
-
- -
- ) -} - -type EstimateSpec = { - vcpu: number - ramMb: number - diskGb: number -} - -type EstimateRow = { - label: string - actual: string - capacity: string - allocated: string - totalCount: number - remainingCount: number -} - -function buildCapacityEstimate( - host: HostInfo, - status: OversellStatus | null, - config: OversellConfig, - spec: EstimateSpec -) { - const cpuCapacity = host.cpu.cores * config.cpu_overcommit - const ramCapacity = host.ram.total_mb * config.ram_overcommit - const diskCapacity = host.disk.total_gb * config.disk_overcommit - - const allocatedCPU = status?.allocated_cpu || 0 - const allocatedRAM = status?.allocated_ram_mb || 0 - const allocatedDisk = status?.allocated_disk_gb || 0 - - const rows: EstimateRow[] = [ - { - label: 'CPU', - actual: `${host.cpu.cores} 核`, - capacity: `${cpuCapacity} vCPU`, - allocated: `${allocatedCPU} vCPU`, - totalCount: safeFloor(cpuCapacity / spec.vcpu), - remainingCount: safeFloor((cpuCapacity - allocatedCPU) / spec.vcpu), - }, - { - label: '内存', - actual: formatMB(Number(host.ram.total_mb)), - capacity: formatMB(ramCapacity), - allocated: formatMB(allocatedRAM), - totalCount: safeFloor(ramCapacity / spec.ramMb), - remainingCount: safeFloor((ramCapacity - allocatedRAM) / spec.ramMb), - }, - { - label: '磁盘', - actual: `${host.disk.total_gb} GB`, - capacity: `${diskCapacity} GB`, - allocated: `${allocatedDisk} GB`, - totalCount: safeFloor(diskCapacity / spec.diskGb), - remainingCount: safeFloor((diskCapacity - allocatedDisk) / spec.diskGb), - }, - ] - - const totalCount = Math.min(...rows.map((row) => row.totalCount)) - const remainingCount = Math.min(...rows.map((row) => row.remainingCount)) - const bottleneck = rows.reduce((current, row) => row.remainingCount < current.remainingCount ? row : current, rows[0]) - - return { - rows, - totalCount, - remainingCount, - bottleneckLabel: bottleneck.label, - } -} - -function safeFloor(value: number): number { - if (!Number.isFinite(value) || value <= 0) return 0 - return Math.floor(value) -} - -function getErrorMessage(err: unknown, fallback: string): string { - if (typeof err === 'object' && err !== null && 'response' in err) { - const response = (err as { response?: { data?: { message?: string } } }).response - return response?.data?.message || fallback - } - return fallback -} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index d21ed23..60b22ce 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -374,45 +374,6 @@ export const getDashboard = () => export const getHostInfo = () => api.get>('/host-info') -// Oversell -export interface OversellConfig { - cpu_overcommit: number - ram_overcommit: number - disk_overcommit: number - ksm_enabled: boolean - swappiness: number -} - -export interface OversellStatus { - ksm_active: boolean - ksm_pages: number - ksm_supported: boolean - swappiness: number - reclaim_supported: boolean - allocated_cpu: number - allocated_ram_mb: number - allocated_disk_gb: number -} - -export interface ReclaimResult { - attempted: number - reclaimed: number - unsupported: number - errors: string[] -} - -export const getOversell = () => - api.get>('/oversell') - -export const updateOversell = (data: OversellConfig) => - api.post>('/oversell', data) - -export const getOversellStatus = () => - api.get>('/oversell/status') - -export const reclaimMemory = () => - api.post>('/oversell/reclaim') - // Snapshots export interface Snapshot { id: string