mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-05 05:36:07 +08:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e9f657ab17 | |||
| 7e5da67de4 | |||
| c99f3f6d55 | |||
| 2df92be501 | |||
| f8d16ca792 | |||
| be17f669f7 | |||
| 65fc787070 | |||
| 245c57449c | |||
| 6dd7079e23 | |||
| 422e48b524 | |||
| 3488b6db56 | |||
| c7ba19fa34 | |||
| ffedf801e7 | |||
| 49d5a65357 | |||
| 8f32765ffe |
+1
Submodule .claude/worktrees/agent-ae3871aebda20eb86 added at 422e48b524
@@ -64,3 +64,4 @@ backend/tmp/
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
linux.txt
|
linux.txt
|
||||||
push-release.ps1
|
push-release.ps1
|
||||||
|
deploy.ps1
|
||||||
|
|||||||
@@ -11,28 +11,28 @@
|
|||||||
<img alt="Vite" src="https://img.shields.io/badge/Vite-5-646CFF?style=flat-square&logo=vite&logoColor=white">
|
<img alt="Vite" src="https://img.shields.io/badge/Vite-5-646CFF?style=flat-square&logo=vite&logoColor=white">
|
||||||
<img alt="Tailwind CSS" src="https://img.shields.io/badge/Tailwind_CSS-3-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white">
|
<img alt="Tailwind CSS" src="https://img.shields.io/badge/Tailwind_CSS-3-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white">
|
||||||
<img alt="LXC" src="https://img.shields.io/badge/LXC-container-111111?style=flat-square">
|
<img alt="LXC" src="https://img.shields.io/badge/LXC-container-111111?style=flat-square">
|
||||||
|
<img alt="KVM" src="https://img.shields.io/badge/KVM-virtualization-EE0000?style=flat-square&logo=linux&logoColor=white">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
CLICD 是一个面向 LXC 的轻量容器管理面板,提供 Web 控制台、CLI、批量任务、镜像管理、NAT 端口、IPv6 分配、WebSSH、资源限制、流量限制和安全告警能力。它适合用来管理小型 VPS 上的 LXC 容器,也适合需要批量创建和分发子用户管理链接的场景。
|
CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板,提供 Web 控制台、CLI、批量任务、镜像管理、NAT 端口、IPv6 分配、WebSSH、VNC、资源限制、流量限制和安全告警能力。它适合用来管理小型 VPS 上的 LXC 容器和 KVM 虚拟机,也适合需要批量创建和分发子用户管理链接的场景。
|
||||||
|
|
||||||
## 功能介绍
|
## 功能介绍
|
||||||
|
|
||||||
1. 支持 Ubuntu、Debian、Alpine、CentOS、Arch Linux、Fedora、Rocky Linux 等系统镜像。镜像可以在镜像管理中按需下载;如果宿主机资源比较小,建议优先选择 Alpine 这类轻量镜像。
|
1. 支持 Ubuntu、Debian、Alpine、CentOS、Arch Linux、Fedora、Rocky Linux 等系统镜像。镜像可以在镜像管理中按需下载;如果宿主机资源比较小,建议优先选择 Alpine 这类轻量镜像。
|
||||||
2. 支持 WebSSH 管理,可以在浏览器里一键进入容器终端,不需要手动复制 SSH 密码。
|
2. 支持 WebSSH 管理,可以在浏览器里一键进入容器终端,不需要手动复制 SSH 密码。
|
||||||
3. 支持子用户管理链接,管理员可以把指定容器分发给拼车用户,子用户只能管理自己被授权的容器。
|
3. 支持设置 NAT4 端口数量、NAT 端口映射和协议限制,并支持分配公网 IPv6。IPv6 分配要求宿主机本身拥有可路由的 IPv6 地址段。
|
||||||
4. 支持设置 NAT4 端口数量、NAT 端口映射和协议限制,并支持分配公网 IPv6。IPv6 分配要求宿主机本身拥有可路由的 IPv6 地址段。
|
4. 支持单向和双向网络流量限制。达到限制后容器会自动关机,避免流量超额。
|
||||||
5. 支持超售容量估算。宿主机控制页提供 KSM 合并、Swap 倾向和 cgroup v2 `memory.reclaim` 一次性回收能力;不会展示 LXC 下无实际通用效果的内存气球回收开关。
|
5. 支持设置容器有效期。到期后容器会自动关机,子用户无法继续操作,只有管理员重新设置延期日期后才能恢复使用。
|
||||||
6. 支持 API 接入,可以通过 API 完成容器、任务、镜像、端口、流量、安全告警等功能的自动化控制。
|
6. 内置基于 conntrack 的轻量安全告警。系统不会保存完整正常连接日志,但会对端口扫描、横向扫描、爆破倾向、SMTP 滥用、UDP 反射、挖矿端口、代理/VPN/Tor 等可疑行为生成告警并写入审计日志。
|
||||||
7. 支持仅使用 CLI 管理。需要关闭 Web 控制台时,可以停止并禁用 systemd 服务,然后使用 `clicd cli --no-web` 进入命令行模式。
|
7. 支持子用户管理链接,管理员可以把指定容器分发给拼车用户,子用户只能管理自己被授权的容器。
|
||||||
8. 支持设置容器有效期。到期后容器会自动关机,子用户无法继续操作,只有管理员重新设置延期日期后才能恢复使用。
|
8. 支持 API 接入,可以通过 API 完成容器、任务、镜像、端口、流量、安全告警等功能的自动化控制。
|
||||||
9. 支持单向和双向网络流量限制。达到限制后容器会自动关机,避免流量超额。
|
9. 支持仅使用 CLI 管理。需要关闭 Web 控制台时,可以停止并禁用 systemd 服务,然后使用 `clicd cli --no-web` 进入命令行模式。
|
||||||
10. 内置基于 conntrack 的轻量安全告警。系统不会保存完整正常连接日志,但会对端口扫描、横向扫描、爆破倾向、SMTP 滥用、UDP 反射、挖矿端口、代理/VPN/Tor 等可疑行为生成告警并写入审计日志。
|
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- Backend: Go, net/http, LXC, cgroup v2, iptables, conntrack
|
- Backend: Go, net/http, LXC, KVM/libvirt, cgroup v2, iptables, conntrack
|
||||||
- Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js
|
- Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js
|
||||||
- Runtime: Linux, systemd, LXC
|
- Runtime: Linux, systemd, LXC, KVM/QEMU
|
||||||
- Build: GitHub Actions, Node.js 20, Go 1.22
|
- Build: GitHub Actions, Node.js 20, Go 1.22
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
@@ -61,4 +61,8 @@ curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh
|
|||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
|
||||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
|
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
|
||||||
</picture>
|
</picture>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
## 鸣谢
|
||||||
|
|
||||||
|
- [Linux.do](https://linux.do) — 一个充满灵感的科技社区
|
||||||
@@ -67,6 +67,27 @@ func claimsFromToken(tokenString string) (jwt.MapClaims, bool) {
|
|||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
claims, ok := token.Claims.(jwt.MapClaims)
|
claims, ok := token.Claims.(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// For sub-user tokens, check token_version against stored version (password rotation invalidation)
|
||||||
|
if subUser, _ := claims["sub_user"].(string); subUser != "" {
|
||||||
|
tokenVersionFloat, hasVersion := claims["token_version"].(float64)
|
||||||
|
tokenVersion := int(tokenVersionFloat)
|
||||||
|
for i := range config.AppConfig.SubUsers {
|
||||||
|
if config.AppConfig.SubUsers[i].Username == subUser {
|
||||||
|
stored := config.AppConfig.SubUsers[i].TokenVersion
|
||||||
|
// If stored version > 0, require token_version to match exactly.
|
||||||
|
// This also rejects legacy tokens that lack token_version entirely.
|
||||||
|
if stored > 0 && (!hasVersion || tokenVersion != stored) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return claims, ok
|
return claims, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,16 +31,35 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
|
|||||||
path := strings.TrimPrefix(r.URL.Path, "/api/containers/")
|
path := strings.TrimPrefix(r.URL.Path, "/api/containers/")
|
||||||
parts := strings.SplitN(path, "/", 2)
|
parts := strings.SplitN(path, "/", 2)
|
||||||
c := containerByIdentifier(parts[0])
|
c := containerByIdentifier(parts[0])
|
||||||
if c == nil {
|
id := 0
|
||||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
if c != nil {
|
||||||
return
|
id = c.ID
|
||||||
}
|
}
|
||||||
id := c.ID
|
|
||||||
action := ""
|
action := ""
|
||||||
if len(parts) > 1 {
|
if len(parts) > 1 {
|
||||||
action = parts[1]
|
action = parts[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Snapshot delete/restore operations: allow even if the container was deleted
|
||||||
|
isSnapshotDelete := strings.HasPrefix(action, "snapshots/") && r.Method == http.MethodDelete
|
||||||
|
isSnapshotRestore := strings.HasPrefix(action, "snapshots/") && strings.HasSuffix(action, "/restore") && r.Method == http.MethodPost
|
||||||
|
isSnapshotAction := isSnapshotDelete || isSnapshotRestore
|
||||||
|
if !isSnapshotAction && c == nil {
|
||||||
|
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isSnapshotAction && id == 0 {
|
||||||
|
// For orphaned snapshots, resolve containerID from the snapshot itself
|
||||||
|
snapshotID := strings.TrimPrefix(action, "snapshots/")
|
||||||
|
snapshotID = strings.TrimSuffix(snapshotID, "/restore")
|
||||||
|
snapshot := config.FindSnapshot(snapshotID)
|
||||||
|
if snapshot == nil {
|
||||||
|
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Snapshot not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id = snapshot.ContainerID
|
||||||
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case action == "start" && r.Method == http.MethodPost:
|
case action == "start" && r.Method == http.MethodPost:
|
||||||
HandleSingleTaskAction(w, r, id, "start")
|
HandleSingleTaskAction(w, r, id, "start")
|
||||||
@@ -86,10 +105,7 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func listContainers(w http.ResponseWriter, r *http.Request) {
|
func listContainers(w http.ResponseWriter, r *http.Request) {
|
||||||
containers, err := lxcManager.ListContainers()
|
containers, _ := listByRuntime()
|
||||||
if err != nil {
|
|
||||||
containers = config.AppConfig.Containers
|
|
||||||
}
|
|
||||||
containers = filterContainersForRequest(r, containers)
|
containers = filterContainersForRequest(r, containers)
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: containers})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: containers})
|
||||||
}
|
}
|
||||||
@@ -104,11 +120,12 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Container name is required"})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Container name is required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
cfg.Virtualization = runtimeFromRequest(cfg.Virtualization)
|
||||||
if cfg.TemplateID == "" {
|
if cfg.TemplateID == "" {
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Template is required"})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Template is required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !isTemplateEnabledAndDownloaded(cfg.TemplateID) {
|
if !isImageEnabledAndDownloaded(cfg.TemplateID, cfg.Virtualization) {
|
||||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -131,7 +148,7 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
|
|||||||
if cfg.SnapshotLimit <= 0 {
|
if cfg.SnapshotLimit <= 0 {
|
||||||
cfg.SnapshotLimit = config.DefaultSnapshotLimit
|
cfg.SnapshotLimit = config.DefaultSnapshotLimit
|
||||||
}
|
}
|
||||||
if err := validateContainerResourceRequest(cfg.VCPU, cfg.RAMMB, cfg.DiskGB); err != nil {
|
if err := validateRuntimeResourceRequest(cfg.Virtualization, cfg.VCPU, cfg.RAMMB, cfg.DiskGB); err != nil {
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -147,7 +164,7 @@ func createContainer(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := lxcManager.CreateContainer(cfg); err != nil {
|
if err := createByRuntime(cfg); err != nil {
|
||||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -160,11 +177,15 @@ func getContainer(w http.ResponseWriter, r *http.Request, id int) {
|
|||||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if c.IsKVM() && c.Status == "running" {
|
||||||
|
_, _ = kvmManager.RefreshVNCPort(c.ID)
|
||||||
|
_, _ = kvmManager.RefreshNetwork(c.ID)
|
||||||
|
}
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: c})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: c})
|
||||||
}
|
}
|
||||||
|
|
||||||
func getUsage(w http.ResponseWriter, r *http.Request, id int) {
|
func getUsage(w http.ResponseWriter, r *http.Request, id int) {
|
||||||
usage, err := lxcManager.GetResourceUsage(id)
|
usage, err := usageByRuntime(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||||
return
|
return
|
||||||
@@ -173,7 +194,7 @@ func getUsage(w http.ResponseWriter, r *http.Request, id int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getTraffic(w http.ResponseWriter, r *http.Request, id int) {
|
func getTraffic(w http.ResponseWriter, r *http.Request, id int) {
|
||||||
info := lxcManager.GetTrafficInfo(id)
|
info := trafficByRuntime(id)
|
||||||
if info == nil {
|
if info == nil {
|
||||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||||
return
|
return
|
||||||
@@ -262,7 +283,7 @@ func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
|
|||||||
if req.RAMMB > 0 {
|
if req.RAMMB > 0 {
|
||||||
nextRAMMB = req.RAMMB
|
nextRAMMB = req.RAMMB
|
||||||
}
|
}
|
||||||
if err := validateContainerResourceRequest(nextVCPU, nextRAMMB, c.DiskGB); err != nil {
|
if err := validateRuntimeResourceRequest(c.Runtime(), nextVCPU, nextRAMMB, c.DiskGB); err != nil {
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -275,13 +296,17 @@ func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
|
|||||||
|
|
||||||
// Re-apply resource limits to running container
|
// Re-apply resource limits to running container
|
||||||
if c.Status == "running" {
|
if c.Status == "running" {
|
||||||
if err := lxcManager.ApplyContainerLimits(c); err != nil {
|
if err := applyLimitsByRuntime(c); err != nil {
|
||||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
func getRandomPort(w http.ResponseWriter, r *http.Request, id int) {
|
||||||
@@ -335,10 +360,7 @@ func HandleDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
containers, err := lxcManager.ListContainers()
|
containers, _ := listByRuntime()
|
||||||
if err != nil {
|
|
||||||
containers = config.AppConfig.Containers
|
|
||||||
}
|
|
||||||
running := 0
|
running := 0
|
||||||
stopped := 0
|
stopped := 0
|
||||||
for _, c := range containers {
|
for _, c := range containers {
|
||||||
@@ -372,7 +394,7 @@ func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) {
|
|||||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "容器已到期,不允许此操作"})
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "容器已到期,不允许此操作"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
newPassword, err := lxcManager.ResetSSHPassword(id)
|
newPassword, err := resetPasswordByRuntime(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"clicd/internal/config"
|
"clicd/internal/config"
|
||||||
|
"clicd/internal/kvm"
|
||||||
"clicd/internal/lxc"
|
"clicd/internal/lxc"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ import (
|
|||||||
type ImageInfo struct {
|
type ImageInfo struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
Distro string `json:"distro"`
|
Distro string `json:"distro"`
|
||||||
Release string `json:"release"`
|
Release string `json:"release"`
|
||||||
Arch string `json:"arch"`
|
Arch string `json:"arch"`
|
||||||
@@ -25,6 +27,7 @@ type ImageInfo struct {
|
|||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Downloading bool `json:"downloading"`
|
Downloading bool `json:"downloading"`
|
||||||
SizeBytes int64 `json:"size_bytes"`
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
ManualPath string `json:"manual_path,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var imageDownloadsMu sync.Mutex
|
var imageDownloadsMu sync.Mutex
|
||||||
@@ -78,6 +81,9 @@ func getEnabledImageSet() map[string]bool {
|
|||||||
for _, t := range lxc.GetTemplates() {
|
for _, t := range lxc.GetTemplates() {
|
||||||
set[t.ID] = true
|
set[t.ID] = true
|
||||||
}
|
}
|
||||||
|
for _, t := range kvm.GetImages() {
|
||||||
|
set[t.ID] = true
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
for _, id := range config.AppConfig.EnabledImages {
|
for _, id := range config.AppConfig.EnabledImages {
|
||||||
set[id] = true
|
set[id] = true
|
||||||
@@ -93,16 +99,17 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
templates := lxc.GetTemplates()
|
|
||||||
enabledSet := getEnabledImageSet()
|
enabledSet := getEnabledImageSet()
|
||||||
|
|
||||||
images := make([]ImageInfo, 0, len(templates))
|
templates := lxc.GetTemplates()
|
||||||
|
images := make([]ImageInfo, 0, len(templates)+len(kvm.GetImages()))
|
||||||
for _, t := range templates {
|
for _, t := range templates {
|
||||||
_, downloading := imageDownloads[t.ID]
|
_, downloading := imageDownloads[t.ID]
|
||||||
downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch)
|
downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch)
|
||||||
images = append(images, ImageInfo{
|
images = append(images, ImageInfo{
|
||||||
ID: t.ID,
|
ID: t.ID,
|
||||||
Name: t.Name,
|
Name: t.Name,
|
||||||
|
Type: config.VirtualizationLXC,
|
||||||
Distro: t.Distro,
|
Distro: t.Distro,
|
||||||
Release: t.Release,
|
Release: t.Release,
|
||||||
Arch: t.Arch,
|
Arch: t.Arch,
|
||||||
@@ -113,6 +120,28 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
SizeBytes: size,
|
SizeBytes: size,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
for _, t := range kvm.GetImages() {
|
||||||
|
_, downloading := imageDownloads[t.ID]
|
||||||
|
downloaded, size := kvm.ImageDownloadedInfo(t.ID)
|
||||||
|
manualPath := ""
|
||||||
|
if t.Distro == "windows" {
|
||||||
|
manualPath = kvm.ImagePath(t.ID)
|
||||||
|
}
|
||||||
|
images = append(images, ImageInfo{
|
||||||
|
ID: t.ID,
|
||||||
|
Name: t.Name,
|
||||||
|
Type: config.VirtualizationKVM,
|
||||||
|
Distro: t.Distro,
|
||||||
|
Release: t.Release,
|
||||||
|
Arch: t.Arch,
|
||||||
|
Description: t.Description,
|
||||||
|
Downloaded: downloaded,
|
||||||
|
Enabled: enabledSet[t.ID],
|
||||||
|
Downloading: downloading,
|
||||||
|
SizeBytes: size,
|
||||||
|
ManualPath: manualPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: images})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: images})
|
||||||
}
|
}
|
||||||
@@ -134,7 +163,37 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
tmpl := lxc.FindTemplate(req.TemplateID)
|
tmpl := lxc.FindTemplate(req.TemplateID)
|
||||||
if tmpl == nil {
|
if tmpl == nil {
|
||||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
|
image := kvm.FindImage(req.TemplateID)
|
||||||
|
if image == nil {
|
||||||
|
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ok, _ := kvm.ImageDownloadedInfo(image.ID); ok {
|
||||||
|
ensureImageEnabled(image.ID)
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
imageDownloadsMu.Lock()
|
||||||
|
if imageDownloads[req.TemplateID] {
|
||||||
|
imageDownloadsMu.Unlock()
|
||||||
|
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Already downloading"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
imageDownloads[req.TemplateID] = true
|
||||||
|
imageDownloadsMu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
imageDownloadsMu.Lock()
|
||||||
|
delete(imageDownloads, req.TemplateID)
|
||||||
|
imageDownloadsMu.Unlock()
|
||||||
|
}()
|
||||||
|
ensureImageEnabled(image.ID)
|
||||||
|
if err := kvm.DownloadImage(*image); err != nil {
|
||||||
|
message := "Download failed: " + err.Error()
|
||||||
|
|
||||||
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: message})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Downloaded successfully"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +265,15 @@ func HandleImageDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
tmpl := lxc.FindTemplate(req.TemplateID)
|
tmpl := lxc.FindTemplate(req.TemplateID)
|
||||||
if tmpl == nil {
|
if tmpl == nil {
|
||||||
|
if image := kvm.FindImage(req.TemplateID); image != nil {
|
||||||
|
if err := kvm.DeleteImage(image.ID); err != nil {
|
||||||
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to delete image cache: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
removeImageEnabled(image.ID)
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Deleted"})
|
||||||
|
return
|
||||||
|
}
|
||||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
|
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -259,13 +327,27 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
templates := lxc.GetTemplates()
|
runtime := runtimeFromRequest(r.URL.Query().Get("type"))
|
||||||
enabledSet := getEnabledImageSet()
|
enabledSet := getEnabledImageSet()
|
||||||
|
|
||||||
result := make([]lxc.Template, 0)
|
result := make([]map[string]string, 0)
|
||||||
for _, t := range templates {
|
if runtime == config.VirtualizationKVM {
|
||||||
if enabledSet[t.ID] && isImageDownloaded(t.Distro, t.Release, t.Arch) {
|
for _, t := range kvm.GetImages() {
|
||||||
result = append(result, t)
|
if downloaded, _ := kvm.ImageDownloadedInfo(t.ID); enabledSet[t.ID] && downloaded {
|
||||||
|
result = append(result, map[string]string{
|
||||||
|
"id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
|
||||||
|
"description": t.Description, "type": config.VirtualizationKVM,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _, t := range lxc.GetTemplates() {
|
||||||
|
if enabledSet[t.ID] && isImageDownloaded(t.Distro, t.Release, t.Arch) {
|
||||||
|
result = append(result, map[string]string{
|
||||||
|
"id": t.ID, "name": t.Name, "distro": t.Distro, "release": t.Release, "arch": t.Arch,
|
||||||
|
"variant": t.Variant, "description": t.Description, "type": config.VirtualizationLXC,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,6 +355,20 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isTemplateEnabledAndDownloaded(templateID string) bool {
|
func isTemplateEnabledAndDownloaded(templateID string) bool {
|
||||||
|
return isImageEnabledAndDownloaded(templateID, runtimeFromTemplateID(templateID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func isImageEnabledAndDownloaded(templateID string, runtime string) bool {
|
||||||
|
runtime = runtimeFromRequest(runtime)
|
||||||
|
if runtime == config.VirtualizationKVM {
|
||||||
|
image := kvm.FindImage(templateID)
|
||||||
|
if image == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
enabledSet := getEnabledImageSet()
|
||||||
|
downloaded, _ := kvm.ImageDownloadedInfo(image.ID)
|
||||||
|
return enabledSet[image.ID] && downloaded
|
||||||
|
}
|
||||||
tmpl := lxc.FindTemplate(templateID)
|
tmpl := lxc.FindTemplate(templateID)
|
||||||
if tmpl == nil {
|
if tmpl == nil {
|
||||||
return false
|
return false
|
||||||
@@ -288,6 +384,9 @@ func ensureImageEnabled(id string) {
|
|||||||
for _, t := range lxc.GetTemplates() {
|
for _, t := range lxc.GetTemplates() {
|
||||||
config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID)
|
config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID)
|
||||||
}
|
}
|
||||||
|
for _, t := range kvm.GetImages() {
|
||||||
|
config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID)
|
||||||
|
}
|
||||||
config.SaveConfig()
|
config.SaveConfig()
|
||||||
return // Already contains all IDs including this one
|
return // Already contains all IDs including this one
|
||||||
}
|
}
|
||||||
@@ -313,6 +412,11 @@ func removeImageEnabled(id string) {
|
|||||||
config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID)
|
config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, t := range kvm.GetImages() {
|
||||||
|
if t.ID != id {
|
||||||
|
config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
config.SaveConfig()
|
config.SaveConfig()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ func HandleIPv6Status(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func assignIPv6(w http.ResponseWriter, r *http.Request, id int) {
|
func assignIPv6(w http.ResponseWriter, r *http.Request, id int) {
|
||||||
c, err := lxcManager.AssignIPv6(id)
|
c, err := assignIPv6ByRuntime(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"clicd/internal/config"
|
||||||
|
"clicd/internal/kvm"
|
||||||
|
"clicd/internal/lxc"
|
||||||
|
)
|
||||||
|
|
||||||
|
var kvmManager = kvm.NewManager()
|
||||||
|
|
||||||
|
func runtimeFromRequest(value string) string {
|
||||||
|
return config.NormalizeVirtualization(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runtimeFromTemplateID(templateID string) string {
|
||||||
|
if kvm.FindImage(templateID) != nil {
|
||||||
|
return config.VirtualizationKVM
|
||||||
|
}
|
||||||
|
return config.VirtualizationLXC
|
||||||
|
}
|
||||||
|
|
||||||
|
func createByRuntime(cfg lxc.ContainerConfig) error {
|
||||||
|
cfg.Virtualization = runtimeFromRequest(cfg.Virtualization)
|
||||||
|
if cfg.Virtualization == config.VirtualizationKVM {
|
||||||
|
return kvmManager.CreateContainer(cfg)
|
||||||
|
}
|
||||||
|
return lxcManager.CreateContainer(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func startByRuntime(id int) error {
|
||||||
|
c := config.FindContainer(id)
|
||||||
|
if c != nil && c.IsKVM() {
|
||||||
|
return kvmManager.StartContainer(id)
|
||||||
|
}
|
||||||
|
return lxcManager.StartContainer(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopByRuntime(id int) error {
|
||||||
|
c := config.FindContainer(id)
|
||||||
|
if c != nil && c.IsKVM() {
|
||||||
|
return kvmManager.StopContainer(id)
|
||||||
|
}
|
||||||
|
return lxcManager.StopContainer(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func restartByRuntime(id int) error {
|
||||||
|
c := config.FindContainer(id)
|
||||||
|
if c != nil && c.IsKVM() {
|
||||||
|
return kvmManager.RestartContainer(id)
|
||||||
|
}
|
||||||
|
return lxcManager.RestartContainer(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func destroyByRuntime(id int) error {
|
||||||
|
c := config.FindContainer(id)
|
||||||
|
if c != nil && c.IsKVM() {
|
||||||
|
return kvmManager.DestroyContainer(id)
|
||||||
|
}
|
||||||
|
return lxcManager.DestroyContainer(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func reinstallByRuntime(id int, templateID string) error {
|
||||||
|
c := config.FindContainer(id)
|
||||||
|
if c != nil && c.IsKVM() {
|
||||||
|
return kvmManager.ReinstallContainer(id, templateID)
|
||||||
|
}
|
||||||
|
return lxcManager.ReinstallContainer(id, templateID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetPasswordByRuntime(id int) (string, error) {
|
||||||
|
c := config.FindContainer(id)
|
||||||
|
if c != nil && c.IsKVM() {
|
||||||
|
return kvmManager.ResetSSHPassword(id)
|
||||||
|
}
|
||||||
|
return lxcManager.ResetSSHPassword(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assignIPv6ByRuntime(id int) (*config.Container, error) {
|
||||||
|
c := config.FindContainer(id)
|
||||||
|
if c != nil && c.IsKVM() {
|
||||||
|
return kvmManager.AssignIPv6(id)
|
||||||
|
}
|
||||||
|
return lxcManager.AssignIPv6(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func usageByRuntime(id int) (map[string]interface{}, error) {
|
||||||
|
c := config.FindContainer(id)
|
||||||
|
if c != nil && c.IsKVM() {
|
||||||
|
return kvmManager.GetResourceUsage(id)
|
||||||
|
}
|
||||||
|
return lxcManager.GetResourceUsage(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func trafficByRuntime(id int) map[string]interface{} {
|
||||||
|
c := config.FindContainer(id)
|
||||||
|
if c != nil && c.IsKVM() {
|
||||||
|
return kvmManager.GetTrafficInfo(id)
|
||||||
|
}
|
||||||
|
return lxcManager.GetTrafficInfo(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSnapshotByRuntime(id int, createdBy string, scheduled bool, rotateLimit int) (config.Snapshot, error) {
|
||||||
|
c := config.FindContainer(id)
|
||||||
|
if c != nil && c.IsKVM() {
|
||||||
|
return kvmManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit)
|
||||||
|
}
|
||||||
|
return lxcManager.CreateSnapshot(id, createdBy, scheduled, rotateLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteSnapshotByRuntime(snapshotID string) error {
|
||||||
|
snapshot := config.FindSnapshot(snapshotID)
|
||||||
|
if snapshot != nil {
|
||||||
|
if c := config.FindContainer(snapshot.ContainerID); c != nil && c.IsKVM() {
|
||||||
|
return kvmManager.DeleteSnapshot(snapshotID)
|
||||||
|
}
|
||||||
|
if strings.Contains(snapshot.Path, string(os.PathSeparator)+"kvm"+string(os.PathSeparator)) {
|
||||||
|
return kvmManager.DeleteSnapshot(snapshotID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lxcManager.DeleteSnapshot(snapshotID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreSnapshotByRuntime(snapshotID string) error {
|
||||||
|
snapshot := config.FindSnapshot(snapshotID)
|
||||||
|
if snapshot != nil {
|
||||||
|
if c := config.FindContainer(snapshot.ContainerID); c != nil && c.IsKVM() {
|
||||||
|
return kvmManager.RestoreSnapshot(snapshotID)
|
||||||
|
}
|
||||||
|
if strings.Contains(snapshot.Path, string(os.PathSeparator)+"kvm"+string(os.PathSeparator)) {
|
||||||
|
return kvmManager.RestoreSnapshot(snapshotID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lxcManager.RestoreSnapshot(snapshotID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSnapshotScheduleByRuntime(id int, enabled bool, intervalHours int, scheduleTime string, createdBy string) (*config.Container, error) {
|
||||||
|
c := config.FindContainer(id)
|
||||||
|
if c != nil && c.IsKVM() {
|
||||||
|
return kvmManager.SetSnapshotSchedule(id, enabled, intervalHours, scheduleTime, createdBy)
|
||||||
|
}
|
||||||
|
return lxcManager.SetSnapshotSchedule(id, enabled, intervalHours, scheduleTime, createdBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyLimitsByRuntime(c *config.Container) error {
|
||||||
|
if c != nil && c.IsKVM() {
|
||||||
|
return kvmManager.ApplyContainerLimits(c)
|
||||||
|
}
|
||||||
|
return lxcManager.ApplyContainerLimits(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listByRuntime() ([]config.Container, error) {
|
||||||
|
containers, err := lxcManager.ListContainers()
|
||||||
|
if err != nil {
|
||||||
|
containers = config.AppConfig.Containers
|
||||||
|
}
|
||||||
|
containers = kvmManager.ListContainers(containers)
|
||||||
|
return containers, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateRuntimeResourceRequest(runtime string, vcpu float64, ramMB int, diskGB int) error {
|
||||||
|
if runtime == config.VirtualizationKVM {
|
||||||
|
if vcpu < 1 || math.Abs(vcpu-math.Round(vcpu)) > 0.000001 {
|
||||||
|
return fmt.Errorf("KVM vCPU must be a whole number and at least 1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return validateContainerResourceRequest(vcpu, ramMB, diskGB)
|
||||||
|
}
|
||||||
@@ -74,7 +74,7 @@ func createContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
snapshot, err := lxcManager.CreateSnapshot(containerID, user, false, 0)
|
snapshot, err := createSnapshotByRuntime(containerID, user, false, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||||
return
|
return
|
||||||
@@ -138,7 +138,7 @@ func updateSnapshotSchedule(w http.ResponseWriter, r *http.Request, containerID
|
|||||||
req.Time = "03:00"
|
req.Time = "03:00"
|
||||||
}
|
}
|
||||||
user := requestUser(r)
|
user := requestUser(r)
|
||||||
c, err := lxcManager.SetSnapshotSchedule(containerID, req.Enabled, req.IntervalHours, req.Time, user)
|
c, err := setSnapshotScheduleByRuntime(containerID, req.Enabled, req.IntervalHours, req.Time, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||||
return
|
return
|
||||||
@@ -162,7 +162,7 @@ func deleteContainerSnapshot(w http.ResponseWriter, r *http.Request, containerID
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
user := requestUser(r)
|
user := requestUser(r)
|
||||||
if err := lxcManager.DeleteSnapshot(snapshotID); err != nil {
|
if err := deleteSnapshotByRuntime(snapshotID); err != nil {
|
||||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -177,7 +177,7 @@ func restoreContainerSnapshot(w http.ResponseWriter, r *http.Request, containerI
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
user := requestUser(r)
|
user := requestUser(r)
|
||||||
if err := lxcManager.RestoreSnapshot(snapshotID); err != nil {
|
if err := restoreSnapshotByRuntime(snapshotID); err != nil {
|
||||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-21
@@ -101,16 +101,30 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if c.IP == "" {
|
if c.IP == "" {
|
||||||
if ip, err := lxcManager.GetContainerIP(c.LxcName()); err == nil {
|
var ip string
|
||||||
|
var err error
|
||||||
|
if c.IsKVM() {
|
||||||
|
ip, err = kvmManager.GetContainerIP(c.VirshName())
|
||||||
|
} else {
|
||||||
|
ip, err = lxcManager.GetContainerIP(c.LxcName())
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
c.IP = ip
|
c.IP = ip
|
||||||
config.SaveConfig()
|
config.SaveConfig()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if c.IP == "" {
|
if c.IP == "" && !c.IsKVM() {
|
||||||
if ip, err := lxcManager.EnsureContainerIPv4(c.ID); err == nil && ip != "" {
|
if ip, err := lxcManager.EnsureContainerIPv4(c.ID); err == nil && ip != "" {
|
||||||
c.IP = ip
|
c.IP = ip
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if c.IP == "" && c.IsKVM() {
|
||||||
|
if err := kvmManager.EnsureSSH(c.ID); err == nil {
|
||||||
|
if refreshed := config.FindContainer(c.ID); refreshed != nil {
|
||||||
|
c = refreshed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if c.IP == "" {
|
if c.IP == "" {
|
||||||
http.Error(w, "container ip is not available", http.StatusBadRequest)
|
http.Error(w, "container ip is not available", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -127,6 +141,10 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
|
|||||||
defer ws.Close()
|
defer ws.Close()
|
||||||
|
|
||||||
if c.SSHPassword == "" {
|
if c.SSHPassword == "" {
|
||||||
|
if c.IsKVM() {
|
||||||
|
writeWebSocketText(ws, nil, "\r\nKVM SSH password is not available. Reinstall or reset after SSH is ready.\r\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeWebSocketText(ws, nil, "\r\nPreparing SSH service. This can take up to 90 seconds on first boot...\r\n")
|
writeWebSocketText(ws, nil, "\r\nPreparing SSH service. This can take up to 90 seconds on first boot...\r\n")
|
||||||
if err := lxcManager.EnsureSSH(c.ID); err != nil {
|
if err := lxcManager.EnsureSSH(c.ID); err != nil {
|
||||||
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", err))
|
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", err))
|
||||||
@@ -154,25 +172,48 @@ func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeWebSocketText(ws, nil, fmt.Sprintf("Connecting to %s...\r\n", addr))
|
writeWebSocketText(ws, nil, fmt.Sprintf("Connecting to %s...\r\n", addr))
|
||||||
client, err := ssh.Dial("tcp", addr, sshConfig)
|
client, err := ssh.Dial("tcp", addr, sshConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeWebSocketText(ws, nil, "\r\nSSH is not ready yet, preparing service. This can take up to 90 seconds on first boot...\r\n")
|
if c.IsKVM() {
|
||||||
if setupErr := lxcManager.EnsureSSH(c.ID); setupErr != nil {
|
writeWebSocketText(ws, nil, "\r\nSSH is not ready yet, preparing KVM guest service. This can take a few minutes on first boot...\r\n")
|
||||||
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", setupErr))
|
if setupErr := kvmManager.EnsureSSH(c.ID); setupErr != nil {
|
||||||
return
|
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nKVM SSH auto setup failed: %v\r\n", setupErr))
|
||||||
}
|
return
|
||||||
if refreshed := config.FindContainer(c.ID); refreshed != nil {
|
}
|
||||||
c = refreshed
|
if refreshed := config.FindContainer(c.ID); refreshed != nil {
|
||||||
}
|
c = refreshed
|
||||||
if ip, ipErr := lxcManager.GetContainerIP(c.LxcName()); ipErr == nil && ip != "" {
|
}
|
||||||
c.IP = ip
|
if ip, ipErr := kvmManager.GetContainerIP(c.VirshName()); ipErr == nil && ip != "" {
|
||||||
config.SaveConfig()
|
c.IP = ip
|
||||||
addr = net.JoinHostPort(c.IP, "22")
|
config.SaveConfig()
|
||||||
}
|
addr = net.JoinHostPort(c.IP, "22")
|
||||||
sshConfig.Auth = []ssh.AuthMethod{ssh.Password(c.SSHPassword)}
|
}
|
||||||
sshConfig.Timeout = 10 * time.Second
|
sshConfig.Auth = []ssh.AuthMethod{ssh.Password(c.SSHPassword)}
|
||||||
client, err = ssh.Dial("tcp", addr, sshConfig)
|
sshConfig.Timeout = 10 * time.Second
|
||||||
if err != nil {
|
client, err = ssh.Dial("tcp", addr, sshConfig)
|
||||||
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nWebSSH connection failed: %v\r\n", err))
|
if err != nil {
|
||||||
return
|
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nWebSSH connection failed: %v\r\n", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
writeWebSocketText(ws, nil, "\r\nSSH is not ready yet, preparing service. This can take up to 90 seconds on first boot...\r\n")
|
||||||
|
if setupErr := lxcManager.EnsureSSH(c.ID); setupErr != nil {
|
||||||
|
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", setupErr))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if refreshed := config.FindContainer(c.ID); refreshed != nil {
|
||||||
|
c = refreshed
|
||||||
|
}
|
||||||
|
if ip, ipErr := lxcManager.GetContainerIP(c.LxcName()); ipErr == nil && ip != "" {
|
||||||
|
c.IP = ip
|
||||||
|
config.SaveConfig()
|
||||||
|
addr = net.JoinHostPort(c.IP, "22")
|
||||||
|
}
|
||||||
|
sshConfig.Auth = []ssh.AuthMethod{ssh.Password(c.SSHPassword)}
|
||||||
|
sshConfig.Timeout = 10 * time.Second
|
||||||
|
client, err = ssh.Dial("tcp", addr, sshConfig)
|
||||||
|
if err != nil {
|
||||||
|
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nWebSSH connection failed: %v\r\n", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
|
|||||||
+192
-10
@@ -66,7 +66,7 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
containerName := c.Name
|
containerName := c.Name
|
||||||
|
|
||||||
// Check if sub-user already exists for this container
|
// Check if sub-user already exists and return the same management password.
|
||||||
for i := range config.AppConfig.SubUsers {
|
for i := range config.AppConfig.SubUsers {
|
||||||
su := &config.AppConfig.SubUsers[i]
|
su := &config.AppConfig.SubUsers[i]
|
||||||
for _, uuid := range su.ContainerUUIDs {
|
for _, uuid := range su.ContainerUUIDs {
|
||||||
@@ -74,18 +74,27 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
if su.AccessCode == "" {
|
if su.AccessCode == "" {
|
||||||
su.AccessCode = generateRandomStr(8)
|
su.AccessCode = generateRandomStr(8)
|
||||||
}
|
}
|
||||||
password := generateRandomStr(16)
|
password := su.Password
|
||||||
if hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost); err == nil {
|
message := "Sub-user link returned"
|
||||||
|
if password == "" {
|
||||||
|
password = generateRandomStr(16)
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate password"})
|
||||||
|
return
|
||||||
|
}
|
||||||
su.PassHash = string(hash)
|
su.PassHash = string(hash)
|
||||||
|
su.Password = password
|
||||||
|
su.Token = ""
|
||||||
|
su.TokenVersion++
|
||||||
|
message = "Sub-user password generated"
|
||||||
}
|
}
|
||||||
su.Password = ""
|
|
||||||
su.Token = ""
|
|
||||||
su.ContainerNames = appendUniqueString(su.ContainerNames, containerName)
|
su.ContainerNames = appendUniqueString(su.ContainerNames, containerName)
|
||||||
su.ContainerUUIDs = appendUniqueString(su.ContainerUUIDs, c.UUID)
|
su.ContainerUUIDs = appendUniqueString(su.ContainerUUIDs, c.UUID)
|
||||||
config.SaveConfig()
|
config.SaveConfig()
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{
|
jsonResponse(w, http.StatusOK, APIResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "Sub-user password rotated",
|
Message: message,
|
||||||
Data: newSubUserResponse(*su, password),
|
Data: newSubUserResponse(*su, password),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -104,6 +113,7 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
subUser := config.SubUser{
|
subUser := config.SubUser{
|
||||||
ID: "sub-" + generateRandomStr(8),
|
ID: "sub-" + generateRandomStr(8),
|
||||||
Username: username,
|
Username: username,
|
||||||
|
Password: password,
|
||||||
PassHash: string(hash),
|
PassHash: string(hash),
|
||||||
ContainerNames: []string{containerName},
|
ContainerNames: []string{containerName},
|
||||||
ContainerUUIDs: []string{c.UUID},
|
ContainerUUIDs: []string{c.UUID},
|
||||||
@@ -134,17 +144,24 @@ func HandleSubUserLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clientIP := r.Header.Get("X-Forwarded-For")
|
||||||
|
if clientIP == "" {
|
||||||
|
clientIP = r.RemoteAddr
|
||||||
|
}
|
||||||
|
clientUA := r.Header.Get("User-Agent")
|
||||||
|
|
||||||
// Find sub-user
|
// Find sub-user
|
||||||
for _, su := range config.AppConfig.SubUsers {
|
for _, su := range config.AppConfig.SubUsers {
|
||||||
if su.Username == req.Username {
|
if su.Username == req.Username {
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(su.PassHash), []byte(req.Password)); err == nil {
|
if err := bcrypt.CompareHashAndPassword([]byte(su.PassHash), []byte(req.Password)); err == nil {
|
||||||
// Generate fresh token
|
|
||||||
containerUUIDs := activeSubUserContainerUUIDs(&su)
|
containerUUIDs := activeSubUserContainerUUIDs(&su)
|
||||||
if len(containerUUIDs) == 0 {
|
if len(containerUUIDs) == 0 {
|
||||||
|
config.AddLoginLog(su.Username, clientIP, clientUA, false)
|
||||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this user"})
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this user"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour))
|
tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour), su.TokenVersion)
|
||||||
|
config.AddLoginLog(su.Username, clientIP, clientUA, true)
|
||||||
|
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{
|
jsonResponse(w, http.StatusOK, APIResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
@@ -155,6 +172,8 @@ func HandleSubUserLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
} else {
|
||||||
|
config.AddLoginLog(su.Username, clientIP, clientUA, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,19 +198,28 @@ func HandleSubUserAccessCode(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Find sub-user by access code
|
// Find sub-user by access code
|
||||||
|
clientIP := r.Header.Get("X-Forwarded-For")
|
||||||
|
if clientIP == "" {
|
||||||
|
clientIP = r.RemoteAddr
|
||||||
|
}
|
||||||
|
clientUA := r.Header.Get("User-Agent")
|
||||||
|
|
||||||
for _, su := range config.AppConfig.SubUsers {
|
for _, su := range config.AppConfig.SubUsers {
|
||||||
if su.AccessCode == req.Code {
|
if su.AccessCode == req.Code {
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(su.PassHash), []byte(req.Password)); err != nil {
|
if err := bcrypt.CompareHashAndPassword([]byte(su.PassHash), []byte(req.Password)); err != nil {
|
||||||
|
config.AddLoginLog(su.Username, clientIP, clientUA, false)
|
||||||
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid password"})
|
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid password"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
containerUUIDs := activeSubUserContainerUUIDs(&su)
|
containerUUIDs := activeSubUserContainerUUIDs(&su)
|
||||||
if len(containerUUIDs) == 0 {
|
if len(containerUUIDs) == 0 {
|
||||||
|
config.AddLoginLog(su.Username, clientIP, clientUA, false)
|
||||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this link"})
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this link"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour))
|
tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour), su.TokenVersion)
|
||||||
|
config.AddLoginLog(su.Username, clientIP, clientUA, true)
|
||||||
|
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{
|
jsonResponse(w, http.StatusOK, APIResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
@@ -208,10 +236,11 @@ func HandleSubUserAccessCode(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid access code"})
|
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid access code"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSubUserToken(username string, containerUUIDs []string, expiresAt time.Time) string {
|
func newSubUserToken(username string, containerUUIDs []string, expiresAt time.Time, tokenVersion int) string {
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||||
"sub_user": username,
|
"sub_user": username,
|
||||||
"container_uuids": containerUUIDs,
|
"container_uuids": containerUUIDs,
|
||||||
|
"token_version": tokenVersion,
|
||||||
"exp": expiresAt.Unix(),
|
"exp": expiresAt.Unix(),
|
||||||
"iat": time.Now().Unix(),
|
"iat": time.Now().Unix(),
|
||||||
})
|
})
|
||||||
@@ -461,3 +490,156 @@ func splitBy(s, sep string) []string {
|
|||||||
result = append(result, current)
|
result = append(result, current)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SubUserListItem is the enriched sub-user info returned by the list API
|
||||||
|
type SubUserListItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
ContainerNames []string `json:"container_names"`
|
||||||
|
ContainerUUIDs []string `json:"container_uuids"`
|
||||||
|
ContainerName string `json:"container_name"`
|
||||||
|
ContainerUUID string `json:"container_uuid"`
|
||||||
|
AccessCode string `json:"access_code"`
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
LastLogin string `json:"last_login"`
|
||||||
|
LastLoginIP string `json:"last_login_ip"`
|
||||||
|
LastLoginUA string `json:"last_login_ua"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleSubUserList returns the list of all sub-users with container info
|
||||||
|
func HandleSubUserList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]SubUserListItem, 0, len(config.AppConfig.SubUsers))
|
||||||
|
for _, su := range config.AppConfig.SubUsers {
|
||||||
|
item := SubUserListItem{
|
||||||
|
ID: su.ID,
|
||||||
|
Username: su.Username,
|
||||||
|
ContainerNames: su.ContainerNames,
|
||||||
|
ContainerUUIDs: su.ContainerUUIDs,
|
||||||
|
AccessCode: su.AccessCode,
|
||||||
|
Password: su.Password,
|
||||||
|
CreatedAt: su.CreatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve container name from first active UUID
|
||||||
|
for _, uuid := range su.ContainerUUIDs {
|
||||||
|
if c := config.FindContainerByUUID(uuid); c != nil {
|
||||||
|
item.ContainerName = c.Name
|
||||||
|
item.ContainerUUID = c.UUID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if item.ContainerName == "" && len(su.ContainerNames) > 0 {
|
||||||
|
item.ContainerName = su.ContainerNames[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find last login time
|
||||||
|
for i := len(config.AppConfig.LoginLogs) - 1; i >= 0; i-- {
|
||||||
|
log := config.AppConfig.LoginLogs[i]
|
||||||
|
if log.Username == su.Username {
|
||||||
|
item.LastLogin = log.Time
|
||||||
|
item.LastLoginIP = log.IP
|
||||||
|
item.LastLoginUA = log.UserAgent
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip orphaned sub-users with no active containers
|
||||||
|
if item.ContainerName == "" && item.ContainerUUID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: result})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleSubUserAction handles actions on a specific sub-user
|
||||||
|
func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/api/sub-users/")
|
||||||
|
parts := strings.SplitN(path, "/", 2)
|
||||||
|
subUserID := parts[0]
|
||||||
|
action := ""
|
||||||
|
if len(parts) > 1 {
|
||||||
|
action = parts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find sub-user
|
||||||
|
var target *config.SubUser
|
||||||
|
for i := range config.AppConfig.SubUsers {
|
||||||
|
if config.AppConfig.SubUsers[i].ID == subUserID {
|
||||||
|
target = &config.AppConfig.SubUsers[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if target == nil {
|
||||||
|
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Sub-user not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case action == "rotate-password" && r.Method == http.MethodPost:
|
||||||
|
password := generateRandomStr(16)
|
||||||
|
if hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost); err == nil {
|
||||||
|
target.PassHash = string(hash)
|
||||||
|
target.Password = password
|
||||||
|
target.Token = ""
|
||||||
|
target.TokenVersion++ // invalidate all existing tokens
|
||||||
|
config.SaveConfig()
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{
|
||||||
|
"password": password,
|
||||||
|
"access_code": target.AccessCode,
|
||||||
|
"username": target.Username,
|
||||||
|
}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate password"})
|
||||||
|
|
||||||
|
case action == "audit-logs" && r.Method == http.MethodGet:
|
||||||
|
// Filter audit logs for this sub-user
|
||||||
|
logs := filterSubUserAuditLogs(target.Username)
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
|
||||||
|
|
||||||
|
case action == "login-logs" && r.Method == http.MethodGet:
|
||||||
|
// Filter login logs for this sub-user
|
||||||
|
logs := filterSubUserLoginLogs(target.Username)
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
|
||||||
|
|
||||||
|
default:
|
||||||
|
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterSubUserAuditLogs(username string) []config.AuditLog {
|
||||||
|
result := make([]config.AuditLog, 0)
|
||||||
|
for i := len(config.AppConfig.AuditLogs) - 1; i >= 0; i-- {
|
||||||
|
log := config.AppConfig.AuditLogs[i]
|
||||||
|
if log.User == username || strings.HasPrefix(log.User, "user:") && strings.Contains(log.User, username) {
|
||||||
|
result = append(result, log)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
result = []config.AuditLog{}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterSubUserLoginLogs(username string) []config.SavedLoginLog {
|
||||||
|
result := make([]config.SavedLoginLog, 0)
|
||||||
|
for i := len(config.AppConfig.LoginLogs) - 1; i >= 0; i-- {
|
||||||
|
log := config.AppConfig.LoginLogs[i]
|
||||||
|
if log.Username == username {
|
||||||
|
result = append(result, log)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
result = []config.SavedLoginLog{}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ type Task struct {
|
|||||||
Config lxc.ContainerConfig `json:"config,omitempty"`
|
Config lxc.ContainerConfig `json:"config,omitempty"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
User string `json:"user,omitempty"` // who created this task
|
User string `json:"user,omitempty"` // who created this task
|
||||||
|
IP string `json:"ip,omitempty"`
|
||||||
|
UserAgent string `json:"user_agent,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TaskQueue struct {
|
type TaskQueue struct {
|
||||||
@@ -100,6 +102,10 @@ func (q *TaskQueue) EnqueueBatch(taskType TaskType, ids []int, templateID string
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (q *TaskQueue) EnqueueBatchWithUser(taskType TaskType, ids []int, templateID string, user string) []string {
|
func (q *TaskQueue) EnqueueBatchWithUser(taskType TaskType, ids []int, templateID string, user string) []string {
|
||||||
|
return q.EnqueueBatchWithAudit(taskType, ids, templateID, user, "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *TaskQueue) EnqueueBatchWithAudit(taskType TaskType, ids []int, templateID string, user string, ip string, userAgent string) []string {
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
defer q.mu.Unlock()
|
defer q.mu.Unlock()
|
||||||
var result []string
|
var result []string
|
||||||
@@ -109,7 +115,7 @@ func (q *TaskQueue) EnqueueBatchWithUser(taskType TaskType, ids []int, templateI
|
|||||||
if c != nil {
|
if c != nil {
|
||||||
name = c.Name
|
name = c.Name
|
||||||
}
|
}
|
||||||
result = append(result, q.enqueueSingleWithUser(id, name, taskType, templateID, user))
|
result = append(result, q.enqueueSingleWithAudit(id, name, taskType, templateID, user, ip, userAgent))
|
||||||
}
|
}
|
||||||
q.persistTasks()
|
q.persistTasks()
|
||||||
return result
|
return result
|
||||||
@@ -168,6 +174,10 @@ func (q *TaskQueue) enqueueSingle(containerID int, containerName string, taskTyp
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (q *TaskQueue) enqueueSingleWithUser(containerID int, containerName string, taskType TaskType, templateID string, user string) string {
|
func (q *TaskQueue) enqueueSingleWithUser(containerID int, containerName string, taskType TaskType, templateID string, user string) string {
|
||||||
|
return q.enqueueSingleWithAudit(containerID, containerName, taskType, templateID, user, "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *TaskQueue) enqueueSingleWithAudit(containerID int, containerName string, taskType TaskType, templateID string, user string, ip string, userAgent string) string {
|
||||||
id := q.nextID
|
id := q.nextID
|
||||||
q.nextID++
|
q.nextID++
|
||||||
task := &Task{
|
task := &Task{
|
||||||
@@ -179,6 +189,8 @@ func (q *TaskQueue) enqueueSingleWithUser(containerID int, containerName string,
|
|||||||
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||||
TemplateID: templateID,
|
TemplateID: templateID,
|
||||||
User: user,
|
User: user,
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: userAgent,
|
||||||
}
|
}
|
||||||
q.enqueueTask(task)
|
q.enqueueTask(task)
|
||||||
return task.ID
|
return task.ID
|
||||||
@@ -214,7 +226,7 @@ func (q *TaskQueue) createWorker() {
|
|||||||
c := config.FindContainerByName(task.Config.Name)
|
c := config.FindContainerByName(task.Config.Name)
|
||||||
if c == nil {
|
if c == nil {
|
||||||
// 1) Download image + apply limits (lxc-create)
|
// 1) Download image + apply limits (lxc-create)
|
||||||
err := lxcManager.CreateContainer(task.Config)
|
err := createByRuntime(task.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
task.Status = "failed"
|
task.Status = "failed"
|
||||||
task.Error = err.Error()
|
task.Error = err.Error()
|
||||||
@@ -244,10 +256,10 @@ func (q *TaskQueue) createWorker() {
|
|||||||
|
|
||||||
// 3) Start + initialize SSH/network in the same worker.
|
// 3) Start + initialize SSH/network in the same worker.
|
||||||
// If init fails, destroy the container so no dead entry remains.
|
// If init fails, destroy the container so no dead entry remains.
|
||||||
startErr := lxcManager.StartContainer(c.ID)
|
startErr := startByRuntime(c.ID)
|
||||||
if startErr != nil {
|
if startErr != nil {
|
||||||
if createdByTask {
|
if createdByTask {
|
||||||
lxcManager.DestroyContainer(c.ID)
|
_ = destroyByRuntime(c.ID)
|
||||||
}
|
}
|
||||||
task.Status = "failed"
|
task.Status = "failed"
|
||||||
task.Error = startErr.Error()
|
task.Error = startErr.Error()
|
||||||
@@ -292,13 +304,13 @@ func (q *TaskQueue) opWorker() {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
switch task.Type {
|
switch task.Type {
|
||||||
case TaskStart:
|
case TaskStart:
|
||||||
err = lxcManager.StartContainer(task.ContainerID)
|
err = startByRuntime(task.ContainerID)
|
||||||
case TaskStop:
|
case TaskStop:
|
||||||
err = lxcManager.StopContainer(task.ContainerID)
|
err = stopByRuntime(task.ContainerID)
|
||||||
case TaskRestart:
|
case TaskRestart:
|
||||||
err = lxcManager.RestartContainer(task.ContainerID)
|
err = restartByRuntime(task.ContainerID)
|
||||||
case TaskDelete:
|
case TaskDelete:
|
||||||
err = lxcManager.DestroyContainer(task.ContainerID)
|
err = destroyByRuntime(task.ContainerID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
time.Sleep(1 * time.Second)
|
time.Sleep(1 * time.Second)
|
||||||
if config.FindContainer(task.ContainerID) != nil {
|
if config.FindContainer(task.ContainerID) != nil {
|
||||||
@@ -306,7 +318,7 @@ func (q *TaskQueue) opWorker() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case TaskReinstall:
|
case TaskReinstall:
|
||||||
err = lxcManager.ReinstallContainer(task.ContainerID, task.TemplateID)
|
err = reinstallByRuntime(task.ContainerID, task.TemplateID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,10 +330,10 @@ func (q *TaskQueue) opWorker() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
task.Status = "failed"
|
task.Status = "failed"
|
||||||
task.Error = err.Error()
|
task.Error = err.Error()
|
||||||
config.AddAuditLog(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser)
|
config.AddAuditLogFull(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser, task.IP, task.UserAgent, false, err.Error())
|
||||||
} else {
|
} else {
|
||||||
task.Status = "done"
|
task.Status = "done"
|
||||||
config.AddAuditLog(string(task.Type), task.ContainerName, "成功", auditUser)
|
config.AddAuditLogFull(string(task.Type), task.ContainerName, "成功", auditUser, task.IP, task.UserAgent, true, "")
|
||||||
switch task.Type {
|
switch task.Type {
|
||||||
case TaskStart:
|
case TaskStart:
|
||||||
config.UpdateContainerStatus(task.ContainerID, "running")
|
config.UpdateContainerStatus(task.ContainerID, "running")
|
||||||
@@ -418,6 +430,8 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
|
|||||||
user = "user:" + subUser
|
user = "user:" + subUser
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ip := clientIP(r)
|
||||||
|
userAgent := r.Header.Get("User-Agent")
|
||||||
|
|
||||||
var taskType TaskType
|
var taskType TaskType
|
||||||
var templateID string
|
var templateID string
|
||||||
@@ -442,7 +456,11 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
|
|||||||
templateID = c.Template
|
templateID = c.Template
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !isTemplateEnabledAndDownloaded(templateID) {
|
runtime := runtimeFromTemplateID(templateID)
|
||||||
|
if c := config.FindContainer(id); c != nil {
|
||||||
|
runtime = c.Runtime()
|
||||||
|
}
|
||||||
|
if !isImageEnabledAndDownloaded(templateID, runtime) {
|
||||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Template is not enabled or downloaded"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -452,7 +470,7 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ids := globalQueue.EnqueueBatchWithUser(taskType, []int{id}, templateID, user)
|
ids := globalQueue.EnqueueBatchWithAudit(taskType, []int{id}, templateID, user, ip, userAgent)
|
||||||
jsonResponse(w, http.StatusAccepted, APIResponse{
|
jsonResponse(w, http.StatusAccepted, APIResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "Task queued",
|
Message: "Task queued",
|
||||||
@@ -502,13 +520,14 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
if req.Containers[i].VCPU <= 0 {
|
if req.Containers[i].VCPU <= 0 {
|
||||||
req.Containers[i].VCPU = 1
|
req.Containers[i].VCPU = 1
|
||||||
}
|
}
|
||||||
|
req.Containers[i].Virtualization = runtimeFromRequest(req.Containers[i].Virtualization)
|
||||||
if req.Containers[i].RAMMB < 128 {
|
if req.Containers[i].RAMMB < 128 {
|
||||||
req.Containers[i].RAMMB = 512
|
req.Containers[i].RAMMB = 512
|
||||||
}
|
}
|
||||||
if req.Containers[i].DiskGB < 1 {
|
if req.Containers[i].DiskGB < 1 {
|
||||||
req.Containers[i].DiskGB = 5
|
req.Containers[i].DiskGB = 5
|
||||||
}
|
}
|
||||||
if !isTemplateEnabledAndDownloaded(req.Containers[i].TemplateID) {
|
if !isImageEnabledAndDownloaded(req.Containers[i].TemplateID, req.Containers[i].Virtualization) {
|
||||||
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"})
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: name + ": template is not enabled or downloaded"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -518,7 +537,7 @@ func HandleBatchCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
if req.Containers[i].SnapshotLimit <= 0 {
|
if req.Containers[i].SnapshotLimit <= 0 {
|
||||||
req.Containers[i].SnapshotLimit = config.DefaultSnapshotLimit
|
req.Containers[i].SnapshotLimit = config.DefaultSnapshotLimit
|
||||||
}
|
}
|
||||||
if err := validateContainerResourceRequest(req.Containers[i].VCPU, req.Containers[i].RAMMB, req.Containers[i].DiskGB); err != nil {
|
if err := validateRuntimeResourceRequest(req.Containers[i].Virtualization, req.Containers[i].VCPU, req.Containers[i].RAMMB, req.Containers[i].DiskGB); err != nil {
|
||||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: name + ": " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"clicd/internal/config"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
type webVNCTicket struct {
|
||||||
|
ContainerName string
|
||||||
|
ContainerUUID string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var webVNCTickets = struct {
|
||||||
|
sync.Mutex
|
||||||
|
items map[string]webVNCTicket
|
||||||
|
}{items: map[string]webVNCTicket{}}
|
||||||
|
|
||||||
|
func HandleVNCTicket(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
ContainerName string `json:"container_name"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ContainerName == "" {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Container name required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isContainerAllowedForRequest(r, req.ContainerName) {
|
||||||
|
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c := config.FindContainerByName(req.ContainerName)
|
||||||
|
if c == nil {
|
||||||
|
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !c.IsKVM() {
|
||||||
|
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "VNC console is only available for KVM VMs"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ticket := randomHex(32)
|
||||||
|
webVNCTickets.Lock()
|
||||||
|
cleanupExpiredWebVNCTicketsLocked(time.Now())
|
||||||
|
webVNCTickets.items[ticket] = webVNCTicket{
|
||||||
|
ContainerName: c.Name,
|
||||||
|
ContainerUUID: c.UUID,
|
||||||
|
ExpiresAt: time.Now().Add(60 * time.Second),
|
||||||
|
}
|
||||||
|
webVNCTickets.Unlock()
|
||||||
|
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{
|
||||||
|
Success: true,
|
||||||
|
Data: map[string]string{"ticket": ticket},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleVNCProxy proxies a KVM VM's local libvirt VNC socket to the browser.
|
||||||
|
func HandleVNCProxy(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ticket := webVNCTicketFromRequest(r)
|
||||||
|
if ticket == "" {
|
||||||
|
http.Error(w, "ticket required", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
containerName := r.URL.Query().Get("container")
|
||||||
|
if containerName == "" {
|
||||||
|
http.Error(w, "container name required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
item, ok := consumeWebVNCTicket(ticket, containerName)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "invalid or expired ticket", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c := config.FindContainerByName(containerName)
|
||||||
|
if c == nil || c.UUID != item.ContainerUUID {
|
||||||
|
http.Error(w, "container not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !c.IsKVM() {
|
||||||
|
http.Error(w, "VNC console is only available for KVM VMs", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c.Status != "running" {
|
||||||
|
http.Error(w, "container is not running", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
vncPort, err := kvmManager.RefreshVNCPort(c.ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("VNC display is not available: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
vncConn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", fmt.Sprintf("%d", vncPort)), 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("VNC connection failed: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer vncConn.Close()
|
||||||
|
|
||||||
|
responseHeader := http.Header{}
|
||||||
|
if protocol := webVNCResponseProtocol(r); protocol != "" {
|
||||||
|
responseHeader.Set("Sec-WebSocket-Protocol", protocol)
|
||||||
|
}
|
||||||
|
ws, err := upgrader.Upgrade(w, r, responseHeader)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("WebVNC upgrade failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer ws.Close()
|
||||||
|
|
||||||
|
log.Printf("WebVNC connected for container %s -> 127.0.0.1:%d", containerName, vncPort)
|
||||||
|
|
||||||
|
done := make(chan string, 2)
|
||||||
|
var writeMu sync.Mutex
|
||||||
|
go streamVNCToWebSocket(ws, &writeMu, vncConn, done)
|
||||||
|
go streamWebSocketToVNC(ws, vncConn, done)
|
||||||
|
|
||||||
|
reason := <-done
|
||||||
|
_ = vncConn.Close()
|
||||||
|
_ = ws.Close()
|
||||||
|
log.Printf("WebVNC disconnected for container %s: %s", containerName, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func webVNCTicketFromRequest(r *http.Request) string {
|
||||||
|
for _, protocol := range websocket.Subprotocols(r) {
|
||||||
|
const prefix = "clicd-vnc-ticket."
|
||||||
|
if len(protocol) > len(prefix) && protocol[:len(prefix)] == prefix {
|
||||||
|
return protocol[len(prefix):]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r.URL.Query().Get("ticket")
|
||||||
|
}
|
||||||
|
|
||||||
|
func webVNCResponseProtocol(r *http.Request) string {
|
||||||
|
for _, protocol := range websocket.Subprotocols(r) {
|
||||||
|
if protocol == "binary" {
|
||||||
|
return protocol
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, protocol := range websocket.Subprotocols(r) {
|
||||||
|
const prefix = "clicd-vnc-ticket."
|
||||||
|
if len(protocol) > len(prefix) && protocol[:len(prefix)] == prefix {
|
||||||
|
return protocol
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func consumeWebVNCTicket(ticket, containerName string) (webVNCTicket, bool) {
|
||||||
|
now := time.Now()
|
||||||
|
webVNCTickets.Lock()
|
||||||
|
defer webVNCTickets.Unlock()
|
||||||
|
cleanupExpiredWebVNCTicketsLocked(now)
|
||||||
|
item, ok := webVNCTickets.items[ticket]
|
||||||
|
if !ok {
|
||||||
|
return webVNCTicket{}, false
|
||||||
|
}
|
||||||
|
delete(webVNCTickets.items, ticket)
|
||||||
|
return item, item.ContainerName == containerName && now.Before(item.ExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupExpiredWebVNCTicketsLocked(now time.Time) {
|
||||||
|
for ticket, item := range webVNCTickets.items {
|
||||||
|
if !now.Before(item.ExpiresAt) {
|
||||||
|
delete(webVNCTickets.items, ticket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func streamVNCToWebSocket(ws *websocket.Conn, writeMu *sync.Mutex, src io.Reader, done chan<- string) {
|
||||||
|
buf := make([]byte, 32*1024)
|
||||||
|
for {
|
||||||
|
n, err := src.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
writeMu.Lock()
|
||||||
|
writeErr := ws.WriteMessage(websocket.BinaryMessage, buf[:n])
|
||||||
|
writeMu.Unlock()
|
||||||
|
if writeErr != nil {
|
||||||
|
done <- fmt.Sprintf("browser websocket write failed: %v", writeErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
done <- "VNC server closed connection"
|
||||||
|
} else {
|
||||||
|
done <- fmt.Sprintf("VNC server read failed: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func streamWebSocketToVNC(ws *websocket.Conn, dst net.Conn, done chan<- string) {
|
||||||
|
for {
|
||||||
|
messageType, msg, err := ws.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
done <- fmt.Sprintf("browser websocket read failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if messageType != websocket.BinaryMessage && messageType != websocket.TextMessage {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := dst.Write(msg); err != nil {
|
||||||
|
done <- fmt.Sprintf("VNC server write failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,21 +47,22 @@ type SavedLoginLog struct {
|
|||||||
|
|
||||||
// AuditLog represents an operation log entry
|
// AuditLog represents an operation log entry
|
||||||
type AuditLog struct {
|
type AuditLog struct {
|
||||||
Time string `json:"time"`
|
Time string `json:"time"`
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
Target string `json:"target"`
|
Target string `json:"target"`
|
||||||
Detail string `json:"detail"`
|
Detail string `json:"detail"`
|
||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
|
IP string `json:"ip,omitempty"`
|
||||||
|
UserAgent string `json:"user_agent,omitempty"`
|
||||||
|
Success *bool `json:"success,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OversellConfig controls host-level overselling behavior
|
type VMReadinessCheck struct {
|
||||||
type OversellConfig struct {
|
Key string `json:"key"`
|
||||||
CPUOvercommit int `json:"cpu_overcommit"` // multiplier, e.g. 4 means 4x oversell
|
Label string `json:"label"`
|
||||||
RAMOvercommit int `json:"ram_overcommit"` // multiplier
|
OK bool `json:"ok"`
|
||||||
DiskOvercommit int `json:"disk_overcommit"` // multiplier
|
Detail string `json:"detail,omitempty"`
|
||||||
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
|
// Container represents an LXC container configuration
|
||||||
@@ -69,7 +70,11 @@ type Container struct {
|
|||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
UUID string `json:"uuid"`
|
UUID string `json:"uuid"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Virtualization string `json:"virtualization,omitempty"`
|
||||||
LXCName string `json:"lxc_name,omitempty"`
|
LXCName string `json:"lxc_name,omitempty"`
|
||||||
|
KVMName string `json:"kvm_name,omitempty"`
|
||||||
|
DiskImage string `json:"disk_image,omitempty"`
|
||||||
|
MACAddress string `json:"mac_address,omitempty"`
|
||||||
Template string `json:"template"`
|
Template string `json:"template"`
|
||||||
VCPU float64 `json:"vcpu"`
|
VCPU float64 `json:"vcpu"`
|
||||||
RAMMB int `json:"ram_mb"`
|
RAMMB int `json:"ram_mb"`
|
||||||
@@ -105,6 +110,28 @@ type Container struct {
|
|||||||
SnapshotScheduleCreatedBy string `json:"snapshot_schedule_created_by"`
|
SnapshotScheduleCreatedBy string `json:"snapshot_schedule_created_by"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
VirtualizationLXC = "lxc"
|
||||||
|
VirtualizationKVM = "kvm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NormalizeVirtualization(value string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case VirtualizationKVM:
|
||||||
|
return VirtualizationKVM
|
||||||
|
default:
|
||||||
|
return VirtualizationLXC
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Container) Runtime() string {
|
||||||
|
return NormalizeVirtualization(c.Virtualization)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Container) IsKVM() bool {
|
||||||
|
return c.Runtime() == VirtualizationKVM
|
||||||
|
}
|
||||||
|
|
||||||
// LxcName returns the internal LXC container name (ct-{id})
|
// LxcName returns the internal LXC container name (ct-{id})
|
||||||
func (c *Container) LxcName() string {
|
func (c *Container) LxcName() string {
|
||||||
if c.LXCName != "" {
|
if c.LXCName != "" {
|
||||||
@@ -113,6 +140,14 @@ func (c *Container) LxcName() string {
|
|||||||
return fmt.Sprintf("ct-%d", c.ID)
|
return fmt.Sprintf("ct-%d", c.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VirshName returns the internal libvirt domain name for KVM instances.
|
||||||
|
func (c *Container) VirshName() string {
|
||||||
|
if c.KVMName != "" {
|
||||||
|
return c.KVMName
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("vm-%d", c.ID)
|
||||||
|
}
|
||||||
|
|
||||||
// SubUser represents a sub-user with access to specific containers
|
// SubUser represents a sub-user with access to specific containers
|
||||||
type ApiKeyConfig struct {
|
type ApiKeyConfig struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -139,13 +174,14 @@ func DeleteApiKey(id string) {
|
|||||||
type SubUser struct {
|
type SubUser struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"-"`
|
Password string `json:"password,omitempty"`
|
||||||
PassHash string `json:"pass_hash"`
|
PassHash string `json:"pass_hash"`
|
||||||
ContainerNames []string `json:"container_names"`
|
ContainerNames []string `json:"container_names"`
|
||||||
ContainerUUIDs []string `json:"container_uuids,omitempty"`
|
ContainerUUIDs []string `json:"container_uuids,omitempty"`
|
||||||
Token string `json:"-"`
|
Token string `json:"-"`
|
||||||
AccessCode string `json:"access_code"`
|
AccessCode string `json:"access_code"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
|
TokenVersion int `json:"token_version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Snapshot struct {
|
type Snapshot struct {
|
||||||
@@ -172,7 +208,6 @@ type ClicdConfig struct {
|
|||||||
NextVNCPort int `json:"next_vnc_port"`
|
NextVNCPort int `json:"next_vnc_port"`
|
||||||
NextSSHPort int `json:"next_ssh_port"`
|
NextSSHPort int `json:"next_ssh_port"`
|
||||||
SetupComplete bool `json:"setup_complete"`
|
SetupComplete bool `json:"setup_complete"`
|
||||||
Oversell OversellConfig `json:"oversell"`
|
|
||||||
SubUsers []SubUser `json:"sub_users"`
|
SubUsers []SubUser `json:"sub_users"`
|
||||||
ApiKeys []ApiKeyConfig `json:"api_keys"`
|
ApiKeys []ApiKeyConfig `json:"api_keys"`
|
||||||
AuditLogs []AuditLog `json:"audit_logs"`
|
AuditLogs []AuditLog `json:"audit_logs"`
|
||||||
@@ -273,14 +308,6 @@ func InitConfig() (*ClicdConfig, error) {
|
|||||||
AuditLogs: []AuditLog{},
|
AuditLogs: []AuditLog{},
|
||||||
Tasks: []SavedTask{},
|
Tasks: []SavedTask{},
|
||||||
LoginLogs: []SavedLoginLog{},
|
LoginLogs: []SavedLoginLog{},
|
||||||
Oversell: OversellConfig{
|
|
||||||
CPUOvercommit: 4,
|
|
||||||
RAMOvercommit: 1,
|
|
||||||
DiskOvercommit: 2,
|
|
||||||
KSMEnabled: true,
|
|
||||||
Swappiness: 10,
|
|
||||||
SubUserSnapshotLimit: 3,
|
|
||||||
},
|
|
||||||
Snapshots: []Snapshot{},
|
Snapshots: []Snapshot{},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,10 +361,10 @@ func InitConfig() (*ClicdConfig, error) {
|
|||||||
if AppConfig.Snapshots == nil {
|
if AppConfig.Snapshots == nil {
|
||||||
AppConfig.Snapshots = make([]Snapshot, 0)
|
AppConfig.Snapshots = make([]Snapshot, 0)
|
||||||
}
|
}
|
||||||
if AppConfig.Oversell.SubUserSnapshotLimit <= 0 {
|
|
||||||
AppConfig.Oversell.SubUserSnapshotLimit = 3
|
|
||||||
}
|
|
||||||
changed := ensureContainerUUIDs()
|
changed := ensureContainerUUIDs()
|
||||||
|
if ensureContainerVirtualization() {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
if ensureContainerPortMappingLimits() {
|
if ensureContainerPortMappingLimits() {
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
@@ -362,6 +389,18 @@ func InitConfig() (*ClicdConfig, error) {
|
|||||||
return AppConfig, nil
|
return AppConfig, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureContainerVirtualization() bool {
|
||||||
|
changed := false
|
||||||
|
for i := range AppConfig.Containers {
|
||||||
|
next := NormalizeVirtualization(AppConfig.Containers[i].Virtualization)
|
||||||
|
if AppConfig.Containers[i].Virtualization != next {
|
||||||
|
AppConfig.Containers[i].Virtualization = next
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
func ensureContainerSnapshotScheduleDefaults() bool {
|
func ensureContainerSnapshotScheduleDefaults() bool {
|
||||||
changed := false
|
changed := false
|
||||||
for i := range AppConfig.Containers {
|
for i := range AppConfig.Containers {
|
||||||
@@ -414,13 +453,9 @@ func ensureContainerPortMappingLimits() bool {
|
|||||||
|
|
||||||
func ensureContainerSnapshotLimits() bool {
|
func ensureContainerSnapshotLimits() bool {
|
||||||
changed := false
|
changed := false
|
||||||
legacyLimit := AppConfig.Oversell.SubUserSnapshotLimit
|
|
||||||
if legacyLimit <= 0 {
|
|
||||||
legacyLimit = DefaultSnapshotLimit
|
|
||||||
}
|
|
||||||
for i := range AppConfig.Containers {
|
for i := range AppConfig.Containers {
|
||||||
if AppConfig.Containers[i].SnapshotLimit <= 0 {
|
if AppConfig.Containers[i].SnapshotLimit <= 0 {
|
||||||
AppConfig.Containers[i].SnapshotLimit = legacyLimit
|
AppConfig.Containers[i].SnapshotLimit = DefaultSnapshotLimit
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -437,10 +472,6 @@ func migrateSubUsers() bool {
|
|||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if su.Password != "" {
|
|
||||||
su.Password = ""
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
if su.Token != "" {
|
if su.Token != "" {
|
||||||
su.Token = ""
|
su.Token = ""
|
||||||
changed = true
|
changed = true
|
||||||
@@ -518,6 +549,7 @@ func AddContainer(c Container) {
|
|||||||
if c.UUID == "" {
|
if c.UUID == "" {
|
||||||
c.UUID = NewContainerUUID()
|
c.UUID = NewContainerUUID()
|
||||||
}
|
}
|
||||||
|
c.Virtualization = NormalizeVirtualization(c.Virtualization)
|
||||||
AppConfig.Containers = append(AppConfig.Containers, c)
|
AppConfig.Containers = append(AppConfig.Containers, c)
|
||||||
SaveConfig()
|
SaveConfig()
|
||||||
}
|
}
|
||||||
@@ -536,6 +568,8 @@ func RemoveContainer(id int) bool {
|
|||||||
if c.ID == id {
|
if c.ID == id {
|
||||||
removeSubUserContainerAccess(c.Name, c.UUID)
|
removeSubUserContainerAccess(c.Name, c.UUID)
|
||||||
removeContainerSnapshotMetadata(id)
|
removeContainerSnapshotMetadata(id)
|
||||||
|
// Clear snapshot schedule for this container
|
||||||
|
clearContainerSnapshotSchedule(&AppConfig.Containers[i])
|
||||||
AppConfig.Containers = append(AppConfig.Containers[:i], AppConfig.Containers[i+1:]...)
|
AppConfig.Containers = append(AppConfig.Containers[:i], AppConfig.Containers[i+1:]...)
|
||||||
SaveConfig()
|
SaveConfig()
|
||||||
return true
|
return true
|
||||||
@@ -544,6 +578,15 @@ func RemoveContainer(id int) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func clearContainerSnapshotSchedule(c *Container) {
|
||||||
|
c.SnapshotScheduleEnabled = false
|
||||||
|
c.SnapshotScheduleIntervalHours = 0
|
||||||
|
c.SnapshotScheduleTime = ""
|
||||||
|
c.SnapshotScheduleLastRun = ""
|
||||||
|
c.SnapshotScheduleNextRun = ""
|
||||||
|
c.SnapshotScheduleCreatedBy = ""
|
||||||
|
}
|
||||||
|
|
||||||
func AddSnapshot(snapshot Snapshot) {
|
func AddSnapshot(snapshot Snapshot) {
|
||||||
AppConfig.Snapshots = append(AppConfig.Snapshots, snapshot)
|
AppConfig.Snapshots = append(AppConfig.Snapshots, snapshot)
|
||||||
SaveConfig()
|
SaveConfig()
|
||||||
@@ -680,14 +723,29 @@ func UpdateVNC(containers []Container) {
|
|||||||
SaveConfig()
|
SaveConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
// AllocateSSHPort allocates a new SSH port
|
// AllocateSSHPort allocates a new SSH port, skipping ports already used by any container
|
||||||
func AllocateSSHPort() int {
|
func AllocateSSHPort() int {
|
||||||
|
used := collectAllHostPorts()
|
||||||
port := AppConfig.NextSSHPort
|
port := AppConfig.NextSSHPort
|
||||||
AppConfig.NextSSHPort++
|
for used[port] {
|
||||||
|
port++
|
||||||
|
}
|
||||||
|
AppConfig.NextSSHPort = port + 1
|
||||||
SaveConfig()
|
SaveConfig()
|
||||||
return port
|
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)
|
// IsValidContainerName checks if container name is valid (no duplicate check needed, ID is primary key)
|
||||||
func IsValidContainerName(name string) bool {
|
func IsValidContainerName(name string) bool {
|
||||||
return IsValidContainerNameSyntax(name)
|
return IsValidContainerNameSyntax(name)
|
||||||
@@ -723,6 +781,26 @@ func AddAuditLog(action, target, detail, user string) {
|
|||||||
SaveConfig()
|
SaveConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func AddAuditLogFull(action, target, detail, user, ip, userAgent string, success bool, errMsg string) {
|
||||||
|
s := success
|
||||||
|
log := AuditLog{
|
||||||
|
Time: time.Now().Format("2006-01-02 15:04:05"),
|
||||||
|
Action: action,
|
||||||
|
Target: target,
|
||||||
|
Detail: detail,
|
||||||
|
User: user,
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: userAgent,
|
||||||
|
Success: &s,
|
||||||
|
Error: errMsg,
|
||||||
|
}
|
||||||
|
AppConfig.AuditLogs = append(AppConfig.AuditLogs, log)
|
||||||
|
if len(AppConfig.AuditLogs) > 500 {
|
||||||
|
AppConfig.AuditLogs = AppConfig.AuditLogs[len(AppConfig.AuditLogs)-500:]
|
||||||
|
}
|
||||||
|
SaveConfig()
|
||||||
|
}
|
||||||
|
|
||||||
// SaveTasks persists the task queue to config
|
// SaveTasks persists the task queue to config
|
||||||
func SaveTasks(tasks []SavedTask) {
|
func SaveTasks(tasks []SavedTask) {
|
||||||
AppConfig.Tasks = tasks
|
AppConfig.Tasks = tasks
|
||||||
@@ -760,6 +838,19 @@ func CleanStaleContainers() {
|
|||||||
valid := make([]Container, 0)
|
valid := make([]Container, 0)
|
||||||
changed := false
|
changed := false
|
||||||
for _, c := range AppConfig.Containers {
|
for _, c := range AppConfig.Containers {
|
||||||
|
if c.IsKVM() {
|
||||||
|
if c.DiskImage == "" {
|
||||||
|
valid = append(valid, c)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(c.DiskImage); os.IsNotExist(err) {
|
||||||
|
fmt.Printf("Cleaning stale KVM config: %s (disk image not found)\n", c.VirshName())
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
valid = append(valid, c)
|
||||||
|
continue
|
||||||
|
}
|
||||||
lxcDir := "/var/lib/lxc/" + c.LxcName()
|
lxcDir := "/var/lib/lxc/" + c.LxcName()
|
||||||
if _, err := os.Stat(lxcDir); os.IsNotExist(err) {
|
if _, err := os.Stat(lxcDir); os.IsNotExist(err) {
|
||||||
fmt.Printf("Cleaning stale container config: %s (LXC dir not found)\n", c.LxcName())
|
fmt.Printf("Cleaning stale container config: %s (LXC dir not found)\n", c.LxcName())
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,112 @@
|
|||||||
|
package kvm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Image struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Distro string `json:"distro"`
|
||||||
|
Release string `json:"release"`
|
||||||
|
Arch string `json:"arch"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetImages() []Image {
|
||||||
|
return []Image{
|
||||||
|
{
|
||||||
|
ID: "kvm-ubuntu-noble", Name: "Ubuntu 24.04 KVM",
|
||||||
|
Distro: "ubuntu", Release: "noble", Arch: "amd64",
|
||||||
|
Description: "Ubuntu 24.04 LTS cloud image for KVM",
|
||||||
|
URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "kvm-ubuntu-jammy", Name: "Ubuntu 22.04 KVM",
|
||||||
|
Distro: "ubuntu", Release: "jammy", Arch: "amd64",
|
||||||
|
Description: "Ubuntu 22.04 LTS cloud image for KVM",
|
||||||
|
URL: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "kvm-debian-bookworm", Name: "Debian 12 KVM",
|
||||||
|
Distro: "debian", Release: "bookworm", Arch: "amd64",
|
||||||
|
Description: "Debian 12 generic cloud image for KVM",
|
||||||
|
URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "kvm-debian-bullseye", Name: "Debian 11 KVM",
|
||||||
|
Distro: "debian", Release: "bullseye", Arch: "amd64",
|
||||||
|
Description: "Debian 11 generic cloud image for KVM",
|
||||||
|
URL: "https://cloud.debian.org/images/cloud/bullseye/latest/debian-11-genericcloud-amd64.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",
|
||||||
|
Distro: "centos", Release: "9-stream", Arch: "amd64",
|
||||||
|
Description: "CentOS Stream 9 GenericCloud image for KVM",
|
||||||
|
URL: "https://cloud.centos.org/centos/9-stream/x86_64/images/CentOS-Stream-GenericCloud-9-latest.x86_64.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",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "kvm-windows-10", Name: "Windows 10 KVM",
|
||||||
|
Distro: "windows", Release: "10", Arch: "amd64",
|
||||||
|
Description: "Windows ISO for KVM (automatic unattended install from image index 1, network, Administrator password, RDP, firewall, and QEMU Guest Agent initialization)",
|
||||||
|
URL: "https://go.microsoft.com/fwlink/?LinkID=2195404",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func FindImage(id string) *Image {
|
||||||
|
for _, image := range GetImages() {
|
||||||
|
if image.ID == id {
|
||||||
|
return &image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CacheDir() string {
|
||||||
|
return filepath.Join(BaseDir(), "images")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ImagePath(id string) string {
|
||||||
|
img := FindImage(id)
|
||||||
|
ext := ".qcow2"
|
||||||
|
if img != nil && img.Distro == "windows" {
|
||||||
|
ext = ".iso"
|
||||||
|
}
|
||||||
|
return filepath.Join(CacheDir(), id+ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsWindowsImage returns true if the image distro is "windows".
|
||||||
|
func IsWindowsImage(id string) bool {
|
||||||
|
img := FindImage(id)
|
||||||
|
return img != nil && img.Distro == "windows"
|
||||||
|
}
|
||||||
|
|
||||||
|
func virtioWinISOPath() string {
|
||||||
|
return filepath.Join(CacheDir(), "virtio-win.iso")
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ func IsExpired(c config.Container) bool {
|
|||||||
// StopExpiredContainers stops running containers whose expiration date has passed.
|
// StopExpiredContainers stops running containers whose expiration date has passed.
|
||||||
func (m *Manager) StopExpiredContainers(now time.Time) {
|
func (m *Manager) StopExpiredContainers(now time.Time) {
|
||||||
for _, container := range config.AppConfig.Containers {
|
for _, container := range config.AppConfig.Containers {
|
||||||
if !isContainerExpired(container, now) {
|
if container.IsKVM() || !isContainerExpired(container, now) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ func (m *Manager) StopTrafficExceededContainers(now time.Time) {
|
|||||||
saved := false
|
saved := false
|
||||||
for i := range config.AppConfig.Containers {
|
for i := range config.AppConfig.Containers {
|
||||||
c := &config.AppConfig.Containers[i]
|
c := &config.AppConfig.Containers[i]
|
||||||
if c.Status != "running" {
|
if c.IsKVM() || c.Status != "running" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+250
-28
@@ -1,6 +1,7 @@
|
|||||||
package lxc
|
package lxc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -72,11 +73,17 @@ func (m *Manager) WarmRunningContainersSSH() {
|
|||||||
containers := append([]config.Container(nil), config.AppConfig.Containers...)
|
containers := append([]config.Container(nil), config.AppConfig.Containers...)
|
||||||
for _, container := range containers {
|
for _, container := range containers {
|
||||||
c := container
|
c := container
|
||||||
|
if c.IsKVM() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
status, err := m.GetContainerStatus(c.LxcName())
|
status, err := m.GetContainerStatus(c.LxcName())
|
||||||
if err != nil || status != "running" {
|
if err != nil || status != "running" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
config.UpdateContainerStatus(c.ID, "running")
|
config.UpdateContainerStatus(c.ID, "running")
|
||||||
|
if c.IP != "" && m.containerPortListening(c.LxcName(), 22) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
m.WarmSSHAsync(c.ID, "running container scan")
|
m.WarmSSHAsync(c.ID, "running container scan")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,6 +105,11 @@ func (m *Manager) updateAllRates() {
|
|||||||
|
|
||||||
for i := range config.AppConfig.Containers {
|
for i := range config.AppConfig.Containers {
|
||||||
c := &config.AppConfig.Containers[i]
|
c := &config.AppConfig.Containers[i]
|
||||||
|
if c.IsKVM() {
|
||||||
|
delete(lastUsage, c.VirshName())
|
||||||
|
delete(rateCache, c.VirshName())
|
||||||
|
continue
|
||||||
|
}
|
||||||
if c.Status != "running" {
|
if c.Status != "running" {
|
||||||
delete(lastUsage, c.LxcName())
|
delete(lastUsage, c.LxcName())
|
||||||
delete(rateCache, c.LxcName())
|
delete(rateCache, c.LxcName())
|
||||||
@@ -105,19 +117,23 @@ func (m *Manager) updateAllRates() {
|
|||||||
}
|
}
|
||||||
lxcName := c.LxcName()
|
lxcName := c.LxcName()
|
||||||
|
|
||||||
// Read raw bytes
|
// Cache init PID once per scan so getContainerNetworkBytes / getContainerDiskIOBytes
|
||||||
memUsage := readIntCommand(fmt.Sprintf(
|
// don't each fork lxc-info separately.
|
||||||
"cat /sys/fs/cgroup/lxc/%[1]s/memory.current 2>/dev/null || "+
|
initPID := m.getContainerInitPID(lxcName)
|
||||||
"cat /sys/fs/cgroup/lxc.payload.%[1]s/memory.current 2>/dev/null || "+
|
|
||||||
"cat /sys/fs/cgroup/memory/lxc/%[1]s/memory.usage_in_bytes 2>/dev/null || echo 0", shellQuote(lxcName)))
|
|
||||||
|
|
||||||
cpuUsec := uint64(readIntCommand(fmt.Sprintf(
|
// Read memory from cgroup directly (no shell fork)
|
||||||
"(cat /sys/fs/cgroup/lxc/%[1]s/cpu.stat 2>/dev/null || "+
|
memUsage := readCgroupFile(lxcName,
|
||||||
"cat /sys/fs/cgroup/lxc.payload.%[1]s/cpu.stat 2>/dev/null) | "+
|
"/sys/fs/cgroup/lxc/%s/memory.current",
|
||||||
"awk '/usage_usec/ {print $2; found=1} END {if (!found) print 0}'", shellQuote(lxcName))))
|
"/sys/fs/cgroup/lxc.payload.%s/memory.current",
|
||||||
|
"/sys/fs/cgroup/memory/lxc/%s/memory.usage_in_bytes")
|
||||||
|
|
||||||
rxBytes, txBytes := m.getContainerNetworkBytes(lxcName)
|
// Read cpu usage from cgroup directly (no shell | awk fork)
|
||||||
readBytes, writeBytes := m.getContainerDiskIOBytes(lxcName)
|
cpuUsec := readCgroupCPUUsec(lxcName,
|
||||||
|
"/sys/fs/cgroup/lxc/%s/cpu.stat",
|
||||||
|
"/sys/fs/cgroup/lxc.payload.%s/cpu.stat")
|
||||||
|
|
||||||
|
rxBytes, txBytes := getNetworkBytesForPID(initPID)
|
||||||
|
readBytes, writeBytes := getDiskIOBytesForPID(initPID)
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
sample := containerUsageSample{
|
sample := containerUsageSample{
|
||||||
@@ -203,6 +219,7 @@ func NewManager() *Manager {
|
|||||||
// ContainerConfig defines container creation parameters
|
// ContainerConfig defines container creation parameters
|
||||||
type ContainerConfig struct {
|
type ContainerConfig struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Virtualization string `json:"virtualization,omitempty"`
|
||||||
TemplateID string `json:"template_id"`
|
TemplateID string `json:"template_id"`
|
||||||
VCPU float64 `json:"vcpu"`
|
VCPU float64 `json:"vcpu"`
|
||||||
CPUPercent int `json:"cpu_percent"`
|
CPUPercent int `json:"cpu_percent"`
|
||||||
@@ -337,6 +354,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
|||||||
ID: id,
|
ID: id,
|
||||||
UUID: config.NewContainerUUID(),
|
UUID: config.NewContainerUUID(),
|
||||||
Name: cfg.Name,
|
Name: cfg.Name,
|
||||||
|
Virtualization: config.VirtualizationLXC,
|
||||||
Template: cfg.TemplateID,
|
Template: cfg.TemplateID,
|
||||||
VCPU: cfg.VCPU,
|
VCPU: cfg.VCPU,
|
||||||
RAMMB: cfg.RAMMB,
|
RAMMB: cfg.RAMMB,
|
||||||
@@ -378,6 +396,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
|
|||||||
|
|
||||||
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
|
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
|
||||||
_ = m.cleanupContainerStorage(lxcName)
|
_ = m.cleanupContainerStorage(lxcName)
|
||||||
|
config.RemoveContainer(id)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,13 +473,13 @@ IPv6AcceptRA=no
|
|||||||
// preconfigureSSH installs and configures SSH directly in the rootfs before first boot.
|
// preconfigureSSH installs and configures SSH directly in the rootfs before first boot.
|
||||||
func (m *Manager) preconfigureSSH(rootfsPath, password, templateID string) error {
|
func (m *Manager) preconfigureSSH(rootfsPath, password, templateID string) error {
|
||||||
_ = templateID
|
_ = templateID
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
cmd := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(password, false))
|
cmd := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(password, false))
|
||||||
cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...)
|
cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if ctx.Err() == context.DeadlineExceeded {
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
return fmt.Errorf("timed out after 120s, output: %s", string(output))
|
return fmt.Errorf("timed out after 180s, output: %s", string(output))
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%v, output: %s", err, string(output))
|
return fmt.Errorf("%v, output: %s", err, string(output))
|
||||||
@@ -980,6 +999,16 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
|
|||||||
if _, err := os.Stat(marker); err == nil {
|
if _, err := os.Stat(marker); err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
m.unmountRootfsChildMounts(rootfsPath)
|
||||||
|
rootInfo, err := os.Lstat(rootfsPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rootStat, ok := rootInfo.Sys().(*syscall.Stat_t)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("failed to read rootfs device for %s", rootfsPath)
|
||||||
|
}
|
||||||
|
rootDev := rootStat.Dev
|
||||||
|
|
||||||
if err := filepath.WalkDir(rootfsPath, func(path string, _ os.DirEntry, walkErr error) error {
|
if err := filepath.WalkDir(rootfsPath, func(path string, _ os.DirEntry, walkErr error) error {
|
||||||
if walkErr != nil {
|
if walkErr != nil {
|
||||||
@@ -993,6 +1022,12 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("failed to read uid/gid for %s", path)
|
return fmt.Errorf("failed to read uid/gid for %s", path)
|
||||||
}
|
}
|
||||||
|
if path != rootfsPath && stat.Dev != rootDev {
|
||||||
|
if info.IsDir() {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
uid := int(stat.Uid)
|
uid := int(stat.Uid)
|
||||||
gid := int(stat.Gid)
|
gid := int(stat.Gid)
|
||||||
if uid >= uidBase && uid < uidBase+65536 && gid >= gidBase && gid < gidBase+65536 {
|
if uid >= uidBase && uid < uidBase+65536 && gid >= gidBase && gid < gidBase+65536 {
|
||||||
@@ -1027,6 +1062,34 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) unmountRootfsChildMounts(rootfsPath string) {
|
||||||
|
rootAbs, err := filepath.Abs(rootfsPath)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := exec.Command("findmnt", "-R", "-n", "-o", "TARGET", rootfsPath).Output()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
targets := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||||
|
for i, j := 0, len(targets)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
targets[i], targets[j] = targets[j], targets[i]
|
||||||
|
}
|
||||||
|
for _, target := range targets {
|
||||||
|
target = strings.TrimSpace(target)
|
||||||
|
if target == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
targetAbs, err := filepath.Abs(target)
|
||||||
|
if err != nil || targetAbs == rootAbs {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(targetAbs, rootAbs+string(os.PathSeparator)) {
|
||||||
|
exec.Command("umount", "-R", "-l", targetAbs).Run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) rootfsShifted(lxcName string) bool {
|
func (m *Manager) rootfsShifted(lxcName string) bool {
|
||||||
marker := filepath.Join(m.LxcPath, lxcName, "rootfs", ".clicd-unprivileged-shifted")
|
marker := filepath.Join(m.LxcPath, lxcName, "rootfs", ".clicd-unprivileged-shifted")
|
||||||
_, err := os.Stat(marker)
|
_, err := os.Stat(marker)
|
||||||
@@ -1474,11 +1537,20 @@ func (m *Manager) DestroyContainer(id int) error {
|
|||||||
}
|
}
|
||||||
return fmt.Errorf("container still exists after cleanup with status %s", status)
|
return fmt.Errorf("container still exists after cleanup with status %s", status)
|
||||||
}
|
}
|
||||||
snapshotDir := filepath.Join(snapshotBaseDir(), lxcName)
|
// Remove snapshot physical files (by container ID, not lxcName)
|
||||||
|
snapshotDir := filepath.Join(snapshotBaseDir(), strconv.Itoa(id))
|
||||||
if err := safePathUnder(snapshotDir, snapshotBaseDir()); err == nil {
|
if err := safePathUnder(snapshotDir, snapshotBaseDir()); err == nil {
|
||||||
os.RemoveAll(snapshotDir)
|
os.RemoveAll(snapshotDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also remove any legacy snapshot dir that used lxcName
|
||||||
|
legacySnapshotDir := filepath.Join(snapshotBaseDir(), lxcName)
|
||||||
|
if legacySnapshotDir != snapshotDir {
|
||||||
|
if err := safePathUnder(legacySnapshotDir, snapshotBaseDir()); err == nil {
|
||||||
|
os.RemoveAll(legacySnapshotDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !config.RemoveContainer(id) {
|
if !config.RemoveContainer(id) {
|
||||||
return fmt.Errorf("container destroyed but config entry was not removed: %d", id)
|
return fmt.Errorf("container destroyed but config entry was not removed: %d", id)
|
||||||
}
|
}
|
||||||
@@ -1513,12 +1585,12 @@ func (m *Manager) EnsureSSH(id int) error {
|
|||||||
|
|
||||||
script := sshSetupScript(c.SSHPassword, true)
|
script := sshSetupScript(c.SSHPassword, true)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
cmd := exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", script)
|
cmd := exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", script)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if ctx.Err() == context.DeadlineExceeded {
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
return fmt.Errorf("timed out configuring SSH in container %d after 90s; package manager or service startup may be stuck, output: %s", id, string(output))
|
return fmt.Errorf("timed out configuring SSH in container %d after 180s; package manager or service startup may be stuck, output: %s", id, string(output))
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to configure SSH in container %d: %v, output: %s", id, err, string(output))
|
return fmt.Errorf("failed to configure SSH in container %d: %v, output: %s", id, err, string(output))
|
||||||
@@ -1626,7 +1698,10 @@ install_sshd() {
|
|||||||
sleep 3
|
sleep 3
|
||||||
done
|
done
|
||||||
elif command -v apk >/dev/null 2>&1; then
|
elif command -v apk >/dev/null 2>&1; then
|
||||||
run_timeout 60 apk add --no-cache openssh-server openssh-client shadow iproute2 procps net-tools && return 0
|
for i in 1 2 3; do
|
||||||
|
run_timeout 120 apk add --no-cache openssh-server openssh-client shadow iproute2 procps net-tools && return 0
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
elif command -v pacman >/dev/null 2>&1; then
|
elif command -v pacman >/dev/null 2>&1; then
|
||||||
run_timeout 45 pacman -Syu --noconfirm >/dev/null 2>&1 || true
|
run_timeout 45 pacman -Syu --noconfirm >/dev/null 2>&1 || true
|
||||||
run_timeout 90 pacman -S --noconfirm openssh shadow iproute2 procps-ng net-tools && return 0
|
run_timeout 90 pacman -S --noconfirm openssh shadow iproute2 procps-ng net-tools && return 0
|
||||||
@@ -1921,6 +1996,9 @@ func (m *Manager) GetContainerIP(lxcName string) (string, error) {
|
|||||||
func (m *Manager) ListContainers() ([]config.Container, error) {
|
func (m *Manager) ListContainers() ([]config.Container, error) {
|
||||||
containers := config.AppConfig.Containers
|
containers := config.AppConfig.Containers
|
||||||
for i := range containers {
|
for i := range containers {
|
||||||
|
if containers[i].IsKVM() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
status, err := m.GetContainerStatus(containers[i].LxcName())
|
status, err := m.GetContainerStatus(containers[i].LxcName())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
containers[i].Status = status
|
containers[i].Status = status
|
||||||
@@ -1996,6 +2074,7 @@ func (m *Manager) ImportExistingClicdContainers() ([]config.Container, error) {
|
|||||||
UUID: config.NewContainerUUID(),
|
UUID: config.NewContainerUUID(),
|
||||||
Name: name,
|
Name: name,
|
||||||
LXCName: lxcName,
|
LXCName: lxcName,
|
||||||
|
Virtualization: config.VirtualizationLXC,
|
||||||
Template: "imported",
|
Template: "imported",
|
||||||
VCPU: 1,
|
VCPU: 1,
|
||||||
RAMMB: 512,
|
RAMMB: 512,
|
||||||
@@ -2193,6 +2272,11 @@ func (m *Manager) GetResourceUsage(id int) (map[string]interface{}, error) {
|
|||||||
usage := make(map[string]interface{})
|
usage := make(map[string]interface{})
|
||||||
|
|
||||||
// Read raw values
|
// Read raw values
|
||||||
|
load1, load5, load15 := m.getContainerLoadAvg(lxcName)
|
||||||
|
usage["load1"] = load1
|
||||||
|
usage["load5"] = load5
|
||||||
|
usage["load15"] = load15
|
||||||
|
|
||||||
memUsage := readIntCommand(fmt.Sprintf(
|
memUsage := readIntCommand(fmt.Sprintf(
|
||||||
"cat /sys/fs/cgroup/lxc/%[1]s/memory.current 2>/dev/null || "+
|
"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 || "+
|
"cat /sys/fs/cgroup/lxc.payload.%[1]s/memory.current 2>/dev/null || "+
|
||||||
@@ -2240,15 +2324,62 @@ func (m *Manager) GetResourceUsage(id int) (map[string]interface{}, error) {
|
|||||||
return usage, nil
|
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) {
|
func (m *Manager) getContainerNetworkBytes(lxcName string) (uint64, uint64) {
|
||||||
pid := m.getContainerInitPID(lxcName)
|
pid := m.getContainerInitPID(lxcName)
|
||||||
if pid == "" {
|
if pid == "" {
|
||||||
return 0, 0
|
return 0, 0
|
||||||
}
|
}
|
||||||
dir := fmt.Sprintf("/proc/%s/net", pid)
|
return readProcNetDev(fmt.Sprintf("/proc/%s/net/dev", pid))
|
||||||
rx := readIntCommand(fmt.Sprintf("cat %s/dev 2>/dev/null | awk '{rx+=$2; tx+=$10} END {print rx}' || echo 0", shellQuote(dir)))
|
}
|
||||||
tx := readIntCommand(fmt.Sprintf("cat %s/dev 2>/dev/null | awk '{rx+=$2; tx+=$10} END {print tx}' || echo 0", shellQuote(dir)))
|
|
||||||
return uint64(rx), uint64(tx)
|
// readProcNetDev parses /proc/PID/net/dev directly (no shell/awk fork).
|
||||||
|
func readProcNetDev(path string) (uint64, uint64) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
var rx, tx uint64
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
// Skip header lines
|
||||||
|
if strings.Contains(line, "|") || strings.Contains(line, "face") || strings.TrimSpace(line) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
// Fields: face | rx_bytes rx_packets rx_errs rx_drop rx_fifo rx_frame rx_compressed rx_multicast | tx_bytes tx_packets tx_errs tx_drop tx_fifo tx_colls tx_carrier tx_compressed
|
||||||
|
// Skip loopback (face starts with "lo")
|
||||||
|
if len(fields) < 10 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(fields[0], "lo") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r, _ := strconv.ParseUint(fields[1], 10, 64)
|
||||||
|
t, _ := strconv.ParseUint(fields[9], 10, 64)
|
||||||
|
rx += r
|
||||||
|
tx += t
|
||||||
|
}
|
||||||
|
return rx, tx
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) getContainerDiskIOBytes(lxcName string) (uint64, uint64) {
|
func (m *Manager) getContainerDiskIOBytes(lxcName string) (uint64, uint64) {
|
||||||
@@ -2256,10 +2387,22 @@ func (m *Manager) getContainerDiskIOBytes(lxcName string) (uint64, uint64) {
|
|||||||
if pid == "" {
|
if pid == "" {
|
||||||
return 0, 0
|
return 0, 0
|
||||||
}
|
}
|
||||||
// /proc/PID/io format: "field_name: value" per line
|
data, err := os.ReadFile(fmt.Sprintf("/proc/%s/io", pid))
|
||||||
// Fields: rchar, wchar, syscr, syscw, read_bytes, write_bytes, cancelled_write_bytes
|
if err != nil {
|
||||||
readBytes := uint64(readIntCommand(fmt.Sprintf("awk '/^read_bytes:/ {print $2}' /proc/%s/io 2>/dev/null || echo 0", pid)))
|
return 0, 0
|
||||||
writeBytes := uint64(readIntCommand(fmt.Sprintf("awk '/^write_bytes:/ {print $2}' /proc/%s/io 2>/dev/null || echo 0", pid)))
|
}
|
||||||
|
var readBytes, writeBytes uint64
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.HasPrefix(line, "read_bytes:") {
|
||||||
|
val := strings.TrimSpace(strings.TrimPrefix(line, "read_bytes:"))
|
||||||
|
readBytes, _ = strconv.ParseUint(val, 10, 64)
|
||||||
|
} else if strings.HasPrefix(line, "write_bytes:") {
|
||||||
|
val := strings.TrimSpace(strings.TrimPrefix(line, "write_bytes:"))
|
||||||
|
writeBytes, _ = strconv.ParseUint(val, 10, 64)
|
||||||
|
}
|
||||||
|
}
|
||||||
return readBytes, writeBytes
|
return readBytes, writeBytes
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2272,6 +2415,73 @@ func (m *Manager) getContainerInitPID(lxcName string) string {
|
|||||||
return strings.TrimSpace(string(out))
|
return strings.TrimSpace(string(out))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readCgroupFile tries each path template in order, reads the file directly (no shell),
|
||||||
|
// and returns the first valid int64 value.
|
||||||
|
func readCgroupFile(name string, paths ...string) int64 {
|
||||||
|
for _, tmpl := range paths {
|
||||||
|
data, err := os.ReadFile(fmt.Sprintf(tmpl, name))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
|
||||||
|
if err == nil && val > 0 {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// readCgroupCPUUsec tries each path template, reads cpu.stat, and extracts usage_usec.
|
||||||
|
func readCgroupCPUUsec(name string, paths ...string) uint64 {
|
||||||
|
for _, tmpl := range paths {
|
||||||
|
data, err := os.ReadFile(fmt.Sprintf(tmpl, name))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.HasPrefix(line, "usage_usec ") {
|
||||||
|
val, err := strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(line, "usage_usec")), 10, 64)
|
||||||
|
if err == nil {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// getNetworkBytesForPID reads /proc/PID/net/dev for a given PID (no lxc-info needed).
|
||||||
|
func getNetworkBytesForPID(pid string) (uint64, uint64) {
|
||||||
|
if pid == "" {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
return readProcNetDev(fmt.Sprintf("/proc/%s/net/dev", pid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDiskIOBytesForPID reads /proc/PID/io for a given PID (no lxc-info needed).
|
||||||
|
func getDiskIOBytesForPID(pid string) (uint64, uint64) {
|
||||||
|
if pid == "" {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(fmt.Sprintf("/proc/%s/io", pid))
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
var readBytes, writeBytes uint64
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.HasPrefix(line, "read_bytes:") {
|
||||||
|
readBytes, _ = strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(line, "read_bytes:")), 10, 64)
|
||||||
|
} else if strings.HasPrefix(line, "write_bytes:") {
|
||||||
|
writeBytes, _ = strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(line, "write_bytes:")), 10, 64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return readBytes, writeBytes
|
||||||
|
}
|
||||||
|
|
||||||
// getContainerUptimeSeconds returns how long the container has been running (in seconds).
|
// getContainerUptimeSeconds returns how long the container has been running (in seconds).
|
||||||
func (m *Manager) getContainerUptimeSeconds(lxcName string) float64 {
|
func (m *Manager) getContainerUptimeSeconds(lxcName string) float64 {
|
||||||
pid := m.getContainerInitPID(lxcName)
|
pid := m.getContainerInitPID(lxcName)
|
||||||
@@ -2399,6 +2609,7 @@ func (m *Manager) AccumulateTraffic() {
|
|||||||
lastTrafficSnapshotMu.Lock()
|
lastTrafficSnapshotMu.Lock()
|
||||||
defer lastTrafficSnapshotMu.Unlock()
|
defer lastTrafficSnapshotMu.Unlock()
|
||||||
|
|
||||||
|
changed := false
|
||||||
for i := range config.AppConfig.Containers {
|
for i := range config.AppConfig.Containers {
|
||||||
c := &config.AppConfig.Containers[i]
|
c := &config.AppConfig.Containers[i]
|
||||||
if c.Status != "running" {
|
if c.Status != "running" {
|
||||||
@@ -2406,23 +2617,34 @@ func (m *Manager) AccumulateTraffic() {
|
|||||||
delete(lastTrafficSnapshot, c.LxcName())
|
delete(lastTrafficSnapshot, c.LxcName())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if c.IsKVM() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
// Reset if new month
|
// Reset if new month
|
||||||
if c.TrafficResetDate != currentMonth {
|
if c.TrafficResetDate != currentMonth {
|
||||||
c.TrafficUsedRX = 0
|
c.TrafficUsedRX = 0
|
||||||
c.TrafficUsedTX = 0
|
c.TrafficUsedTX = 0
|
||||||
c.TrafficResetDate = currentMonth
|
c.TrafficResetDate = currentMonth
|
||||||
delete(lastTrafficSnapshot, c.LxcName())
|
delete(lastTrafficSnapshot, c.LxcName())
|
||||||
|
changed = true
|
||||||
}
|
}
|
||||||
rx, tx := m.getContainerNetworkBytes(c.LxcName())
|
rx, tx := m.getContainerNetworkBytes(c.LxcName())
|
||||||
prev, exists := lastTrafficSnapshot[c.LxcName()]
|
prev, exists := lastTrafficSnapshot[c.LxcName()]
|
||||||
// Only add the DELTA (increment since last snapshot)
|
// Only add the DELTA (increment since last snapshot)
|
||||||
if exists && rx >= prev.RXBytes && tx >= prev.TXBytes {
|
if exists && rx >= prev.RXBytes && tx >= prev.TXBytes {
|
||||||
c.TrafficUsedRX += int64(rx - prev.RXBytes)
|
deltaRX := int64(rx - prev.RXBytes)
|
||||||
c.TrafficUsedTX += int64(tx - prev.TXBytes)
|
deltaTX := int64(tx - prev.TXBytes)
|
||||||
|
if deltaRX > 0 || deltaTX > 0 {
|
||||||
|
c.TrafficUsedRX += deltaRX
|
||||||
|
c.TrafficUsedTX += deltaTX
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
lastTrafficSnapshot[c.LxcName()] = trafficSample{RXBytes: rx, TXBytes: tx}
|
lastTrafficSnapshot[c.LxcName()] = trafficSample{RXBytes: rx, TXBytes: tx}
|
||||||
}
|
}
|
||||||
config.SaveConfig()
|
if changed {
|
||||||
|
config.SaveConfig()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTrafficInfo returns traffic usage info for a container
|
// GetTrafficInfo returns traffic usage info for a container
|
||||||
|
|||||||
@@ -18,8 +18,14 @@ func (m *Manager) ApplyPortMappings(id int) error {
|
|||||||
return fmt.Errorf("container has no IP")
|
return fmt.Errorf("container has no IP")
|
||||||
}
|
}
|
||||||
tag := clicdTag(id)
|
tag := clicdTag(id)
|
||||||
|
bridge := "lxcbr0"
|
||||||
|
subnet := "10.0.3.0/24"
|
||||||
|
if c.IsKVM() {
|
||||||
|
bridge = "virbr0"
|
||||||
|
subnet = "192.168.122.0/24"
|
||||||
|
}
|
||||||
|
|
||||||
EnsureForwardRules()
|
EnsureForwardRules(bridge)
|
||||||
m.CleanPortMappings(id)
|
m.CleanPortMappings(id)
|
||||||
|
|
||||||
for _, pm := range c.PortMappings {
|
for _, pm := range c.PortMappings {
|
||||||
@@ -41,8 +47,8 @@ func (m *Manager) ApplyPortMappings(id int) error {
|
|||||||
fmt.Printf("Port mapping: host:%d -> %s:%d\n", pm.HostPort, c.IP, pm.ContainerPort)
|
fmt.Printf("Port mapping: host:%d -> %s:%d\n", pm.HostPort, c.IP, pm.ContainerPort)
|
||||||
}
|
}
|
||||||
|
|
||||||
if exec.Command("iptables", "-t", "nat", "-C", "POSTROUTING", "-s", "10.0.3.0/24", "-o", "eth+", "-j", "MASQUERADE").Run() != nil {
|
if exec.Command("iptables", "-t", "nat", "-C", "POSTROUTING", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run() != nil {
|
||||||
exec.Command("iptables", "-t", "nat", "-I", "POSTROUTING", "1", "-s", "10.0.3.0/24", "-o", "eth+", "-j", "MASQUERADE").Run()
|
exec.Command("iptables", "-t", "nat", "-I", "POSTROUTING", "1", "-s", subnet, "-o", "eth+", "-j", "MASQUERADE").Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -50,18 +56,25 @@ func (m *Manager) ApplyPortMappings(id int) error {
|
|||||||
|
|
||||||
func clicdTag(id int) string { return "c" + strconv.Itoa(id) }
|
func clicdTag(id int) string { return "c" + strconv.Itoa(id) }
|
||||||
|
|
||||||
// EnsureForwardRules makes sure iptables FORWARD chain allows LXC bridge traffic
|
// EnsureForwardRules makes sure iptables FORWARD chain allows bridge traffic.
|
||||||
func EnsureForwardRules() {
|
func EnsureForwardRules(bridge string) {
|
||||||
|
if bridge == "" {
|
||||||
|
bridge = "lxcbr0"
|
||||||
|
}
|
||||||
rules := [][]string{
|
rules := [][]string{
|
||||||
{"-A", "FORWARD", "-i", "lxcbr0", "-j", "ACCEPT"},
|
{"-i", bridge, "-j", "ACCEPT"},
|
||||||
{"-A", "FORWARD", "-o", "lxcbr0", "-j", "ACCEPT"},
|
{"-o", bridge, "-j", "ACCEPT"},
|
||||||
{"-A", "FORWARD", "-i", "lxcbr0", "-o", "lxcbr0", "-j", "ACCEPT"},
|
{"-i", bridge, "-o", bridge, "-j", "ACCEPT"},
|
||||||
}
|
}
|
||||||
for _, args := range rules {
|
for _, args := range rules {
|
||||||
checkArgs := append([]string{"-C", "FORWARD"}, args[2:]...)
|
for {
|
||||||
if exec.Command("iptables", checkArgs...).Run() != nil {
|
deleteArgs := append([]string{"-D", "FORWARD"}, args...)
|
||||||
exec.Command("iptables", args...).Run()
|
if exec.Command("iptables", deleteArgs...).Run() != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
insertArgs := append([]string{"-I", "FORWARD", "1"}, args...)
|
||||||
|
exec.Command("iptables", insertArgs...).Run()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,12 +174,24 @@ func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapp
|
|||||||
if pm.HostPort <= 0 {
|
if pm.HostPort <= 0 {
|
||||||
pm.HostPort = pm.ContainerPort
|
pm.HostPort = pm.ContainerPort
|
||||||
}
|
}
|
||||||
|
// Check current container's own mappings
|
||||||
for i, existing := range c.PortMappings {
|
for i, existing := range c.PortMappings {
|
||||||
if i == skipIndex {
|
if i == skipIndex {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if existing.HostPort == pm.HostPort && existing.Protocol == pm.Protocol {
|
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
|
return pm, nil
|
||||||
@@ -177,10 +202,20 @@ func allocateDefaultEqualPorts(c *config.Container, count int) []int {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
used := map[int]bool{}
|
used := map[int]bool{}
|
||||||
|
// Mark current container's ports
|
||||||
for _, pm := range c.PortMappings {
|
for _, pm := range c.PortMappings {
|
||||||
used[pm.HostPort] = true
|
used[pm.HostPort] = true
|
||||||
used[pm.ContainerPort] = 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)
|
ports := make([]int, 0, count)
|
||||||
next := 20000
|
next := 20000
|
||||||
for len(ports) < count {
|
for len(ports) < count {
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotat
|
|||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
snapshotID := fmt.Sprintf("snap-%d-%s", id, now.Format("20060102150405-000000000"))
|
snapshotID := fmt.Sprintf("snap-%d-%s", id, now.Format("20060102150405-000000000"))
|
||||||
snapshotDir := filepath.Join(snapshotBaseDir(), lxcName, snapshotID)
|
// Use container ID instead of lxcName to avoid collision when containers are recreated
|
||||||
|
snapshotDir := filepath.Join(snapshotBaseDir(), strconv.Itoa(id), snapshotID)
|
||||||
if err := safePathUnder(snapshotDir, snapshotBaseDir()); err != nil {
|
if err := safePathUnder(snapshotDir, snapshotBaseDir()); err != nil {
|
||||||
return config.Snapshot{}, err
|
return config.Snapshot{}, err
|
||||||
}
|
}
|
||||||
@@ -206,7 +207,7 @@ func (m *Manager) runDueSnapshotSchedules() {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
containers := append([]config.Container(nil), config.AppConfig.Containers...)
|
containers := append([]config.Container(nil), config.AppConfig.Containers...)
|
||||||
for _, c := range containers {
|
for _, c := range containers {
|
||||||
if !c.SnapshotScheduleEnabled {
|
if c.IsKVM() || !c.SnapshotScheduleEnabled {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
nextRun, err := time.Parse(time.RFC3339, c.SnapshotScheduleNextRun)
|
nextRun, err := time.Parse(time.RFC3339, c.SnapshotScheduleNextRun)
|
||||||
|
|||||||
@@ -7,11 +7,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"clicd/internal/api"
|
"clicd/internal/api"
|
||||||
"clicd/internal/config"
|
"clicd/internal/config"
|
||||||
"clicd/internal/lxc"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// webFS holds embedded frontend files
|
// webFS holds embedded frontend files
|
||||||
@@ -90,9 +88,6 @@ func setupRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots)))
|
mux.HandleFunc("/api/snapshots", corsMiddleware(api.AdminMiddleware(api.HandleSnapshots)))
|
||||||
mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting)))
|
mux.HandleFunc("/api/routing", corsMiddleware(api.AdminMiddleware(api.HandleRouting)))
|
||||||
mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status)))
|
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.SubUserMiddleware(api.HandleTasks))))
|
||||||
mux.HandleFunc("/api/tasks/", corsMiddleware(api.AuthMiddleware(api.AdminMiddleware(api.HandleTaskDelete))))
|
mux.HandleFunc("/api/tasks/", corsMiddleware(api.AuthMiddleware(api.AdminMiddleware(api.HandleTaskDelete))))
|
||||||
mux.HandleFunc("/api/batch-create", corsMiddleware(api.AdminMiddleware(api.HandleBatchCreate)))
|
mux.HandleFunc("/api/batch-create", corsMiddleware(api.AdminMiddleware(api.HandleBatchCreate)))
|
||||||
@@ -100,6 +95,8 @@ func setupRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/sub-user/create", corsMiddleware(api.AdminMiddleware(api.HandleSubUserCreate)))
|
mux.HandleFunc("/api/sub-user/create", corsMiddleware(api.AdminMiddleware(api.HandleSubUserCreate)))
|
||||||
mux.HandleFunc("/api/sub-user/login", corsMiddleware(api.HandleSubUserLogin))
|
mux.HandleFunc("/api/sub-user/login", corsMiddleware(api.HandleSubUserLogin))
|
||||||
mux.HandleFunc("/api/sub-user/access", corsMiddleware(api.HandleSubUserAccessCode))
|
mux.HandleFunc("/api/sub-user/access", corsMiddleware(api.HandleSubUserAccessCode))
|
||||||
|
mux.HandleFunc("/api/sub-users", corsMiddleware(api.AdminMiddleware(api.HandleSubUserList)))
|
||||||
|
mux.HandleFunc("/api/sub-users/", corsMiddleware(api.AdminMiddleware(api.HandleSubUserAction)))
|
||||||
mux.HandleFunc("/api/audit-logs", corsMiddleware(api.AdminMiddleware(api.HandleAuditLogs)))
|
mux.HandleFunc("/api/audit-logs", corsMiddleware(api.AdminMiddleware(api.HandleAuditLogs)))
|
||||||
mux.HandleFunc("/api/security/alerts", corsMiddleware(api.AdminMiddleware(api.HandleSecurityAlerts)))
|
mux.HandleFunc("/api/security/alerts", corsMiddleware(api.AdminMiddleware(api.HandleSecurityAlerts)))
|
||||||
mux.HandleFunc("/api/security/check", corsMiddleware(api.AdminMiddleware(api.HandleSecurityCheck)))
|
mux.HandleFunc("/api/security/check", corsMiddleware(api.AdminMiddleware(api.HandleSecurityCheck)))
|
||||||
@@ -107,6 +104,8 @@ func setupRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/security/summary", corsMiddleware(api.AdminMiddleware(api.HandleContainerSecuritySummary)))
|
mux.HandleFunc("/api/security/summary", corsMiddleware(api.AdminMiddleware(api.HandleContainerSecuritySummary)))
|
||||||
mux.HandleFunc("/api/ssh-ticket", corsMiddleware(api.AuthMiddleware(api.HandleWebSSHTicket)))
|
mux.HandleFunc("/api/ssh-ticket", corsMiddleware(api.AuthMiddleware(api.HandleWebSSHTicket)))
|
||||||
mux.HandleFunc("/api/ssh", api.HandleWebSSH) // WebSocket
|
mux.HandleFunc("/api/ssh", api.HandleWebSSH) // WebSocket
|
||||||
|
mux.HandleFunc("/api/vnc-ticket", corsMiddleware(api.AuthMiddleware(api.HandleVNCTicket)))
|
||||||
|
mux.HandleFunc("/api/vnc", api.HandleVNCProxy) // WebSocket
|
||||||
|
|
||||||
// API Key management
|
// API Key management
|
||||||
mux.HandleFunc("/api/api-keys", corsMiddleware(api.AdminMiddleware(api.HandleApiKeys)))
|
mux.HandleFunc("/api/api-keys", corsMiddleware(api.AdminMiddleware(api.HandleApiKeys)))
|
||||||
@@ -149,7 +148,6 @@ func setupRoutes(mux *http.ServeMux) {
|
|||||||
func Run() error {
|
func Run() error {
|
||||||
// Use embedded frontend files
|
// Use embedded frontend files
|
||||||
webFS = GetEmbeddedFS()
|
webFS = GetEmbeddedFS()
|
||||||
startExpiryMonitor()
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
setupRoutes(mux)
|
setupRoutes(mux)
|
||||||
@@ -165,16 +163,3 @@ func Run() error {
|
|||||||
|
|
||||||
return server.ListenAndServe()
|
return server.ListenAndServe()
|
||||||
}
|
}
|
||||||
|
|
||||||
func startExpiryMonitor() {
|
|
||||||
manager := lxc.NewManager()
|
|
||||||
go func() {
|
|
||||||
manager.StopExpiredContainers(time.Now())
|
|
||||||
|
|
||||||
ticker := time.NewTicker(time.Minute)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for now := range ticker.C {
|
|
||||||
manager.StopExpiredContainers(now)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package version
|
package version
|
||||||
|
|
||||||
var (
|
var (
|
||||||
Version = "1.0.7"
|
Version = "1.0.13"
|
||||||
Repo = "MengMengCode/CLICD"
|
Repo = "MengMengCode/CLICD"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,3 +13,9 @@ func Current() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+13
-5
@@ -9,6 +9,7 @@ import (
|
|||||||
"clicd/internal/api"
|
"clicd/internal/api"
|
||||||
"clicd/internal/cli"
|
"clicd/internal/cli"
|
||||||
"clicd/internal/config"
|
"clicd/internal/config"
|
||||||
|
"clicd/internal/kvm"
|
||||||
"clicd/internal/lxc"
|
"clicd/internal/lxc"
|
||||||
"clicd/internal/server"
|
"clicd/internal/server"
|
||||||
|
|
||||||
@@ -50,18 +51,25 @@ func main() {
|
|||||||
// Start security scanner
|
// Start security scanner
|
||||||
api.InitScanner()
|
api.InitScanner()
|
||||||
|
|
||||||
// Ensure iptables FORWARD rules allow LXC traffic
|
// Ensure iptables FORWARD rules allow managed bridge traffic.
|
||||||
lxc.EnsureForwardRules()
|
lxc.EnsureForwardRules("lxcbr0")
|
||||||
|
lxc.EnsureForwardRules("virbr0")
|
||||||
|
|
||||||
// Start expiry scanner (stops expired containers every 30s)
|
// Start expiry scanners (stops expired/over-traffic workloads every 30s)
|
||||||
manager := lxc.NewManager()
|
manager := lxc.NewManager()
|
||||||
|
kvmManager := kvm.NewManager()
|
||||||
manager.StartExpiryScanner()
|
manager.StartExpiryScanner()
|
||||||
|
kvmManager.StartExpiryScanner()
|
||||||
|
|
||||||
// Start usage monitor (computes CPU/network/disk rates every 5s)
|
// Start usage monitors (computes CPU/network/disk rates every 5s)
|
||||||
manager.StartUsageMonitor()
|
manager.StartUsageMonitor()
|
||||||
|
kvmManager.StartUsageMonitor()
|
||||||
|
kvmManager.StartNetworkSyncMonitor()
|
||||||
|
kvmManager.StartIPv6Guard()
|
||||||
|
|
||||||
// Start scheduled snapshot scanner.
|
// Start scheduled snapshot scanners.
|
||||||
manager.StartSnapshotScheduler()
|
manager.StartSnapshotScheduler()
|
||||||
|
kvmManager.StartSnapshotScheduler()
|
||||||
|
|
||||||
// Clean up stale container configs (LXC dir was deleted but config remains)
|
// Clean up stale container configs (LXC dir was deleted but config remains)
|
||||||
config.CleanStaleContainers()
|
config.CleanStaleContainers()
|
||||||
|
|||||||
Generated
+7
@@ -8,6 +8,7 @@
|
|||||||
"name": "clicd-frontend",
|
"name": "clicd-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@novnc/novnc": "1.6.0",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
@@ -801,6 +802,12 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@novnc/novnc": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-CJrmdSe9Yt2ZbLsJpVFoVkEu0KICEvnr3njW25Nz0jodaiFJtg8AYLGZogRYy0/N5HUWkGUsCmegKXYBSqwygw==",
|
||||||
|
"license": "MPL-2.0"
|
||||||
|
},
|
||||||
"node_modules/@remix-run/router": {
|
"node_modules/@remix-run/router": {
|
||||||
"version": "1.23.3",
|
"version": "1.23.3",
|
||||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
|
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@novnc/novnc": "1.6.0",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Login from './pages/Login'
|
|||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
import Containers from './pages/Containers'
|
import Containers from './pages/Containers'
|
||||||
import ContainerDetail from './pages/ContainerDetail'
|
import ContainerDetail from './pages/ContainerDetail'
|
||||||
import Oversell from './pages/Oversell'
|
|
||||||
import Security from './pages/Security'
|
import Security from './pages/Security'
|
||||||
import AuditLogs from './pages/AuditLogs'
|
import AuditLogs from './pages/AuditLogs'
|
||||||
import ApiIntegration from './pages/ApiIntegration'
|
import ApiIntegration from './pages/ApiIntegration'
|
||||||
@@ -12,6 +12,7 @@ import Settings from './pages/Settings'
|
|||||||
import ImageManagement from './pages/ImageManagement'
|
import ImageManagement from './pages/ImageManagement'
|
||||||
import Snapshots from './pages/Snapshots'
|
import Snapshots from './pages/Snapshots'
|
||||||
import Routing from './pages/Routing'
|
import Routing from './pages/Routing'
|
||||||
|
import SubUserManagement from './pages/SubUserManagement'
|
||||||
import Layout from './components/Layout'
|
import Layout from './components/Layout'
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
@@ -57,12 +58,13 @@ function App() {
|
|||||||
<Route path="containers" element={<Containers />} />
|
<Route path="containers" element={<Containers />} />
|
||||||
<Route path="images" element={<ImageManagement />} />
|
<Route path="images" element={<ImageManagement />} />
|
||||||
<Route path="container/:id" element={<ContainerDetail />} />
|
<Route path="container/:id" element={<ContainerDetail />} />
|
||||||
<Route path="oversell" element={<Oversell />} />
|
|
||||||
<Route path="security" element={<Security />} />
|
<Route path="security" element={<Security />} />
|
||||||
<Route path="snapshots" element={<Snapshots />} />
|
<Route path="snapshots" element={<Snapshots />} />
|
||||||
<Route path="routing" element={<Routing />} />
|
<Route path="routing" element={<Routing />} />
|
||||||
<Route path="audit-logs" element={<AuditLogs />} />
|
<Route path="audit-logs" element={<AuditLogs />} />
|
||||||
<Route path="api-integration" element={<ApiIntegration />} />
|
<Route path="api-integration" element={<ApiIntegration />} />
|
||||||
|
<Route path="sub-users" element={<SubUserManagement />} />
|
||||||
<Route path="settings" element={<Settings />} />
|
<Route path="settings" element={<Settings />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ interface CreateContainerModalProps {
|
|||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSuccess: (containers: CreateContainerRequest[]) => void | Promise<void>
|
onSuccess: (containers: CreateContainerRequest[]) => void | Promise<void>
|
||||||
|
existingNames?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultForm: CreateContainerRequest = {
|
const defaultForm: CreateContainerRequest = {
|
||||||
name: '',
|
name: '',
|
||||||
|
virtualization: 'lxc',
|
||||||
template_id: '',
|
template_id: '',
|
||||||
vcpu: 1,
|
vcpu: 1,
|
||||||
cpu_percent: 100,
|
cpu_percent: 100,
|
||||||
@@ -24,12 +26,12 @@ const defaultForm: CreateContainerRequest = {
|
|||||||
io_speed_mbps: 0,
|
io_speed_mbps: 0,
|
||||||
extra_ports: [],
|
extra_ports: [],
|
||||||
port_mapping_count: 2,
|
port_mapping_count: 2,
|
||||||
snapshot_limit: 3,
|
snapshot_limit: 1,
|
||||||
assign_ipv6: false,
|
assign_ipv6: false,
|
||||||
expires_at: '',
|
expires_at: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CreateContainerModal({ isOpen, onClose, onSuccess }: CreateContainerModalProps) {
|
export default function CreateContainerModal({ isOpen, onClose, onSuccess, existingNames = [] }: CreateContainerModalProps) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const [templates, setTemplates] = useState<Template[]>([])
|
const [templates, setTemplates] = useState<Template[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -37,17 +39,19 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
|
|||||||
const [form, setForm] = useState<CreateContainerRequest>(defaultForm)
|
const [form, setForm] = useState<CreateContainerRequest>(defaultForm)
|
||||||
const [hostInfo, setHostInfo] = useState<HostInfo | null>(null)
|
const [hostInfo, setHostInfo] = useState<HostInfo | null>(null)
|
||||||
const [ipv6Status, setIPv6Status] = useState<IPv6Status | null>(null)
|
const [ipv6Status, setIPv6Status] = useState<IPv6Status | null>(null)
|
||||||
|
const [nameError, setNameError] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
|
|
||||||
getEnabledImages()
|
getEnabledImages(form.virtualization)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const data = res.data.data || []
|
const data = res.data.data || []
|
||||||
setTemplates(data)
|
setTemplates(data)
|
||||||
if (data.length > 0) {
|
setForm((prev) => {
|
||||||
setForm((prev) => ({ ...prev, template_id: prev.template_id || data[0].id }))
|
const templateID = data.some((item) => item.id === prev.template_id) ? prev.template_id : (data[0]?.id || '')
|
||||||
}
|
return applyTemplateDefaults({ ...prev, template_id: templateID })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
|
|
||||||
@@ -67,13 +71,14 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
|
|||||||
getHostInfo()
|
getHostInfo()
|
||||||
.then((res) => setHostInfo(res.data.data || null))
|
.then((res) => setHostInfo(res.data.data || null))
|
||||||
.catch(() => setHostInfo(null))
|
.catch(() => setHostInfo(null))
|
||||||
}, [isOpen])
|
}, [isOpen, form.virtualization])
|
||||||
|
|
||||||
const ipv6Available = !!ipv6Status?.available
|
const ipv6Available = !!ipv6Status?.available
|
||||||
const ipv6Prefix = ipv6Status?.prefixes?.[0]?.prefix || ''
|
const ipv6Prefix = ipv6Status?.prefixes?.[0]?.prefix || ''
|
||||||
const maxVCPU = hostInfo?.cpu.cores || 64
|
const maxVCPU = hostInfo?.cpu.cores || 64
|
||||||
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
|
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
|
||||||
const maxDiskGB = hostInfo?.disk.total_gb ? Math.max(1, Math.floor(hostInfo.disk.total_gb)) : undefined
|
const maxDiskGB = hostInfo?.disk.total_gb ? Math.max(1, Math.floor(hostInfo.disk.total_gb)) : undefined
|
||||||
|
const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB)
|
||||||
|
|
||||||
const autoPorts = useMemo(() => {
|
const autoPorts = useMemo(() => {
|
||||||
const count = Math.max(2, form.port_mapping_count)
|
const count = Math.max(2, form.port_mapping_count)
|
||||||
@@ -83,18 +88,52 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
|
|||||||
// 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
|
||||||
|
|
||||||
|
// Find next available batch index to avoid name conflicts
|
||||||
|
const batchStartIndex = useMemo(() => {
|
||||||
|
if (batchCount <= 1 || !form.name) return 1
|
||||||
|
const prefix = `${form.name}-`
|
||||||
|
let maxIdx = 0
|
||||||
|
for (const existing of existingNames) {
|
||||||
|
if (existing.startsWith(prefix)) {
|
||||||
|
const suffix = existing.slice(prefix.length)
|
||||||
|
const idx = parseInt(suffix, 10)
|
||||||
|
if (!isNaN(idx) && idx > maxIdx) {
|
||||||
|
maxIdx = idx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxIdx + 1
|
||||||
|
}, [form.name, batchCount, existingNames])
|
||||||
|
|
||||||
|
const handleNameChange = (value: string) => {
|
||||||
|
setForm({ ...form, name: value })
|
||||||
|
if (/\s/.test(value)) {
|
||||||
|
setNameError('容器名称不能包含空格')
|
||||||
|
} else if (value && existingNames.includes(value) && batchCount === 1) {
|
||||||
|
setNameError('该容器名称已存在')
|
||||||
|
} else {
|
||||||
|
setNameError('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!form.name || !form.template_id) {
|
if (!form.name || !form.template_id) {
|
||||||
dialog.alert('提示', '请填写容器名称并选择系统模板')
|
dialog.alert('提示', '请填写容器名称并选择系统模板')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const boundedForm = clampCreateForm(form, maxVCPU, maxRAMMB, maxDiskGB)
|
if (Object.keys(resourceErrors).length > 0) {
|
||||||
|
dialog.alert('资源配置有误', '请按红色提示修改 vCPU、内存或磁盘配置')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const boundedForm = normalizeCreateForm(form)
|
||||||
|
|
||||||
// Build batch of containers
|
// Build batch of containers
|
||||||
const containers: CreateContainerRequest[] = []
|
const containers: CreateContainerRequest[] = []
|
||||||
|
const startIndex = batchStartIndex
|
||||||
for (let i = 0; i < batchCount; i++) {
|
for (let i = 0; i < batchCount; i++) {
|
||||||
const name = batchCount > 1 ? `${boundedForm.name}-${i + 1}` : boundedForm.name
|
const name = batchCount > 1 ? `${boundedForm.name}-${startIndex + i}` : boundedForm.name
|
||||||
containers.push({
|
containers.push({
|
||||||
...boundedForm,
|
...boundedForm,
|
||||||
name,
|
name,
|
||||||
@@ -137,27 +176,47 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(event) => setForm({ ...form, name: event.target.value })}
|
onChange={(event) => handleNameChange(event.target.value)}
|
||||||
className={inputClass}
|
className={`${inputClass} ${nameError ? 'border-red-400 focus:ring-red-400 focus:border-red-400' : ''}`}
|
||||||
placeholder="my-container"
|
placeholder="my-container"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
{nameError && <p className="text-xs text-red-500 mt-1">{nameError}</p>}
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="批量创建数量">
|
<Field label="批量创建数量">
|
||||||
<NumberInput value={batchCount} min={1} max={50} onChange={(value) => setBatchCount(Math.max(1, value || 1))} />
|
<NumberInput value={batchCount} min={1} max={50} onChange={(value) => setBatchCount(Math.max(1, value || 1))} />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
{batchCount > 1 && <p className="text-xs text-gray-400">将创建 {batchCount} 个容器:{form.name}-1 至 {form.name}-{batchCount}</p>}
|
{batchCount > 1 && <p className="text-xs text-gray-400">将创建 {batchCount} 个容器:{form.name}-{batchStartIndex} 至 {form.name}-{batchStartIndex + batchCount - 1}</p>}
|
||||||
|
|
||||||
|
<Field label="虚拟化架构">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'lxc', template_id: '' }))}
|
||||||
|
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors ${form.virtualization === 'lxc' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
||||||
|
>
|
||||||
|
LXC 容器
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'kvm', template_id: '' }))}
|
||||||
|
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors ${form.virtualization === 'kvm' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
||||||
|
>
|
||||||
|
KVM 虚拟机
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<Field label="系统模板">
|
<Field label="系统模板">
|
||||||
{templates.length === 0 ? (
|
{templates.length === 0 ? (
|
||||||
<div className="text-sm text-amber-600 bg-amber-50 border border-amber-200 rounded-md px-3 py-2">
|
<div className="text-sm text-amber-600 bg-amber-50 border border-amber-200 rounded-md px-3 py-2">
|
||||||
暂无可用的系统镜像,请先在「镜像管理」中下载镜像模板。
|
暂无可用的{form.virtualization === 'kvm' ? ' KVM' : ' LXC'}系统镜像,请先在「镜像管理」中下载镜像模板。
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<select
|
<select
|
||||||
value={form.template_id}
|
value={form.template_id}
|
||||||
onChange={(event) => setForm({ ...form, template_id: event.target.value })}
|
onChange={(event) => setForm(applyTemplateDefaults({ ...form, template_id: event.target.value }))}
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
>
|
>
|
||||||
{templates.map((template) => (
|
{templates.map((template) => (
|
||||||
@@ -167,6 +226,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<label className={`flex items-start gap-3 rounded-md border px-3 py-2 text-sm ${ipv6Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
|
<label className={`flex items-start gap-3 rounded-md border px-3 py-2 text-sm ${ipv6Available ? 'border-gray-200 bg-white' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
|
||||||
@@ -187,16 +247,39 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Field label="vCPU">
|
<Field label="vCPU">
|
||||||
<NumberInput value={form.vcpu} min={0.25} max={maxVCPU} step={0.25} onChange={(value) => setForm({ ...form, vcpu: clampVCPU(value, maxVCPU) })} />
|
<NumberInput
|
||||||
|
value={form.vcpu}
|
||||||
|
min={form.virtualization === 'kvm' ? 1 : 0.25}
|
||||||
|
max={maxVCPU}
|
||||||
|
step={form.virtualization === 'kvm' ? 1 : 0.25}
|
||||||
|
invalid={!!resourceErrors.vcpu}
|
||||||
|
onChange={(value) => setForm({ ...form, vcpu: value })}
|
||||||
|
/>
|
||||||
|
{resourceErrors.vcpu && <p className="mt-1 text-xs text-red-500">{resourceErrors.vcpu}</p>}
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="内存 (MB)">
|
<Field label="内存 (MB)">
|
||||||
<NumberInput value={form.ram_mb} min={128} max={maxRAMMB} step={128} onChange={(value) => setForm({ ...form, ram_mb: clampInt(value, 128, maxRAMMB, 512) })} />
|
<NumberInput
|
||||||
|
value={form.ram_mb}
|
||||||
|
min={128}
|
||||||
|
max={maxRAMMB}
|
||||||
|
step={128}
|
||||||
|
invalid={!!resourceErrors.ram_mb}
|
||||||
|
onChange={(value) => setForm({ ...form, ram_mb: value })}
|
||||||
|
/>
|
||||||
|
{resourceErrors.ram_mb && <p className="mt-1 text-xs text-red-500">{resourceErrors.ram_mb}</p>}
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<Field label="磁盘 (GB)">
|
<Field label="磁盘 (GB)">
|
||||||
<NumberInput value={form.disk_gb} min={1} max={maxDiskGB} onChange={(value) => setForm({ ...form, disk_gb: clampInt(value, 1, maxDiskGB, 10) })} />
|
<NumberInput
|
||||||
|
value={form.disk_gb}
|
||||||
|
min={1}
|
||||||
|
max={maxDiskGB}
|
||||||
|
invalid={!!resourceErrors.disk_gb}
|
||||||
|
onChange={(value) => setForm({ ...form, disk_gb: value })}
|
||||||
|
/>
|
||||||
|
{resourceErrors.disk_gb && <p className="mt-1 text-xs text-red-500">{resourceErrors.disk_gb}</p>}
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="带宽 (Mbps)">
|
<Field label="带宽 (Mbps)">
|
||||||
<NumberInput value={form.network_bw_mbps} min={0} onChange={(value) => setForm({ ...form, network_bw_mbps: value })} />
|
<NumberInput value={form.network_bw_mbps} min={0} onChange={(value) => setForm({ ...form, network_bw_mbps: value })} />
|
||||||
@@ -245,7 +328,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
|
|||||||
/>
|
/>
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
<div className="mt-2 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">
|
||||||
SSH: {sshPortPreview} -> 22
|
{isWindowsTemplate(form.template_id) ? 'RDP' : 'SSH'}: {sshPortPreview} -> {isWindowsTemplate(form.template_id) ? 3389 : 22}
|
||||||
</span>
|
</span>
|
||||||
{autoPorts.map((port) => (
|
{autoPorts.map((port) => (
|
||||||
<span key={port} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
|
<span key={port} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
|
||||||
@@ -310,44 +393,115 @@ function NumberInput({
|
|||||||
min,
|
min,
|
||||||
max,
|
max,
|
||||||
step,
|
step,
|
||||||
|
invalid,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
value: number
|
value: number
|
||||||
min?: number
|
min?: number
|
||||||
max?: number
|
max?: number
|
||||||
step?: number
|
step?: number
|
||||||
|
invalid?: boolean
|
||||||
onChange: (value: number) => void
|
onChange: (value: number) => void
|
||||||
}) {
|
}) {
|
||||||
|
const [draft, setDraft] = useState(Number.isFinite(value) ? String(value) : '')
|
||||||
|
const [focused, setFocused] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focused) {
|
||||||
|
setDraft(Number.isFinite(value) ? String(value) : '')
|
||||||
|
}
|
||||||
|
}, [focused, value])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="text"
|
||||||
value={value}
|
inputMode={step && !Number.isInteger(step) ? 'decimal' : 'numeric'}
|
||||||
min={min}
|
value={draft}
|
||||||
max={max}
|
onFocus={() => setFocused(true)}
|
||||||
step={step}
|
onBlur={() => {
|
||||||
|
setFocused(false)
|
||||||
|
setDraft(Number.isFinite(value) ? String(value) : '')
|
||||||
|
}}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const raw = event.target.value
|
const raw = event.target.value
|
||||||
const value = step && !Number.isInteger(step) ? parseFloat(raw) : parseInt(raw, 10)
|
setDraft(raw)
|
||||||
onChange(value)
|
const next = step && !Number.isInteger(step) ? parseFloat(raw) : parseInt(raw, 10)
|
||||||
|
onChange(next)
|
||||||
}}
|
}}
|
||||||
className={inputClass}
|
aria-invalid={invalid || undefined}
|
||||||
|
data-min={min}
|
||||||
|
data-max={max}
|
||||||
|
data-step={step}
|
||||||
|
className={`${inputClass} ${invalid ? 'border-red-400 focus:border-red-400 focus:ring-red-400' : ''}`}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampCreateForm(form: CreateContainerRequest, maxVCPU: number, maxRAMMB?: number, maxDiskGB?: number): CreateContainerRequest {
|
function validateResourceInputs(form: CreateContainerRequest, maxVCPU: number, maxRAMMB?: number, maxDiskGB?: number) {
|
||||||
|
const errors: Partial<Record<'vcpu' | 'ram_mb' | 'disk_gb', string>> = {}
|
||||||
|
const windows = isWindowsTemplate(form.template_id)
|
||||||
|
const minVCPU = windows ? 2 : (form.virtualization === 'kvm' ? 1 : 0.25)
|
||||||
|
const minRAMMB = windows ? 2048 : 128
|
||||||
|
const minDiskGB = windows ? 30 : 1
|
||||||
|
|
||||||
|
if (!Number.isFinite(form.vcpu)) {
|
||||||
|
errors.vcpu = '请输入 vCPU'
|
||||||
|
} else if (form.vcpu < minVCPU) {
|
||||||
|
errors.vcpu = `不能小于 ${minVCPU} 核`
|
||||||
|
} else if (form.vcpu > maxVCPU) {
|
||||||
|
errors.vcpu = `不能大于 ${maxVCPU} 核`
|
||||||
|
} else if (form.virtualization === 'kvm' && form.vcpu !== Math.round(form.vcpu)) {
|
||||||
|
errors.vcpu = 'KVM vCPU 必须是整数'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(form.ram_mb)) {
|
||||||
|
errors.ram_mb = '请输入内存'
|
||||||
|
} else if (form.ram_mb < minRAMMB) {
|
||||||
|
errors.ram_mb = `不能小于 ${minRAMMB} MB`
|
||||||
|
} else if (maxRAMMB && form.ram_mb > maxRAMMB) {
|
||||||
|
errors.ram_mb = `不能大于 ${maxRAMMB} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(form.disk_gb)) {
|
||||||
|
errors.disk_gb = '请输入磁盘'
|
||||||
|
} else if (form.disk_gb < minDiskGB) {
|
||||||
|
errors.disk_gb = `不能小于 ${minDiskGB} GB`
|
||||||
|
} else if (maxDiskGB && form.disk_gb > maxDiskGB) {
|
||||||
|
errors.disk_gb = `不能大于 ${maxDiskGB} GB`
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCreateForm(form: CreateContainerRequest): CreateContainerRequest {
|
||||||
|
const normalized = applyTemplateDefaults(form)
|
||||||
return {
|
return {
|
||||||
...form,
|
...normalized,
|
||||||
vcpu: clampVCPU(form.vcpu, maxVCPU),
|
vcpu: normalized.virtualization === 'kvm' ? Math.round(normalized.vcpu) : normalizeLXCvCPU(normalized.vcpu),
|
||||||
ram_mb: clampInt(form.ram_mb, 128, maxRAMMB, 512),
|
ram_mb: Math.round(normalized.ram_mb),
|
||||||
disk_gb: clampInt(form.disk_gb, 1, maxDiskGB, 10),
|
disk_gb: Math.round(normalized.disk_gb),
|
||||||
snapshot_limit: clampInt(form.snapshot_limit, 1, undefined, 3),
|
snapshot_limit: clampInt(normalized.snapshot_limit, 1, undefined, 3),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampVCPU(value: number, max: number) {
|
function applyTemplateDefaults(form: CreateContainerRequest): CreateContainerRequest {
|
||||||
|
if (!isWindowsTemplate(form.template_id)) return form
|
||||||
|
return {
|
||||||
|
...form,
|
||||||
|
virtualization: 'kvm',
|
||||||
|
vcpu: Math.max(2, Math.round(Number.isFinite(form.vcpu) ? form.vcpu : 2)),
|
||||||
|
ram_mb: Math.max(2048, Math.round(Number.isFinite(form.ram_mb) ? form.ram_mb : 2048)),
|
||||||
|
disk_gb: Math.max(30, Math.round(Number.isFinite(form.disk_gb) ? form.disk_gb : 30)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWindowsTemplate(templateID: string) {
|
||||||
|
return templateID.toLowerCase().includes('windows')
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLXCvCPU(value: number) {
|
||||||
const rounded = Math.round((Number.isFinite(value) ? value : 1) * 4) / 4
|
const rounded = Math.round((Number.isFinite(value) ? value : 1) * 4) / 4
|
||||||
return Number(Math.min(Math.max(rounded, 0.25), max).toFixed(2))
|
return Number(rounded.toFixed(2))
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampInt(value: number, min: number, max?: number, fallback = min) {
|
function clampInt(value: number, min: number, max?: number, fallback = min) {
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ export function RingStat({ value, max = 100, label, subLabel, size = 120, stroke
|
|||||||
|
|
||||||
const radius = (size - strokeWidth) / 2
|
const radius = (size - strokeWidth) / 2
|
||||||
const circumference = radius * 2 * Math.PI
|
const circumference = radius * 2 * Math.PI
|
||||||
const percentage = Math.min(Math.max(value / max * 100, 0), 100)
|
const percentage = max === Infinity ? Math.max(value, 0) : Math.min(Math.max(value / max * 100, 0), 100)
|
||||||
const strokeDashoffset = circumference - (percentage / 100) * circumference
|
const strokeDashoffset = circumference - (Math.min(percentage, 100) / 100) * circumference
|
||||||
|
|
||||||
const bgStroke = isDark ? '#374151' : '#f3f4f6'
|
const bgStroke = isDark ? '#374151' : '#f3f4f6'
|
||||||
const progressStroke = isDark ? '#f9fafb' : '#000000'
|
const progressStroke = isDark ? '#f9fafb' : '#000000'
|
||||||
@@ -51,7 +51,7 @@ export function RingStat({ value, max = 100, label, subLabel, size = 120, stroke
|
|||||||
</svg>
|
</svg>
|
||||||
{/* Center value */}
|
{/* Center value */}
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
<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>
|
</div>
|
||||||
<div className="mt-2 text-center">
|
<div className="mt-2 text-center">
|
||||||
@@ -73,7 +73,6 @@ interface RingStatsProps {
|
|||||||
swapUsed?: number
|
swapUsed?: number
|
||||||
swapTotal?: number
|
swapTotal?: number
|
||||||
loadPercent: number
|
loadPercent: number
|
||||||
loadStatus: string
|
|
||||||
diskPercent: number
|
diskPercent: number
|
||||||
diskUsed: number
|
diskUsed: number
|
||||||
diskTotal: number
|
diskTotal: number
|
||||||
@@ -90,7 +89,6 @@ export default function RingStats({
|
|||||||
swapUsed = 0,
|
swapUsed = 0,
|
||||||
swapTotal = 0,
|
swapTotal = 0,
|
||||||
loadPercent,
|
loadPercent,
|
||||||
loadStatus,
|
|
||||||
diskPercent,
|
diskPercent,
|
||||||
diskUsed,
|
diskUsed,
|
||||||
diskTotal,
|
diskTotal,
|
||||||
@@ -125,8 +123,8 @@ export default function RingStats({
|
|||||||
)}
|
)}
|
||||||
<RingStat
|
<RingStat
|
||||||
value={loadPercent}
|
value={loadPercent}
|
||||||
|
max={Infinity}
|
||||||
label="负载"
|
label="负载"
|
||||||
subLabel={loadStatus}
|
|
||||||
/>
|
/>
|
||||||
<RingStat
|
<RingStat
|
||||||
value={diskPercent}
|
value={diskPercent}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
Route,
|
Route,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Server,
|
Server,
|
||||||
Settings2,
|
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Sun,
|
Sun,
|
||||||
UserCog,
|
UserCog,
|
||||||
@@ -49,7 +49,7 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
location.pathname.startsWith('/container')
|
location.pathname.startsWith('/container')
|
||||||
|
|
||||||
const isImagesPage = location.pathname.startsWith('/images')
|
const isImagesPage = location.pathname.startsWith('/images')
|
||||||
const isOversellPage = location.pathname.startsWith('/oversell')
|
|
||||||
const isSnapshotsPage = location.pathname.startsWith('/snapshots')
|
const isSnapshotsPage = location.pathname.startsWith('/snapshots')
|
||||||
const isRoutingPage = location.pathname.startsWith('/routing')
|
const isRoutingPage = location.pathname.startsWith('/routing')
|
||||||
const isAuditLogsPage = location.pathname.startsWith('/audit-logs')
|
const isAuditLogsPage = location.pathname.startsWith('/audit-logs')
|
||||||
@@ -133,18 +133,6 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
|
|
||||||
{!isSubUser && (
|
{!isSubUser && (
|
||||||
<>
|
<>
|
||||||
<button
|
|
||||||
onClick={() => navigate('/oversell')}
|
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
|
||||||
isOversellPage
|
|
||||||
? 'bg-black text-white dark:bg-white dark:text-black'
|
|
||||||
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Settings2 className="w-4 h-4" />
|
|
||||||
{!collapsed && <span>宿主机控制</span>}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/security')}
|
onClick={() => navigate('/security')}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||||
@@ -193,6 +181,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
{!collapsed && <span>操作日志</span>}
|
{!collapsed && <span>操作日志</span>}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/sub-users')}
|
||||||
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||||
|
location.pathname.startsWith('/sub-users')
|
||||||
|
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<UserCog className="w-4 h-4" />
|
||||||
|
{!collapsed && <span>子用户管理</span>}
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/api-integration')}
|
onClick={() => navigate('/api-integration')}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Monitor, RefreshCw, Send, X } from 'lucide-react'
|
||||||
|
import RFB from '@novnc/novnc'
|
||||||
|
import { createVNCTicket, getWebVNCUrl } from '../services/api'
|
||||||
|
|
||||||
|
interface WebVNCViewerProps {
|
||||||
|
containerName: string
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerProps) {
|
||||||
|
const screenRef = useRef<HTMLDivElement>(null)
|
||||||
|
const rfbRef = useRef<RFB | null>(null)
|
||||||
|
const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected' | 'error'>('connecting')
|
||||||
|
const [errorMsg, setErrorMsg] = useState('')
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (rfbRef.current) {
|
||||||
|
rfbRef.current.disconnect()
|
||||||
|
rfbRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const connect = async () => {
|
||||||
|
const target = screenRef.current
|
||||||
|
if (!target) return
|
||||||
|
|
||||||
|
cleanup()
|
||||||
|
target.innerHTML = ''
|
||||||
|
setStatus('connecting')
|
||||||
|
setErrorMsg('')
|
||||||
|
|
||||||
|
let ticket = ''
|
||||||
|
try {
|
||||||
|
const response = await createVNCTicket(containerName)
|
||||||
|
ticket = response.data.data?.ticket || ''
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { message?: string } } }
|
||||||
|
setStatus('error')
|
||||||
|
setErrorMsg(error.response?.data?.message || 'WebVNC ticket 创建失败,请重新登录后再试')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!ticket) {
|
||||||
|
setStatus('error')
|
||||||
|
setErrorMsg('WebVNC ticket 为空,请重新登录后再试')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rfb = new RFB(target, getWebVNCUrl(containerName, ticket))
|
||||||
|
rfb.scaleViewport = true
|
||||||
|
rfb.resizeSession = false
|
||||||
|
rfb.focusOnClick = true
|
||||||
|
rfb.qualityLevel = 6
|
||||||
|
rfb.compressionLevel = 2
|
||||||
|
rfb.background = '#050505'
|
||||||
|
rfb.addEventListener('connect', () => {
|
||||||
|
setStatus('connected')
|
||||||
|
})
|
||||||
|
rfb.addEventListener('disconnect', (event) => {
|
||||||
|
const detail = (event as CustomEvent<{ clean?: boolean }>).detail
|
||||||
|
setStatus((current) => current === 'error' ? current : 'disconnected')
|
||||||
|
if (detail && detail.clean === false) {
|
||||||
|
setErrorMsg('WebVNC 连接已断开,请确认虚拟机正在运行且 VNC 控制台可用')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
rfb.addEventListener('securityfailure', () => {
|
||||||
|
setStatus('error')
|
||||||
|
setErrorMsg('VNC 安全协商失败')
|
||||||
|
})
|
||||||
|
rfb.addEventListener('credentialsrequired', () => {
|
||||||
|
setStatus('error')
|
||||||
|
setErrorMsg('当前 VNC 控制台要求密码,暂不支持自动输入')
|
||||||
|
})
|
||||||
|
rfbRef.current = rfb
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setStatus('error')
|
||||||
|
setErrorMsg('WebVNC 初始化失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setTimeout(connect, 100)
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timer)
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
}, [containerName])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden h-full flex flex-col">
|
||||||
|
<div className="flex items-center justify-between px-4 py-2.5 border-b border-gray-200 bg-gray-50 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Monitor className="w-4 h-4 text-gray-600" />
|
||||||
|
<span className="text-sm font-medium text-black">WebVNC - {containerName}</span>
|
||||||
|
{status === 'connected' && <span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700">已连接</span>}
|
||||||
|
{status === 'connecting' && <span className="text-xs px-1.5 py-0.5 rounded bg-yellow-100 text-yellow-700">连接中...</span>}
|
||||||
|
{status === 'disconnected' && <span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">已断开</span>}
|
||||||
|
{status === 'error' && <span className="text-xs px-1.5 py-0.5 rounded bg-red-100 text-red-700">连接失败</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => rfbRef.current?.sendCtrlAltDel()}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1.5 hover:bg-gray-200 rounded text-gray-500 text-xs"
|
||||||
|
title="发送 Ctrl+Alt+Del"
|
||||||
|
>
|
||||||
|
<Send className="w-3.5 h-3.5" />
|
||||||
|
Ctrl+Alt+Del
|
||||||
|
</button>
|
||||||
|
<button onClick={connect} className="p-1.5 hover:bg-gray-200 rounded text-gray-500 text-xs" title="重新连接">
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button onClick={onClose} className="p-1.5 hover:bg-gray-200 rounded text-gray-500" title="关闭">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex-1 min-h-0 bg-black overflow-hidden">
|
||||||
|
<div ref={screenRef} className="h-full w-full [&>div]:h-full [&>div]:w-full [&_canvas]:block" />
|
||||||
|
{(status === 'connecting' || status === 'error' || (status === 'disconnected' && errorMsg)) && (
|
||||||
|
<div className={`absolute inset-x-0 bottom-0 border-t px-4 py-2 text-sm ${status === 'error' ? 'border-red-900 bg-red-950 text-red-100' : 'border-gray-800 bg-gray-950 text-gray-200'}`}>
|
||||||
|
{status === 'connecting' ? '正在连接 KVM VNC 控制台...' : (errorMsg || 'WebVNC 已断开')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Vendored
+14
@@ -0,0 +1,14 @@
|
|||||||
|
declare module '@novnc/novnc' {
|
||||||
|
export default class RFB extends EventTarget {
|
||||||
|
constructor(target: HTMLElement, url: string, options?: { credentials?: Record<string, string>; shared?: boolean; repeaterID?: string; wsProtocols?: string[] })
|
||||||
|
scaleViewport: boolean
|
||||||
|
resizeSession: boolean
|
||||||
|
focusOnClick: boolean
|
||||||
|
viewOnly: boolean
|
||||||
|
qualityLevel: number
|
||||||
|
compressionLevel: number
|
||||||
|
background: string
|
||||||
|
disconnect(): void
|
||||||
|
sendCtrlAltDel(): void
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { Key, Plus, Trash2, Copy, RefreshCw, Code, X } from 'lucide-react'
|
import { Key, Plus, Trash2, Copy, RefreshCw, Code, X } from 'lucide-react'
|
||||||
import api, { APIResponse } from '../services/api'
|
import api, { APIResponse } from '../services/api'
|
||||||
|
import { copyToClipboard } from '../utils/clipboard'
|
||||||
|
|
||||||
interface ApiKeyItem {
|
interface ApiKeyItem {
|
||||||
id: string
|
id: string
|
||||||
@@ -62,21 +63,12 @@ export default function ApiIntegration() {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const copyKey = () => {
|
const copyKey = async () => {
|
||||||
try {
|
const copied = await copyToClipboard(newKey)
|
||||||
navigator.clipboard.writeText(newKey)
|
if (copied) {
|
||||||
} catch {
|
setCopiedKey(true)
|
||||||
const ta = document.createElement('textarea')
|
setTimeout(() => setCopiedKey(false), 2000)
|
||||||
ta.value = newKey
|
|
||||||
ta.style.position = 'fixed'
|
|
||||||
ta.style.left = '-9999px'
|
|
||||||
document.body.appendChild(ta)
|
|
||||||
ta.select()
|
|
||||||
document.execCommand('copy')
|
|
||||||
document.body.removeChild(ta)
|
|
||||||
}
|
}
|
||||||
setCopiedKey(true)
|
|
||||||
setTimeout(() => setCopiedKey(false), 2000)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -256,10 +248,7 @@ export default function ApiIntegration() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3 className="font-semibold text-black mb-2">超售 & 批量</h3>
|
<h3 className="font-semibold text-black mb-2">批量操作</h3>
|
||||||
<Endpoint method="POST" path="/api/oversell" desc="获取/更新超售配置" body='{"cpu_overcommit": 4, "ram_overcommit": 2, "disk_overcommit": 1, "ksm_enabled": true, "swappiness": 10}' />
|
|
||||||
<Endpoint method="POST" path="/api/oversell/reclaim" desc="触发一次内存回收" />
|
|
||||||
<Endpoint method="POST" path="/api/oversell/status" desc="超售状态" />
|
|
||||||
<Endpoint method="POST" path="/api/batch-create" desc="批量创建" body='{"containers": [{...}]}' />
|
<Endpoint method="POST" path="/api/batch-create" desc="批量创建" body='{"containers": [{...}]}' />
|
||||||
<Endpoint method="POST" path="/api/batch-action" desc="批量操作" body='{"action": "start", "containers": [1, 2, 3]}' />
|
<Endpoint method="POST" path="/api/batch-action" desc="批量操作" body='{"action": "start", "containers": [1, 2, 3]}' />
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback, type ReactNode } from 'react'
|
import { useState, useEffect, useCallback, useRef, type ReactNode } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -8,7 +8,10 @@ import {
|
|||||||
Cpu,
|
Cpu,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Key,
|
Key,
|
||||||
|
Maximize2,
|
||||||
MemoryStick,
|
MemoryStick,
|
||||||
|
Minimize2,
|
||||||
|
Monitor,
|
||||||
Network,
|
Network,
|
||||||
Pencil,
|
Pencil,
|
||||||
Play,
|
Play,
|
||||||
@@ -65,7 +68,9 @@ import {
|
|||||||
import { useDialog } from '../components/Dialog'
|
import { useDialog } from '../components/Dialog'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import WebSSHViewer from '../components/WebSSHViewer'
|
import WebSSHViewer from '../components/WebSSHViewer'
|
||||||
|
import WebVNCViewer from '../components/WebVNCViewer'
|
||||||
import { RingStat } from '../components/RingStats'
|
import { RingStat } from '../components/RingStats'
|
||||||
|
import { copyToClipboard } from '../utils/clipboard'
|
||||||
import ResourceStatsPanel, {
|
import ResourceStatsPanel, {
|
||||||
ChartPoint,
|
ChartPoint,
|
||||||
ResourceChartConfig,
|
ResourceChartConfig,
|
||||||
@@ -114,6 +119,9 @@ export default function ContainerDetail() {
|
|||||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||||
const [taskStatus, setTaskStatus] = useState('') // current task type for this container
|
const [taskStatus, setTaskStatus] = useState('') // current task type for this container
|
||||||
const [showSSH, setShowSSH] = useState(false)
|
const [showSSH, setShowSSH] = useState(false)
|
||||||
|
const [showVNC, setShowVNC] = useState(false)
|
||||||
|
const vncFullscreenRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [vncFullscreen, setVncFullscreen] = useState(false)
|
||||||
const [showNat, setShowNat] = useState(false)
|
const [showNat, setShowNat] = useState(false)
|
||||||
const [showNatAdd, setShowNatAdd] = useState(false)
|
const [showNatAdd, setShowNatAdd] = useState(false)
|
||||||
const [showExpiryEdit, setShowExpiryEdit] = useState(false)
|
const [showExpiryEdit, setShowExpiryEdit] = useState(false)
|
||||||
@@ -179,15 +187,18 @@ export default function ContainerDetail() {
|
|||||||
const appendUsagePoint = useCallback((nextUsage: ContainerUsage, currentContainer: Container | null) => {
|
const appendUsagePoint = useCallback((nextUsage: ContainerUsage, currentContainer: Container | null) => {
|
||||||
if (!containerIdentifier || !currentContainer) return
|
if (!containerIdentifier || !currentContainer) return
|
||||||
|
|
||||||
const memoryPct = currentContainer.ram_mb > 0
|
const memoryTotalBytes = nextUsage.memory_total_bytes && nextUsage.memory_total_bytes > 0
|
||||||
? (nextUsage.memory_usage_bytes / (currentContainer.ram_mb * 1024 * 1024)) * 100
|
? nextUsage.memory_total_bytes
|
||||||
|
: currentContainer.ram_mb * 1024 * 1024
|
||||||
|
const memoryPct = memoryTotalBytes > 0
|
||||||
|
? (nextUsage.memory_usage_bytes / memoryTotalBytes) * 100
|
||||||
: 0
|
: 0
|
||||||
const networkBps = (nextUsage.network_rx_bps || 0) + (nextUsage.network_tx_bps || 0)
|
const networkBps = (nextUsage.network_rx_bps || 0) + (nextUsage.network_tx_bps || 0)
|
||||||
const diskIOBps = (nextUsage.disk_read_bps || 0) + (nextUsage.disk_write_bps || 0)
|
const diskIOBps = (nextUsage.disk_read_bps || 0) + (nextUsage.disk_write_bps || 0)
|
||||||
|
|
||||||
const point: MetricPoint = {
|
const point: MetricPoint = {
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
cpu: clamp(nextUsage.cpu_usage_pct || 0),
|
cpu: clamp((nextUsage.cpu_usage_pct || 0) / (currentContainer.vcpu || 1)),
|
||||||
memory: clamp(memoryPct),
|
memory: clamp(memoryPct),
|
||||||
network: networkBps,
|
network: networkBps,
|
||||||
diskIO: diskIOBps,
|
diskIO: diskIOBps,
|
||||||
@@ -226,6 +237,28 @@ export default function ContainerDetail() {
|
|||||||
return () => window.clearInterval(timer)
|
return () => window.clearInterval(timer)
|
||||||
}, [fetchContainer])
|
}, [fetchContainer])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleFullscreenChange = () => {
|
||||||
|
setVncFullscreen(document.fullscreenElement === vncFullscreenRef.current)
|
||||||
|
}
|
||||||
|
document.addEventListener('fullscreenchange', handleFullscreenChange)
|
||||||
|
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleVNCFullscreen = async () => {
|
||||||
|
const target = vncFullscreenRef.current
|
||||||
|
if (!target) return
|
||||||
|
try {
|
||||||
|
if (document.fullscreenElement === target) {
|
||||||
|
await document.exitFullscreen()
|
||||||
|
} else {
|
||||||
|
await target.requestFullscreen()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setVncFullscreen((value) => !value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUsage()
|
fetchUsage()
|
||||||
const timer = window.setInterval(fetchUsage, 5000)
|
const timer = window.setInterval(fetchUsage, 5000)
|
||||||
@@ -277,6 +310,7 @@ export default function ContainerDetail() {
|
|||||||
case 'stop':
|
case 'stop':
|
||||||
await stopContainer(containerIdentifier)
|
await stopContainer(containerIdentifier)
|
||||||
setShowSSH(false)
|
setShowSSH(false)
|
||||||
|
setShowVNC(false)
|
||||||
break
|
break
|
||||||
case 'restart':
|
case 'restart':
|
||||||
await restartContainer(containerIdentifier)
|
await restartContainer(containerIdentifier)
|
||||||
@@ -358,7 +392,7 @@ export default function ContainerDetail() {
|
|||||||
|
|
||||||
const openReinstall = async () => {
|
const openReinstall = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getEnabledImages()
|
const res = await getEnabledImages(container?.virtualization || 'lxc')
|
||||||
if (res.data.data) {
|
if (res.data.data) {
|
||||||
setTemplates(res.data.data)
|
setTemplates(res.data.data)
|
||||||
setSelectedTemplate(res.data.data[0]?.id || '')
|
setSelectedTemplate(res.data.data[0]?.id || '')
|
||||||
@@ -389,6 +423,7 @@ export default function ContainerDetail() {
|
|||||||
await reinstallContainer(containerIdentifier, selectedTemplate)
|
await reinstallContainer(containerIdentifier, selectedTemplate)
|
||||||
setShowReinstall(false)
|
setShowReinstall(false)
|
||||||
setShowSSH(false)
|
setShowSSH(false)
|
||||||
|
setShowVNC(false)
|
||||||
await fetchContainer()
|
await fetchContainer()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Reinstall failed:', err)
|
console.error('Reinstall failed:', err)
|
||||||
@@ -619,19 +654,7 @@ export default function ContainerDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const copyText = async (text: string) => {
|
const copyText = async (text: string) => {
|
||||||
try {
|
await copyToClipboard(text)
|
||||||
await copyText(text)
|
|
||||||
} catch {
|
|
||||||
// Fallback for HTTP (non-secure context)
|
|
||||||
const ta = document.createElement('textarea')
|
|
||||||
ta.value = text
|
|
||||||
ta.style.position = 'fixed'
|
|
||||||
ta.style.left = '-9999px'
|
|
||||||
document.body.appendChild(ta)
|
|
||||||
ta.select()
|
|
||||||
document.execCommand('copy')
|
|
||||||
document.body.removeChild(ta)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -654,17 +677,25 @@ export default function ContainerDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isRunning = container.status === 'running'
|
const isRunning = container.status === 'running'
|
||||||
|
const isKVM = (container.virtualization || 'lxc') === 'kvm'
|
||||||
|
const isWindows = container.template?.includes('windows')
|
||||||
|
const canOpenVNC = isKVM && isRunning
|
||||||
const isExpired = container.expires_at ? new Date(container.expires_at) < new Date() : false
|
const isExpired = container.expires_at ? new Date(container.expires_at) < new Date() : false
|
||||||
const publicHost = hostInfo?.network.public_ipv4 || PUBLIC_HOST
|
const publicHost = hostInfo?.network.public_ipv4 || PUBLIC_HOST
|
||||||
const maxVCPU = hostInfo?.cpu.cores || 64
|
const maxVCPU = hostInfo?.cpu.cores || 64
|
||||||
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
|
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
|
||||||
const sshCommand = `ssh -p ${container.ssh_port} root@${publicHost}`
|
const sshCommand = `ssh -p ${container.ssh_port} root@${publicHost}`
|
||||||
const editingSSH = draft.index !== null && !!container.port_mappings?.[draft.index] && (
|
const editingSSH = draft.index !== null && !!container.port_mappings?.[draft.index] && (
|
||||||
container.port_mappings[draft.index].description === 'SSH' || container.port_mappings[draft.index].container_port === 22
|
container.port_mappings[draft.index].description === 'SSH' || container.port_mappings[draft.index].container_port === 22 ||
|
||||||
|
container.port_mappings[draft.index].description === 'RDP' || container.port_mappings[draft.index].container_port === 3389
|
||||||
)
|
)
|
||||||
const filtered = filterHistory(history, range)
|
const filtered = filterHistory(history, range)
|
||||||
const cpuPct = clamp(usage?.cpu_usage_pct || 0)
|
const cpuPct = clamp(((usage?.cpu_usage_pct || 0) / (container.vcpu || 1)))
|
||||||
const ramPct = container.ram_mb > 0 ? clamp(((usage?.memory_usage_bytes || 0) / (container.ram_mb * 1024 * 1024)) * 100) : 0
|
const ramTotalBytes = usage?.memory_total_bytes && usage.memory_total_bytes > 0
|
||||||
|
? usage.memory_total_bytes
|
||||||
|
: container.ram_mb * 1024 * 1024
|
||||||
|
const ramPct = ramTotalBytes > 0 ? clamp(((usage?.memory_usage_bytes || 0) / ramTotalBytes) * 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 diskPct = container.disk_gb > 0 ? clamp(((usage?.disk_usage_bytes || 0) / (container.disk_gb * 1024 * 1024 * 1024)) * 100) : 0
|
||||||
const networkBps = (usage?.network_rx_bps || 0) + (usage?.network_tx_bps || 0)
|
const networkBps = (usage?.network_rx_bps || 0) + (usage?.network_tx_bps || 0)
|
||||||
const rx = usage?.network_rx_bps || 0
|
const rx = usage?.network_rx_bps || 0
|
||||||
@@ -676,12 +707,11 @@ export default function ContainerDetail() {
|
|||||||
const managementUrl = subUser?.access_code
|
const managementUrl = subUser?.access_code
|
||||||
? `${window.location.origin}/login?code=${encodeURIComponent(subUser.access_code)}`
|
? `${window.location.origin}/login?code=${encodeURIComponent(subUser.access_code)}`
|
||||||
: ''
|
: ''
|
||||||
const managementPassword = subUser?.password || ''
|
|
||||||
const charts: ResourceChartConfig[] = [
|
const charts: ResourceChartConfig[] = [
|
||||||
{
|
{
|
||||||
title: 'CPU 使用率',
|
title: 'CPU 使用率',
|
||||||
icon: <Cpu className="w-5 h-5" />,
|
icon: <Cpu className="w-5 h-5" />,
|
||||||
current: usage?.cpu_usage_pct || 0,
|
current: clamp(((usage?.cpu_usage_pct || 0) / (container.vcpu || 1))),
|
||||||
points: toChartPoints(filtered, 'cpu'),
|
points: toChartPoints(filtered, 'cpu'),
|
||||||
max: 100,
|
max: 100,
|
||||||
formatValue: formatPercent,
|
formatValue: formatPercent,
|
||||||
@@ -694,7 +724,7 @@ export default function ContainerDetail() {
|
|||||||
points: toChartPoints(filtered, 'memory'),
|
points: toChartPoints(filtered, 'memory'),
|
||||||
max: 100,
|
max: 100,
|
||||||
formatValue: formatPercent,
|
formatValue: formatPercent,
|
||||||
detail: `${formatBytes(usage?.memory_usage_bytes || 0)} / ${container.ram_mb} MB`,
|
detail: `${formatBytes(usage?.memory_usage_bytes || 0)} / ${formatBytes(ramTotalBytes)}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '网络流量',
|
title: '网络流量',
|
||||||
@@ -740,9 +770,10 @@ export default function ContainerDetail() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-wrap mt-2">
|
<div className="flex items-center gap-2 flex-wrap mt-2">
|
||||||
<InfoTag color="blue">系统 {container.template}</InfoTag>
|
<InfoTag color="blue">系统 {container.template}</InfoTag>
|
||||||
|
<InfoTag color="slate">类型 {(container.virtualization || 'lxc').toUpperCase()}</InfoTag>
|
||||||
<InfoTag color="emerald">内网 {container.ip || '-'}</InfoTag>
|
<InfoTag color="emerald">内网 {container.ip || '-'}</InfoTag>
|
||||||
<InfoTag color="amber">NAT {mappingCount} 条</InfoTag>
|
<InfoTag color="amber">NAT {mappingCount} 条</InfoTag>
|
||||||
<InfoTag color="violet">{publicHost}:{container.ssh_port}</InfoTag>
|
<InfoTag color="violet">{isWindows ? 'RDP' : 'SSH'} {publicHost}:{container.ssh_port}</InfoTag>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -763,10 +794,18 @@ export default function ContainerDetail() {
|
|||||||
<RefreshCw className="w-3.5 h-3.5" />
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
{isExpired ? '已到期' : taskStatus === 'restart' ? taskActionLabels['restart'] : '重启'}
|
{isExpired ? '已到期' : taskStatus === 'restart' ? taskActionLabels['restart'] : '重启'}
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
<ActionButton dark onClick={() => setShowSSH(true)}>
|
{!isWindows && (
|
||||||
<TerminalSquare className="w-3.5 h-3.5" />
|
<ActionButton dark onClick={() => setShowSSH(true)}>
|
||||||
WebSSH
|
<TerminalSquare className="w-3.5 h-3.5" />
|
||||||
</ActionButton>
|
WebSSH
|
||||||
|
</ActionButton>
|
||||||
|
)}
|
||||||
|
{isKVM && (
|
||||||
|
<ActionButton dark disabled={!canOpenVNC} onClick={() => setShowVNC(true)}>
|
||||||
|
<Monitor className="w-3.5 h-3.5" />
|
||||||
|
WebVNC
|
||||||
|
</ActionButton>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!isSubUser && (
|
{!isSubUser && (
|
||||||
@@ -803,30 +842,59 @@ export default function ContainerDetail() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||||
<Panel title="连接信息">
|
<Panel title="连接信息">
|
||||||
<PlainRow label="SSH 地址" value={`${publicHost}:${container.ssh_port}`} mono copyValue={sshCommand} onCopy={copyText} />
|
{isWindows ? (
|
||||||
<PlainRow label="用户名" value="root" mono />
|
<>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<PlainRow label="RDP 地址" value={`${publicHost}:${container.ssh_port}`} mono />
|
||||||
<span className="text-gray-500">SSH 密码</span>
|
<PlainRow label="用户名" value="Administrator" mono />
|
||||||
<div className="flex items-center gap-1 min-w-0">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span
|
<span className="text-gray-500">管理员密码</span>
|
||||||
className={`font-mono text-xs cursor-pointer select-none ${showPassword ? 'text-black' : 'text-gray-400 tracking-[0.25em]'}`}
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
<span
|
||||||
title={showPassword ? '点击隐藏' : '点击显示'}
|
className={`font-mono text-xs cursor-pointer select-none ${showPassword ? 'text-black' : 'text-gray-400 tracking-[0.25em]'}`}
|
||||||
>
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
{showPassword ? (container.ssh_password || '-') : '••••••••'}
|
title={showPassword ? '点击隐藏' : '点击显示'}
|
||||||
</span>
|
>
|
||||||
{container.ssh_password && (
|
{container.ssh_password ? (showPassword ? container.ssh_password : '••••••••') : '-'}
|
||||||
<button onClick={() => copyText(container.ssh_password)} className="p-0.5 text-gray-400 hover:text-black rounded" title="复制">
|
</span>
|
||||||
<Copy className="w-3 h-3" />
|
{container.ssh_password && (
|
||||||
|
<button onClick={() => copyText(container.ssh_password)} className="p-0.5 text-gray-400 hover:text-black rounded" title="复制">
|
||||||
|
<Copy className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{container.vnc_port > 0 && (
|
||||||
|
<PlainRow label="VNC 端口" value={`127.0.0.1:${container.vnc_port}`} mono />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<PlainRow label="SSH 地址" value={`${publicHost}:${container.ssh_port}`} mono copyValue={sshCommand} onCopy={copyText} />
|
||||||
|
<PlainRow label="用户名" value="root" mono />
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-gray-500">SSH 密码</span>
|
||||||
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
|
<span
|
||||||
|
className={`font-mono text-xs cursor-pointer select-none ${showPassword ? 'text-black' : 'text-gray-400 tracking-[0.25em]'}`}
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
title={showPassword ? '点击隐藏' : '点击显示'}
|
||||||
|
>
|
||||||
|
{container.ssh_password ? (showPassword ? container.ssh_password : '••••••••') : '-'}
|
||||||
|
</span>
|
||||||
|
{container.ssh_password && (
|
||||||
|
<button onClick={() => copyText(container.ssh_password)} className="p-0.5 text-gray-400 hover:text-black rounded" title="复制">
|
||||||
|
<Copy className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isSubUser && (
|
||||||
|
<button onClick={handleResetPassword} className="inline-flex items-center gap-1.5 text-xs text-gray-600 hover:text-black">
|
||||||
|
<Key className="w-3 h-3" />
|
||||||
|
重置 SSH 密码
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
</div>
|
|
||||||
{!isSubUser && (
|
|
||||||
<button onClick={handleResetPassword} className="inline-flex items-center gap-1.5 text-xs text-gray-600 hover:text-black">
|
|
||||||
<Key className="w-3 h-3" />
|
|
||||||
重置 SSH 密码
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
@@ -881,12 +949,12 @@ export default function ContainerDetail() {
|
|||||||
<RingStat
|
<RingStat
|
||||||
value={ramPct}
|
value={ramPct}
|
||||||
label="内存"
|
label="内存"
|
||||||
subLabel={`${formatMB(usage?.memory_usage_bytes || 0)} / ${formatMB(container.ram_mb * 1024 * 1024)}`}
|
subLabel={`${formatMB(usage?.memory_usage_bytes || 0)} / ${formatMB(ramTotalBytes)}`}
|
||||||
/>
|
/>
|
||||||
<RingStat
|
<RingStat
|
||||||
value={Math.min(cpuPct, 100)}
|
value={loadPct}
|
||||||
|
max={Infinity}
|
||||||
label="负载"
|
label="负载"
|
||||||
subLabel={cpuPct < 70 ? '正常' : cpuPct < 90 ? '中等' : '高'}
|
|
||||||
/>
|
/>
|
||||||
<RingStat
|
<RingStat
|
||||||
value={diskPct}
|
value={diskPct}
|
||||||
@@ -990,6 +1058,38 @@ export default function ContainerDetail() {
|
|||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showVNC && (
|
||||||
|
<Modal
|
||||||
|
title={`WebVNC - ${container.name}`}
|
||||||
|
onClose={() => setShowVNC(false)}
|
||||||
|
wide
|
||||||
|
flush
|
||||||
|
extra={
|
||||||
|
<button
|
||||||
|
onClick={toggleVNCFullscreen}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs text-gray-600 hover:text-black hover:bg-gray-100 rounded"
|
||||||
|
title={vncFullscreen ? '退出全屏' : '全屏显示'}
|
||||||
|
>
|
||||||
|
{vncFullscreen ? <Minimize2 className="w-3.5 h-3.5" /> : <Maximize2 className="w-3.5 h-3.5" />}
|
||||||
|
{vncFullscreen ? '退出全屏' : '全屏'}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={vncFullscreenRef}
|
||||||
|
className={`bg-white ${vncFullscreen ? 'fixed inset-0 z-[70] p-3' : 'h-[calc(92vh-112px)] min-h-[420px] p-5'}`}
|
||||||
|
>
|
||||||
|
<div className="h-full">
|
||||||
|
{canOpenVNC ? (
|
||||||
|
<WebVNCViewer containerName={container.name} onClose={() => setShowVNC(false)} />
|
||||||
|
) : (
|
||||||
|
<div className="h-full flex items-center justify-center bg-gray-950 text-gray-400 rounded-md">VNC 控制台暂不可用,请确认 KVM 虚拟机已开机并刷新页面</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
{showSnapshots && (
|
{showSnapshots && (
|
||||||
<Modal
|
<Modal
|
||||||
title="快照"
|
title="快照"
|
||||||
@@ -1263,55 +1363,21 @@ export default function ContainerDetail() {
|
|||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showSubUser && subUser && false && (
|
|
||||||
<Modal title="管理链接" onClose={() => setShowSubUser(false)}>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-xs text-amber-800">
|
|
||||||
安全提示:请通过私密渠道(如加密通讯工具)分享以下信息,不要在不安全的网络环境下明文传输。
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-50 rounded-lg p-4 text-sm space-y-3">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<span className="shrink-0 text-gray-500">管理地址</span>
|
|
||||||
<div className="flex min-w-0 items-center gap-1">
|
|
||||||
<span className="font-mono text-xs text-black break-all">{managementUrl}</span>
|
|
||||||
<button onClick={() => copyText(managementUrl)} className="shrink-0 p-0.5 text-gray-400 hover:text-black rounded"><Copy className="w-3 h-3" /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<span className="shrink-0 text-gray-500">用户名</span>
|
|
||||||
<div className="flex min-w-0 items-center gap-1">
|
|
||||||
<span className="font-mono text-xs text-black">{subUser?.username}</span>
|
|
||||||
<button onClick={() => copyText(subUser?.username || '')} className="shrink-0 p-0.5 text-gray-400 hover:text-black rounded"><Copy className="w-3 h-3" /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<span className="shrink-0 text-gray-500">密码</span>
|
|
||||||
<div className="flex min-w-0 items-center gap-1">
|
|
||||||
<span className="font-mono text-xs text-black">{managementPassword}</span>
|
|
||||||
<button onClick={() => copyText(managementPassword)} className="shrink-0 p-0.5 text-gray-400 hover:text-black rounded"><Copy className="w-3 h-3" /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-400">打开管理地址,输入用户名和密码即可管理该容器。链接不含 token,无法被截获后直接使用。</p>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showSubUser && subUser && (
|
{showSubUser && subUser && (
|
||||||
<Modal title="管理链接" onClose={() => setShowSubUser(false)}>
|
<Modal title="管理链接" onClose={() => setShowSubUser(false)}>
|
||||||
<div className="bg-gray-50 rounded-lg p-4 text-sm space-y-3">
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 text-sm space-y-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<span className="shrink-0 text-gray-500">地址</span>
|
<span className="shrink-0 text-gray-500 dark:text-gray-400">地址</span>
|
||||||
<div className="flex min-w-0 items-center gap-1">
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
<span className="font-mono text-xs text-black break-all">{managementUrl}</span>
|
<span className="font-mono text-xs text-black dark:text-white break-all">{managementUrl}</span>
|
||||||
<button onClick={() => copyText(managementUrl)} className="shrink-0 p-0.5 text-gray-400 hover:text-black rounded"><Copy className="w-3 h-3" /></button>
|
<button onClick={() => copyText(managementUrl)} className="shrink-0 p-0.5 text-gray-400 hover:text-black dark:hover:text-white rounded"><Copy className="w-3 h-3" /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<span className="shrink-0 text-gray-500">密码</span>
|
<span className="shrink-0 text-gray-500 dark:text-gray-400">密码</span>
|
||||||
<div className="flex min-w-0 items-center gap-1">
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
<span className="font-mono text-xs text-black">{managementPassword}</span>
|
<span className="font-mono text-xs text-black dark:text-white">{subUser.password || ''}</span>
|
||||||
<button onClick={() => copyText(managementPassword)} className="shrink-0 p-0.5 text-gray-400 hover:text-black rounded"><Copy className="w-3 h-3" /></button>
|
<button onClick={() => copyText(subUser.password || '')} className="shrink-0 p-0.5 text-gray-400 hover:text-black dark:hover:text-white rounded"><Copy className="w-3 h-3" /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1441,12 +1507,13 @@ function StatusBadge({ running }: { running: boolean }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoTag({ color, children }: { color: 'blue' | 'emerald' | 'amber' | 'violet'; children: ReactNode }) {
|
function InfoTag({ color, children }: { color: 'blue' | 'emerald' | 'amber' | 'violet' | 'slate'; children: ReactNode }) {
|
||||||
const classes = {
|
const classes = {
|
||||||
blue: 'bg-blue-50 text-blue-700 border-blue-100',
|
blue: 'bg-blue-50 text-blue-700 border-blue-100',
|
||||||
emerald: 'bg-emerald-50 text-emerald-700 border-emerald-100',
|
emerald: 'bg-emerald-50 text-emerald-700 border-emerald-100',
|
||||||
amber: 'bg-amber-50 text-amber-700 border-amber-100',
|
amber: 'bg-amber-50 text-amber-700 border-amber-100',
|
||||||
violet: 'bg-violet-50 text-violet-700 border-violet-100',
|
violet: 'bg-violet-50 text-violet-700 border-violet-100',
|
||||||
|
slate: 'bg-slate-50 text-slate-700 border-slate-100',
|
||||||
}
|
}
|
||||||
return <span className={`px-1.5 py-0.5 border rounded text-[11px] whitespace-nowrap ${classes[color]}`}>{children}</span>
|
return <span className={`px-1.5 py-0.5 border rounded text-[11px] whitespace-nowrap ${classes[color]}`}>{children}</span>
|
||||||
}
|
}
|
||||||
@@ -1616,7 +1683,7 @@ function MappingTable({ mappings, publicHost, onEdit, onDelete, compact = false,
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100">
|
<tbody className="divide-y divide-gray-100">
|
||||||
{mappings.map((pm, index) => {
|
{mappings.map((pm, index) => {
|
||||||
const isSSH = pm.description === 'SSH' || pm.container_port === 22
|
const isSSH = pm.description === 'SSH' || pm.container_port === 22 || pm.description === 'RDP' || pm.container_port === 3389
|
||||||
return (
|
return (
|
||||||
<tr key={`${pm.host_port}-${pm.container_port}-${index}`}>
|
<tr key={`${pm.host_port}-${pm.container_port}-${index}`}>
|
||||||
<td className="px-3 py-2 text-sm">
|
<td className="px-3 py-2 text-sm">
|
||||||
@@ -1662,7 +1729,7 @@ function Field({ label, children }: { label: string; children: ReactNode }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Modal({ title, children, onClose, wide = false, extra }: { title: string; children: ReactNode; onClose: () => void; wide?: boolean; extra?: ReactNode }) {
|
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">
|
||||||
<div className={`bg-white rounded-lg shadow-xl border border-gray-200 w-full ${wide ? 'max-w-5xl' : 'max-w-md'} max-h-[92vh] overflow-hidden flex flex-col`}>
|
<div className={`bg-white rounded-lg shadow-xl border border-gray-200 w-full ${wide ? 'max-w-5xl' : 'max-w-md'} max-h-[92vh] overflow-hidden flex flex-col`}>
|
||||||
@@ -1675,7 +1742,7 @@ function Modal({ title, children, onClose, wide = false, extra }: { title: strin
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-5 overflow-y-auto">{children}</div>
|
<div className={flush ? "overflow-hidden" : "p-5 overflow-y-auto"}>{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -1850,6 +1917,7 @@ function TrafficBar({ container }: { container: Container }) {
|
|||||||
|
|
||||||
function getTemplateIcon(id: string): ReactNode {
|
function getTemplateIcon(id: string): ReactNode {
|
||||||
const size = 'w-6 h-6'
|
const size = 'w-6 h-6'
|
||||||
|
id = id.startsWith('kvm-') ? id.slice(4) : id
|
||||||
if (id.startsWith('debian')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M935.473 375.359a558.602 558.602 0 0 0-22.351-114.655l13.308 4.436c-35.66-81.385-90.086-163.623-153.556-199.282-8.701-5.118-35.147 4.948-26.616-12.113s-37.536-8.19-56.816-4.778c-26.275 4.266-30.028-29.175-75.071-35.83-25.593-3.582-32.247 18.427-44.702 13.309-23.545-9.384-20.816-27.64-57.669-9.384-18.427 9.042 11.602-26.105-49.138-4.607L457.744 0C349.23 41.63 318.69 76.266 288.15 79.337c-6.996 0-34.124 32.759-53.574 53.062-17.062 17.062-26.275 36.512-49.138 39.583l-17.062 70.636A136.494 136.494 0 0 0 119.41 339.7a66.711 66.711 0 0 1 4.436-52.892c-17.062 6.825-45.896 17.062-29.687 96.91 12.796 63.13-5.29 135.13 10.066 204.742 4.777 20.986 0 40.095 6.142 51.185 107.66 235.794 208.836 392.08 472.44 384.06l4.436-8.872c-28.152-6.825-55.11-17.062-111.584-30.711-18.597-4.436-23.033-34.124-40.265-44.19-9.384-5.46-28.323-4.095-37.195-9.896s4.266-21.668-19.962-14.332c-8.531 2.56-13.82-10.92-20.133-17.061s0-23.716-23.375-24.74-18.426-29.687-19.791-44.702c-12.114 1.536-1.195-1.535-13.308 4.436a63.64 63.64 0 0 1-23.887-31.735c-10.237-48.967-10.578-21.497-15.014-32.417a322.297 322.297 0 0 0-19.28-42.142l26.787 8.872h4.436l4.436-13.309-26.616-8.701h31.223c-7.678 13.99 2.047 5.29-13.479 8.872v13.308l22.35-8.872v-13.308c-20.644-10.237-28.663-13.308-49.137-22.01l9.043 8.872v4.436h-49.138c-22.01-14.843-13.99-31.734-17.915-53.062 17.062 0 9.213 6.655 17.062-13.137l-17.062 8.872 13.308-33.953-13.308 13.138c-29.176-38.73-16.209-97.764-11.943-152.02A180.684 180.684 0 0 1 211.2 372.97c8.872-10.067 5.119-25.251 5.46-37.195l31.223-26.445H265.8c7.678 17.061 4.777 5.46 0 22.01l8.872 4.435c7.166-8.701 6.142-5.971 8.872-22.01-10.066-10.578-6.995-9.895-26.616-13.308A119.432 119.432 0 0 1 368.51 243.13l4.436-13.308-17.915 9.043-4.436-13.138a109.536 109.536 0 0 1 76.095-27.128c6.313 0 6.996-17.062 12.797-19.45 161.574-60.57 309.33 9.383 371.093 147.413a324.173 324.173 0 0 1 8.19 34.123c17.061 56.987-7.167 121.48 9.725 155.604-7.849 36-36.683 13.82-40.266 30.881-8.531 41.29-14.844 59.717-40.778 78.826a196.38 196.38 0 0 1-30.711 22.35 84.285 84.285 0 0 0 22.35-39.753c-106.294 111.584-262.58 63.981-290.049-105.954a101.176 101.176 0 0 1 35.147-93.157c92.987-87.527 150.144-52.38 205.765-20.474l-8.872-30.711c-32.93-24.398-17.062-19.792-9.043-57.328v-4.436l-17.915-13.137c2.56 10.066 1.024 5.289 9.043 17.061-4.436 16.039 0 9.043-8.872 17.062-15.014 9.725-23.716 7.337-44.702 4.436l4.436-13.308-13.308-13.308c0 11.773-4.095 2.73 0 17.062-126.086 9.896-218.05 80.02-178.636 260.191a220.608 220.608 0 0 0 8.872 44.19l-8.872 8.702-4.436-26.446h-13.48l-4.435 13.308c-12.626-25.763-0.853 10.75 40.265 52.892a149.29 149.29 0 0 0 12.797 12.625c47.773 34.124 113.29 81.385 201.328 49.138h9.043v-4.436l-102.37-13.308-4.436-8.701c106.806 24.74 176.93-8.531 236.646-48.456 13.138-17.062 11.431-24.057 22.18-9.043 19.28-17.061 3.925-26.786 13.48-44.019 6.483-11.772 32.587-17.062 44.7-35.318l40.096-136.494h-17.062c3.071-14.332 22.522-34.123-4.436-48.455-2.559-1.536 9.043-1.365 8.872-4.266a145.537 145.537 0 0 0-22.18-66.37c33.1 21.669 36.342 68.247 53.574 105.783v8.872h4.436V375.36zM453.308 595.455l-9.555-26.446 62.446 57.328zM146.196 211.736l-23.204-4.436v39.754c16.72-10.578 18.939-10.407 22.35-35.318z m574.981 176.419a57.498 57.498 0 0 0-17.062 44.19l13.48 8.701a37.877 37.877 0 0 0 4.435-52.891zM868.42 555.872c26.275-11.602 54.598-58.01 35.83-97.081l-35.83 96.91z m-174.03-79.508c-15.697 11.773-19.791 13.308-22.35 39.754l13.307 8.872 17.915-8.872a60.228 60.228 0 0 0 4.436-48.455c-8.36 13.478-2.559 20.644-13.308 8.701z m-67.053 79.508c15.868-10.92 11.944-14.844 17.915-22.18v-4.778a292.097 292.097 0 0 1-62.446 0c-13.137-13.99-13.308-29.346-31.223-39.583 17.062 35.147 3.242 38.218 31.223 61.764a158.162 158.162 0 0 0 40.095 4.436c1.536 0-6.824-1.024 4.436 0zM207.79 520.554H194.31l-8.872 8.702c9.555 10.237 5.46 7.166 13.308-4.436L212.225 547l4.436-17.062-8.872-8.701z m17.062 57.328l4.436-8.873c-10.067-8.701 0-3.583-13.308 0l-13.309-17.061 4.436 17.061v8.873h17.062z" fill="#CE0C48"/></svg>
|
if (id.startsWith('debian')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M935.473 375.359a558.602 558.602 0 0 0-22.351-114.655l13.308 4.436c-35.66-81.385-90.086-163.623-153.556-199.282-8.701-5.118-35.147 4.948-26.616-12.113s-37.536-8.19-56.816-4.778c-26.275 4.266-30.028-29.175-75.071-35.83-25.593-3.582-32.247 18.427-44.702 13.309-23.545-9.384-20.816-27.64-57.669-9.384-18.427 9.042 11.602-26.105-49.138-4.607L457.744 0C349.23 41.63 318.69 76.266 288.15 79.337c-6.996 0-34.124 32.759-53.574 53.062-17.062 17.062-26.275 36.512-49.138 39.583l-17.062 70.636A136.494 136.494 0 0 0 119.41 339.7a66.711 66.711 0 0 1 4.436-52.892c-17.062 6.825-45.896 17.062-29.687 96.91 12.796 63.13-5.29 135.13 10.066 204.742 4.777 20.986 0 40.095 6.142 51.185 107.66 235.794 208.836 392.08 472.44 384.06l4.436-8.872c-28.152-6.825-55.11-17.062-111.584-30.711-18.597-4.436-23.033-34.124-40.265-44.19-9.384-5.46-28.323-4.095-37.195-9.896s4.266-21.668-19.962-14.332c-8.531 2.56-13.82-10.92-20.133-17.061s0-23.716-23.375-24.74-18.426-29.687-19.791-44.702c-12.114 1.536-1.195-1.535-13.308 4.436a63.64 63.64 0 0 1-23.887-31.735c-10.237-48.967-10.578-21.497-15.014-32.417a322.297 322.297 0 0 0-19.28-42.142l26.787 8.872h4.436l4.436-13.309-26.616-8.701h31.223c-7.678 13.99 2.047 5.29-13.479 8.872v13.308l22.35-8.872v-13.308c-20.644-10.237-28.663-13.308-49.137-22.01l9.043 8.872v4.436h-49.138c-22.01-14.843-13.99-31.734-17.915-53.062 17.062 0 9.213 6.655 17.062-13.137l-17.062 8.872 13.308-33.953-13.308 13.138c-29.176-38.73-16.209-97.764-11.943-152.02A180.684 180.684 0 0 1 211.2 372.97c8.872-10.067 5.119-25.251 5.46-37.195l31.223-26.445H265.8c7.678 17.061 4.777 5.46 0 22.01l8.872 4.435c7.166-8.701 6.142-5.971 8.872-22.01-10.066-10.578-6.995-9.895-26.616-13.308A119.432 119.432 0 0 1 368.51 243.13l4.436-13.308-17.915 9.043-4.436-13.138a109.536 109.536 0 0 1 76.095-27.128c6.313 0 6.996-17.062 12.797-19.45 161.574-60.57 309.33 9.383 371.093 147.413a324.173 324.173 0 0 1 8.19 34.123c17.061 56.987-7.167 121.48 9.725 155.604-7.849 36-36.683 13.82-40.266 30.881-8.531 41.29-14.844 59.717-40.778 78.826a196.38 196.38 0 0 1-30.711 22.35 84.285 84.285 0 0 0 22.35-39.753c-106.294 111.584-262.58 63.981-290.049-105.954a101.176 101.176 0 0 1 35.147-93.157c92.987-87.527 150.144-52.38 205.765-20.474l-8.872-30.711c-32.93-24.398-17.062-19.792-9.043-57.328v-4.436l-17.915-13.137c2.56 10.066 1.024 5.289 9.043 17.061-4.436 16.039 0 9.043-8.872 17.062-15.014 9.725-23.716 7.337-44.702 4.436l4.436-13.308-13.308-13.308c0 11.773-4.095 2.73 0 17.062-126.086 9.896-218.05 80.02-178.636 260.191a220.608 220.608 0 0 0 8.872 44.19l-8.872 8.702-4.436-26.446h-13.48l-4.435 13.308c-12.626-25.763-0.853 10.75 40.265 52.892a149.29 149.29 0 0 0 12.797 12.625c47.773 34.124 113.29 81.385 201.328 49.138h9.043v-4.436l-102.37-13.308-4.436-8.701c106.806 24.74 176.93-8.531 236.646-48.456 13.138-17.062 11.431-24.057 22.18-9.043 19.28-17.061 3.925-26.786 13.48-44.019 6.483-11.772 32.587-17.062 44.7-35.318l40.096-136.494h-17.062c3.071-14.332 22.522-34.123-4.436-48.455-2.559-1.536 9.043-1.365 8.872-4.266a145.537 145.537 0 0 0-22.18-66.37c33.1 21.669 36.342 68.247 53.574 105.783v8.872h4.436V375.36zM453.308 595.455l-9.555-26.446 62.446 57.328zM146.196 211.736l-23.204-4.436v39.754c16.72-10.578 18.939-10.407 22.35-35.318z m574.981 176.419a57.498 57.498 0 0 0-17.062 44.19l13.48 8.701a37.877 37.877 0 0 0 4.435-52.891zM868.42 555.872c26.275-11.602 54.598-58.01 35.83-97.081l-35.83 96.91z m-174.03-79.508c-15.697 11.773-19.791 13.308-22.35 39.754l13.307 8.872 17.915-8.872a60.228 60.228 0 0 0 4.436-48.455c-8.36 13.478-2.559 20.644-13.308 8.701z m-67.053 79.508c15.868-10.92 11.944-14.844 17.915-22.18v-4.778a292.097 292.097 0 0 1-62.446 0c-13.137-13.99-13.308-29.346-31.223-39.583 17.062 35.147 3.242 38.218 31.223 61.764a158.162 158.162 0 0 0 40.095 4.436c1.536 0-6.824-1.024 4.436 0zM207.79 520.554H194.31l-8.872 8.702c9.555 10.237 5.46 7.166 13.308-4.436L212.225 547l4.436-17.062-8.872-8.701z m17.062 57.328l4.436-8.873c-10.067-8.701 0-3.583-13.308 0l-13.309-17.061 4.436 17.061v8.873h17.062z" fill="#CE0C48"/></svg>
|
||||||
if (id.startsWith('ubuntu')) return <svg className={size} viewBox="0 0 1024 1024"><circle cx="512" cy="512" r="511" fill="#DD4814"/><path d="M164.532 442.532c-37.676 0-68.2 30.524-68.2 68.2 0 37.656 30.524 68.184 68.2 68.184 37.66 0 68.184-30.528 68.184-68.184 0-37.676-30.524-68.2-68.184-68.2z m486.86 309.912c-32.612 18.84-43.8 60.52-24.96 93.116 18.82 32.616 60.5 43.796 93.116 24.96 32.612-18.82 43.796-60.5 24.96-93.12-18.82-32.592-60.524-43.772-93.116-24.956z m-338.744-241.712c0-67.384 33.472-126.92 84.684-162.968L347.48 264.268c-59.656 39.88-104.048 100.816-122.496 172.188 21.528 17.56 35.304 44.3 35.304 74.272 0 29.956-13.776 56.696-35.304 74.26C243.408 656.376 287.8 717.32 347.48 757.2l49.852-83.52c-51.212-36.028-84.684-95.56-84.684-162.948z m199.168-199.188c104.052 0 189.42 79.776 198.38 181.52l97.16-1.432c-4.776-75.112-37.592-142.544-88.008-192.128-25.928 9.796-55.88 8.296-81.76-6.624-25.932-14.964-42.192-40.208-46.636-67.608a297.04 297.04 0 0 0-79.14-10.76 295.148 295.148 0 0 0-131.276 30.652l47.38 84.908a198.384 198.384 0 0 1 83.9-18.528z m0 398.36a198.404 198.404 0 0 1-83.896-18.528l-47.38 84.9a294.848 294.848 0 0 0 131.28 30.684 296.16 296.16 0 0 0 79.136-10.788c4.444-27.4 20.708-52.62 46.632-67.608 25.904-14.948 55.836-16.42 81.76-6.624 50.42-49.584 83.232-117.016 88.016-192.128l-97.188-1.432c-8.94 101.772-94.304 181.52-198.36 181.52z m139.552-440.924c32.616 18.832 74.3 7.68 93.116-24.936 18.84-32.616 7.68-74.3-24.936-93.14-32.616-18.816-74.296-7.64-93.14 24.976-18.812 32.6-7.632 74.28 24.96 93.1z" fill="#FFF"/></svg>
|
if (id.startsWith('ubuntu')) return <svg className={size} viewBox="0 0 1024 1024"><circle cx="512" cy="512" r="511" fill="#DD4814"/><path d="M164.532 442.532c-37.676 0-68.2 30.524-68.2 68.2 0 37.656 30.524 68.184 68.2 68.184 37.66 0 68.184-30.528 68.184-68.184 0-37.676-30.524-68.2-68.184-68.2z m486.86 309.912c-32.612 18.84-43.8 60.52-24.96 93.116 18.82 32.616 60.5 43.796 93.116 24.96 32.612-18.82 43.796-60.5 24.96-93.12-18.82-32.592-60.524-43.772-93.116-24.956z m-338.744-241.712c0-67.384 33.472-126.92 84.684-162.968L347.48 264.268c-59.656 39.88-104.048 100.816-122.496 172.188 21.528 17.56 35.304 44.3 35.304 74.272 0 29.956-13.776 56.696-35.304 74.26C243.408 656.376 287.8 717.32 347.48 757.2l49.852-83.52c-51.212-36.028-84.684-95.56-84.684-162.948z m199.168-199.188c104.052 0 189.42 79.776 198.38 181.52l97.16-1.432c-4.776-75.112-37.592-142.544-88.008-192.128-25.928 9.796-55.88 8.296-81.76-6.624-25.932-14.964-42.192-40.208-46.636-67.608a297.04 297.04 0 0 0-79.14-10.76 295.148 295.148 0 0 0-131.276 30.652l47.38 84.908a198.384 198.384 0 0 1 83.9-18.528z m0 398.36a198.404 198.404 0 0 1-83.896-18.528l-47.38 84.9a294.848 294.848 0 0 0 131.28 30.684 296.16 296.16 0 0 0 79.136-10.788c4.444-27.4 20.708-52.62 46.632-67.608 25.904-14.948 55.836-16.42 81.76-6.624 50.42-49.584 83.232-117.016 88.016-192.128l-97.188-1.432c-8.94 101.772-94.304 181.52-198.36 181.52z m139.552-440.924c32.616 18.832 74.3 7.68 93.116-24.936 18.84-32.616 7.68-74.3-24.936-93.14-32.616-18.816-74.296-7.64-93.14 24.976-18.812 32.6-7.632 74.28 24.96 93.1z" fill="#FFF"/></svg>
|
||||||
if (id.startsWith('alpine')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M255.914667 68.565333L0 512l255.914667 443.434667h512.170666L1024 512 768.085333 68.565333H255.914667zM425.173333 303.786667L540.16 422.4l68.181333 68.053333 0.085334-0.085333 102.826666 100.821333c-8.533333 5.973333-16.469333 10.752-24.021333 14.677334a160.256 160.256 0 0 1-21.162667 9.258666 115.285333 115.285333 0 0 1-18.133333 4.736c-5.589333 0.981333-10.666667 1.450667-15.274667 1.450667-5.546667 0-10.325333-0.597333-14.421333-1.450667a56.192 56.192 0 0 1-10.24-3.072 40.533333 40.533333 0 0 1-8.533333-4.821333l-45.312-46.592-129.664-129.749333-46.933334 44.928-130.986666 131.072a41.557333 41.557333 0 0 1-8.533334 4.736 54.357333 54.357333 0 0 1-10.112 3.114666 70.826667 70.826667 0 0 1-14.421333 1.408c-4.608 0-9.685333-0.384-15.274667-1.322666a115.2 115.2 0 0 1-18.133333-4.864 159.914667 159.914667 0 0 1-21.162667-9.258667 223.061333 223.061333 0 0 1-24.021333-14.634667L425.173333 303.786667z m201.386667 33.493333l195.370667 196.181333 58.965333 57.728a223.573333 223.573333 0 0 1-24.064 14.677334 159.146667 159.146667 0 0 1-21.077333 9.258666 115.072 115.072 0 0 1-18.176 4.736c-5.546667 0.981333-10.709333 1.450667-15.36 1.450667-5.504 0-10.282667-0.597333-14.378667-1.450667a54.826667 54.826667 0 0 1-16.426667-6.229333 10.197333 10.197333 0 0 1-2.261333-1.706667l-52.565333-51.968-90.069334-90.069333-14.250666 14.250667L545.706667 418.133333l80.896-80.938666z m-254.549333 175.786667v107.904a90.794667 90.794667 0 0 1-15.189334-1.493334 117.973333 117.973333 0 0 1-18.005333-4.949333 158.208 158.208 0 0 1-20.821333-9.130667 222.592 222.592 0 0 1-23.68-14.506666l77.653333-77.866667z" fill="#0D597F"/></svg>
|
if (id.startsWith('alpine')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M255.914667 68.565333L0 512l255.914667 443.434667h512.170666L1024 512 768.085333 68.565333H255.914667zM425.173333 303.786667L540.16 422.4l68.181333 68.053333 0.085334-0.085333 102.826666 100.821333c-8.533333 5.973333-16.469333 10.752-24.021333 14.677334a160.256 160.256 0 0 1-21.162667 9.258666 115.285333 115.285333 0 0 1-18.133333 4.736c-5.589333 0.981333-10.666667 1.450667-15.274667 1.450667-5.546667 0-10.325333-0.597333-14.421333-1.450667a56.192 56.192 0 0 1-10.24-3.072 40.533333 40.533333 0 0 1-8.533333-4.821333l-45.312-46.592-129.664-129.749333-46.933334 44.928-130.986666 131.072a41.557333 41.557333 0 0 1-8.533334 4.736 54.357333 54.357333 0 0 1-10.112 3.114666 70.826667 70.826667 0 0 1-14.421333 1.408c-4.608 0-9.685333-0.384-15.274667-1.322666a115.2 115.2 0 0 1-18.133333-4.864 159.914667 159.914667 0 0 1-21.162667-9.258667 223.061333 223.061333 0 0 1-24.021333-14.634667L425.173333 303.786667z m201.386667 33.493333l195.370667 196.181333 58.965333 57.728a223.573333 223.573333 0 0 1-24.064 14.677334 159.146667 159.146667 0 0 1-21.077333 9.258666 115.072 115.072 0 0 1-18.176 4.736c-5.546667 0.981333-10.709333 1.450667-15.36 1.450667-5.504 0-10.282667-0.597333-14.378667-1.450667a54.826667 54.826667 0 0 1-16.426667-6.229333 10.197333 10.197333 0 0 1-2.261333-1.706667l-52.565333-51.968-90.069334-90.069333-14.250666 14.250667L545.706667 418.133333l80.896-80.938666z m-254.549333 175.786667v107.904a90.794667 90.794667 0 0 1-15.189334-1.493334 117.973333 117.973333 0 0 1-18.005333-4.949333 158.208 158.208 0 0 1-20.821333-9.130667 222.592 222.592 0 0 1-23.68-14.506666l77.653333-77.866667z" fill="#0D597F"/></svg>
|
||||||
@@ -1859,5 +1927,6 @@ function getTemplateIcon(id: string): ReactNode {
|
|||||||
if (id.startsWith('nixos')) return <svg className={size} viewBox="0 0 60 60"><g fillRule="evenodd"><path d="M23.58 20.214L8.964 45.528 5.55 39.743l3.94-6.78-7.823-.02L0 30.052l1.703-2.956 11.135.035 4.002-6.9zM24.7 40.45h29.23l-3.302 5.85-7.84-.022 3.894 6.785-1.67 2.9-3.412.004-5.537-9.66-7.976-.016zm17.014-11.092L27.1 4.043l6.716-.063 3.902 6.8 3.93-6.765 3.337.002 1.7 2.953-5.598 9.626 3.974 6.916z" fill="#7ebae4"/><path d="M35.28 19.486l-29.23-.002 3.303-5.848 7.84.022L13.3 6.873l1.67-2.9 3.412-.004 5.537 9.66 7.976.016zm1.14 20.294l14.616-25.313 3.413 5.785-3.94 6.78 7.823.02 1.668 2.9-1.703 2.956-11.135-.035-4.002 6.9z" fill="#5277c3"/></g><defs><path id="B" d="M18.305 30.642L32.92 55.956l-6.716.063-3.902-6.8-3.93 6.765-3.337-.002-1.71-2.953 5.598-9.626-3.974-6.916z"/></defs></svg>
|
if (id.startsWith('nixos')) return <svg className={size} viewBox="0 0 60 60"><g fillRule="evenodd"><path d="M23.58 20.214L8.964 45.528 5.55 39.743l3.94-6.78-7.823-.02L0 30.052l1.703-2.956 11.135.035 4.002-6.9zM24.7 40.45h29.23l-3.302 5.85-7.84-.022 3.894 6.785-1.67 2.9-3.412.004-5.537-9.66-7.976-.016zm17.014-11.092L27.1 4.043l6.716-.063 3.902 6.8 3.93-6.765 3.337.002 1.7 2.953-5.598 9.626 3.974 6.916z" fill="#7ebae4"/><path d="M35.28 19.486l-29.23-.002 3.303-5.848 7.84.022L13.3 6.873l1.67-2.9 3.412-.004 5.537 9.66 7.976.016zm1.14 20.294l14.616-25.313 3.413 5.785-3.94 6.78 7.823.02 1.668 2.9-1.703 2.956-11.135-.035-4.002 6.9z" fill="#5277c3"/></g><defs><path id="B" d="M18.305 30.642L32.92 55.956l-6.716.063-3.902-6.8-3.93 6.765-3.337-.002-1.71-2.953 5.598-9.626-3.974-6.916z"/></defs></svg>
|
||||||
if (id.startsWith('kali')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M545.194667 253.568s-84.053333-5.546667-227.285334 39.253333c-145.92 45.653333-228.693333 110.378667-228.693333 110.378667s217.514667-121.472 463.018667-128.341333z m313.642666 132.053333l10.965334-0.725333s-62.634667-75.946667-182.528-112.981333c67.413333 27.392 126.037333 63.701333 171.562666 113.706666z m17.92 31.573334c1.664-2.901333 7.082667 9.258667 11.221334 14.378666 0.170667 1.024 0.426667 1.664-1.92 1.152-0.213333-1.066667-0.554667-1.365333-0.554667-1.365333s-5.76-3.413333-7.552-5.845333c-1.749333-2.432-2.090667-6.698667-1.194667-8.32z m147.114667 361.770666s13.312-152.661333-226.56-187.861333a779.818667 779.818667 0 0 0-107.690667-7.978667c-192.256 2.56-199.253333-221.738667-54.4-233.045333 60.032-4.949333 131.712 27.434667 201.813334 60.074667-0.298667 8.704 0.085333 16.426667 5.802666 23.552 5.717333 7.168 27.648 14.933333 34.688 18.986666 6.997333 4.010667 29.482667 18.346667 43.264 36.266667 2.986667-5.589333 27.904-21.845333 27.904-21.845333s-5.973333 0.128-19.84-5.077334c-13.909333-5.205333-30.421333-20.906667-30.805333-21.802666-0.426667-0.938667-0.64-2.346667 2.56-2.986667 2.517333-2.090667-3.072-8.832-5.546667-11.306667-2.474667-2.474667-18.986667-30.549333-19.370666-31.146666-0.384-0.682667-0.512-1.322667-1.706667-2.133334-3.626667-1.152-19.626667 1.706667-19.626667 1.706667s-24.533333-12.074667-33.024-38.101333c0.128 4.565333-4.224 9.557333 0 20.010666-12.8-5.418667-23.808-14.677333-32.512-37.546666-5.12 13.013333 0 21.290667 0 21.290666s-30.165333-8.448-34.986666-36.266666c-5.290667 12.501333 0 20.010667 0 20.010666s-49.194667-25.685333-130.944-26.026666c-54.741333-5.034667-66.133333-101.290667-61.013334-117.504 0 0-78.933333-41.6-234.368-59.989334-155.392-18.346667-282.794667-2.773333-282.794666-2.773333s275.2-13.226667 495.658666 76.074667c7.509333 33.493333 30.037333 89.344 42.197334 116.181333-34.773333 24.021333-73.941333 46.592-80.042667 126.72-6.101333 80.128 62.805333 150.613333 148.224 152.746667 81.066667 4.352 137.130667 4.949333 205.056 40.192 64.853333 35.84 118.016 145.066667 123.306667 243.328 5.632-72.917333-21.717333-229.674667-149.333334-277.248 178.389333 31.232 194.090667 163.498667 194.090667 163.498666zM541.013333 241.621333l-6.4-20.693333s-105.984-18.816-248.405333-8.704C143.786667 222.336 0 272.213333 0 272.213333s294.229333-74.026667 541.013333-30.592z" fill="#557C94"/></svg>
|
if (id.startsWith('kali')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M545.194667 253.568s-84.053333-5.546667-227.285334 39.253333c-145.92 45.653333-228.693333 110.378667-228.693333 110.378667s217.514667-121.472 463.018667-128.341333z m313.642666 132.053333l10.965334-0.725333s-62.634667-75.946667-182.528-112.981333c67.413333 27.392 126.037333 63.701333 171.562666 113.706666z m17.92 31.573334c1.664-2.901333 7.082667 9.258667 11.221334 14.378666 0.170667 1.024 0.426667 1.664-1.92 1.152-0.213333-1.066667-0.554667-1.365333-0.554667-1.365333s-5.76-3.413333-7.552-5.845333c-1.749333-2.432-2.090667-6.698667-1.194667-8.32z m147.114667 361.770666s13.312-152.661333-226.56-187.861333a779.818667 779.818667 0 0 0-107.690667-7.978667c-192.256 2.56-199.253333-221.738667-54.4-233.045333 60.032-4.949333 131.712 27.434667 201.813334 60.074667-0.298667 8.704 0.085333 16.426667 5.802666 23.552 5.717333 7.168 27.648 14.933333 34.688 18.986666 6.997333 4.010667 29.482667 18.346667 43.264 36.266667 2.986667-5.589333 27.904-21.845333 27.904-21.845333s-5.973333 0.128-19.84-5.077334c-13.909333-5.205333-30.421333-20.906667-30.805333-21.802666-0.426667-0.938667-0.64-2.346667 2.56-2.986667 2.517333-2.090667-3.072-8.832-5.546667-11.306667-2.474667-2.474667-18.986667-30.549333-19.370666-31.146666-0.384-0.682667-0.512-1.322667-1.706667-2.133334-3.626667-1.152-19.626667 1.706667-19.626667 1.706667s-24.533333-12.074667-33.024-38.101333c0.128 4.565333-4.224 9.557333 0 20.010666-12.8-5.418667-23.808-14.677333-32.512-37.546666-5.12 13.013333 0 21.290667 0 21.290666s-30.165333-8.448-34.986666-36.266666c-5.290667 12.501333 0 20.010667 0 20.010666s-49.194667-25.685333-130.944-26.026666c-54.741333-5.034667-66.133333-101.290667-61.013334-117.504 0 0-78.933333-41.6-234.368-59.989334-155.392-18.346667-282.794667-2.773333-282.794666-2.773333s275.2-13.226667 495.658666 76.074667c7.509333 33.493333 30.037333 89.344 42.197334 116.181333-34.773333 24.021333-73.941333 46.592-80.042667 126.72-6.101333 80.128 62.805333 150.613333 148.224 152.746667 81.066667 4.352 137.130667 4.949333 205.056 40.192 64.853333 35.84 118.016 145.066667 123.306667 243.328 5.632-72.917333-21.717333-229.674667-149.333334-277.248 178.389333 31.232 194.090667 163.498667 194.090667 163.498666zM541.013333 241.621333l-6.4-20.693333s-105.984-18.816-248.405333-8.704C143.786667 222.336 0 272.213333 0 272.213333s294.229333-74.026667 541.013333-30.592z" fill="#557C94"/></svg>
|
||||||
if (id.startsWith('rockylinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M995.498667 680.362667c18.474667-52.778667 28.501333-109.568 28.501333-168.704C1024 229.077333 794.752 0 512 0S0 229.077333 0 511.658667c0 139.818667 56.106667 266.496 147.114667 358.826666L666.453333 351.530667l128.213334 128.170666 200.832 200.704z m-93.525334 162.816l-235.52-235.349334-368.896 368.597334A510.506667 510.506667 0 0 0 512 1023.274667c156.16 0 296.106667-69.888 389.973333-180.053334h0.042667z" fill="#10B981"/></svg>
|
if (id.startsWith('rockylinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M995.498667 680.362667c18.474667-52.778667 28.501333-109.568 28.501333-168.704C1024 229.077333 794.752 0 512 0S0 229.077333 0 511.658667c0 139.818667 56.106667 266.496 147.114667 358.826666L666.453333 351.530667l128.213334 128.170666 200.832 200.704z m-93.525334 162.816l-235.52-235.349334-368.896 368.597334A510.506667 510.506667 0 0 0 512 1023.274667c156.16 0 296.106667-69.888 389.973333-180.053334h0.042667z" fill="#10B981"/></svg>
|
||||||
|
if (id.startsWith('windows')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M56.888889 227.555556l398.222222-70.542223V512H56.888889V227.555556z m0 625.777777l398.222222 70.542223V568.888889H56.888889v284.444444zM512 147.342222L1024 56.888889v455.111111H512V147.342222z m0 786.204445L1024 1024v-455.111111H512v364.657778z" fill="#16C6FE"/></svg>
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState, type ReactNode } from 'react'
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
Search,
|
||||||
Server,
|
Server,
|
||||||
Square,
|
Square,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -46,6 +47,12 @@ export default function Containers() {
|
|||||||
const [showTasks, setShowTasks] = useState(false)
|
const [showTasks, setShowTasks] = useState(false)
|
||||||
const [tasks, setTasks] = useState<Task[]>([])
|
const [tasks, setTasks] = useState<Task[]>([])
|
||||||
const [queuedCreates, setQueuedCreates] = useState<Record<string, CreateContainerRequest>>({})
|
const [queuedCreates, setQueuedCreates] = useState<Record<string, CreateContainerRequest>>({})
|
||||||
|
const [searchText, setSearchText] = useState('')
|
||||||
|
const [typeFilter, setTypeFilter] = useState('all')
|
||||||
|
const [systemFilter, setSystemFilter] = useState('all')
|
||||||
|
const [statusFilter, setStatusFilter] = useState('all')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(10)
|
||||||
|
|
||||||
const refreshUsage = useCallback(async (items: Container[]) => {
|
const refreshUsage = useCallback(async (items: Container[]) => {
|
||||||
const targets = items.filter((container) => container.status === 'running')
|
const targets = items.filter((container) => container.status === 'running')
|
||||||
@@ -102,18 +109,6 @@ export default function Containers() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleAll = () => {
|
|
||||||
const selectableIDs = displayContainers
|
|
||||||
.filter((container) => !container.isPlaceholder && !taskStatusMap[container.id] && !taskNameMap[container.name])
|
|
||||||
.map((container) => container.id)
|
|
||||||
|
|
||||||
if (selected.size === selectableIDs.length) {
|
|
||||||
setSelected(new Set())
|
|
||||||
} else {
|
|
||||||
setSelected(new Set(selectableIDs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map of container_id -> current task status.
|
// Map of container_id -> current task status.
|
||||||
// For create tasks, container_id may be 0 initially but gets set after creation,
|
// For create tasks, container_id may be 0 initially but gets set after creation,
|
||||||
// so we also index by container_name as fallback for placeholder items.
|
// so we also index by container_name as fallback for placeholder items.
|
||||||
@@ -170,6 +165,45 @@ export default function Containers() {
|
|||||||
|
|
||||||
const displayContainers = buildDisplayContainers(containers, queuedCreates, tasks)
|
const displayContainers = buildDisplayContainers(containers, queuedCreates, tasks)
|
||||||
const activeTaskCount = tasks.filter((task) => task.status === 'pending' || task.status === 'running').length
|
const activeTaskCount = tasks.filter((task) => task.status === 'pending' || task.status === 'running').length
|
||||||
|
const systemOptions = useMemo(() => buildSystemOptions(displayContainers), [displayContainers])
|
||||||
|
const filteredContainers = useMemo(() => {
|
||||||
|
return filterContainers(displayContainers, {
|
||||||
|
search: searchText,
|
||||||
|
type: typeFilter,
|
||||||
|
system: systemFilter,
|
||||||
|
status: statusFilter,
|
||||||
|
taskStatusMap,
|
||||||
|
taskNameMap,
|
||||||
|
})
|
||||||
|
}, [displayContainers, searchText, typeFilter, systemFilter, statusFilter, tasks])
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filteredContainers.length / pageSize))
|
||||||
|
const currentPage = Math.min(page, totalPages)
|
||||||
|
const pageStart = (currentPage - 1) * pageSize
|
||||||
|
const pageContainers = filteredContainers.slice(pageStart, pageStart + pageSize)
|
||||||
|
const selectableIDs = filteredContainers
|
||||||
|
.filter((container) => !container.isPlaceholder && !taskStatusMap[container.id] && !taskNameMap[container.name])
|
||||||
|
.map((container) => container.id)
|
||||||
|
const allFilteredSelected = selectableIDs.length > 0 && selectableIDs.every((id) => selected.has(id))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1)
|
||||||
|
}, [searchText, typeFilter, systemFilter, statusFilter, pageSize])
|
||||||
|
|
||||||
|
const toggleAll = () => {
|
||||||
|
if (allFilteredSelected) {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
selectableIDs.forEach((id) => next.delete(id))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
selectableIDs.forEach((id) => next.add(id))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleCreateQueued = async (items: CreateContainerRequest[]) => {
|
const handleCreateQueued = async (items: CreateContainerRequest[]) => {
|
||||||
setQueuedCreates((current) => {
|
setQueuedCreates((current) => {
|
||||||
@@ -193,29 +227,16 @@ export default function Containers() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div className="min-w-[180px]">
|
||||||
<h1 className="text-2xl font-bold text-black">容器管理</h1>
|
<h1 className="text-2xl font-bold text-black">容器管理</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">共 {displayContainers.length} 个容器{selected.size > 0 && `,已选 ${selected.size} 个`}</p>
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
共 {displayContainers.length} 个容器
|
||||||
|
{filteredContainers.length !== displayContainers.length && `,筛选后 ${filteredContainers.length} 个`}
|
||||||
|
{selected.size > 0 && `,已选 ${selected.size} 个`}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
{selected.size > 0 && (
|
|
||||||
<div className="flex items-center gap-1.5 bg-gray-50 border border-gray-200 rounded-md px-3 py-1.5">
|
|
||||||
<span className="text-xs text-gray-500 mr-1">{selected.size} 个</span>
|
|
||||||
<button onClick={() => handleBatchAction('start')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex items-center gap-1 px-2.5 py-1 text-xs text-gray-700 hover:bg-gray-200 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
|
|
||||||
<Play className="w-3 h-3" />{batchLoading ? '执行中...' : '开机'}
|
|
||||||
</button>
|
|
||||||
<button onClick={() => handleBatchAction('stop')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex items-center gap-1 px-2.5 py-1 text-xs text-gray-700 hover:bg-gray-200 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
|
|
||||||
<Square className="w-3 h-3" />{batchLoading ? '执行中...' : '关机'}
|
|
||||||
</button>
|
|
||||||
<button onClick={() => handleBatchAction('restart')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex items-center gap-1 px-2.5 py-1 text-xs text-gray-700 hover:bg-gray-200 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
|
|
||||||
<RotateCcw className="w-3 h-3" />{batchLoading ? '执行中...' : '重启'}
|
|
||||||
</button>
|
|
||||||
<button onClick={() => handleBatchAction('delete')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex items-center gap-1 px-2.5 py-1 text-xs text-red-600 hover:bg-red-50 rounded border border-red-200 disabled:opacity-50 disabled:cursor-not-allowed">
|
|
||||||
<Trash2 className="w-3 h-3" />{batchLoading ? '执行中...' : '删除'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={handleRefreshList}
|
onClick={handleRefreshList}
|
||||||
disabled={refreshing}
|
disabled={refreshing}
|
||||||
@@ -249,6 +270,84 @@ export default function Containers() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{displayContainers.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="relative w-[260px]">
|
||||||
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
value={searchText}
|
||||||
|
onChange={(event) => setSearchText(event.target.value)}
|
||||||
|
className="h-8 w-full rounded-md border border-gray-300 bg-white pl-8 pr-2 text-xs text-black outline-none focus:border-black focus:ring-2 focus:ring-black"
|
||||||
|
placeholder="搜索名称、ID、UUID、IP"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={(event) => setTypeFilter(event.target.value)}
|
||||||
|
className="h-8 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 outline-none focus:border-black focus:ring-2 focus:ring-black"
|
||||||
|
title="类型筛选"
|
||||||
|
>
|
||||||
|
<option value="all">全部类型</option>
|
||||||
|
<option value="lxc">LXC</option>
|
||||||
|
<option value="kvm">KVM</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={systemFilter}
|
||||||
|
onChange={(event) => setSystemFilter(event.target.value)}
|
||||||
|
className="h-8 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 outline-none focus:border-black focus:ring-2 focus:ring-black"
|
||||||
|
title="系统筛选"
|
||||||
|
>
|
||||||
|
<option value="all">全部系统</option>
|
||||||
|
{systemOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(event) => setStatusFilter(event.target.value)}
|
||||||
|
className="h-8 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 outline-none focus:border-black focus:ring-2 focus:ring-black"
|
||||||
|
title="状态筛选"
|
||||||
|
>
|
||||||
|
<option value="all">全部状态</option>
|
||||||
|
<option value="running">在线</option>
|
||||||
|
<option value="stopped">离线</option>
|
||||||
|
<option value="task">任务中</option>
|
||||||
|
<option value="creating">创建中</option>
|
||||||
|
<option value="failed">失败</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(event) => setPageSize(Number(event.target.value))}
|
||||||
|
className="h-8 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 outline-none focus:border-black focus:ring-2 focus:ring-black"
|
||||||
|
title="每页数量"
|
||||||
|
>
|
||||||
|
<option value={10}>10 / 页</option>
|
||||||
|
<option value={20}>20 / 页</option>
|
||||||
|
<option value={50}>50 / 页</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected.size > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||||
|
<span className="text-xs text-gray-500">{selected.size} 个</span>
|
||||||
|
<button onClick={() => handleBatchAction('start')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex h-8 items-center gap-1 px-2.5 text-xs text-gray-700 hover:bg-gray-100 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
<Play className="w-3 h-3" />{batchLoading ? '执行中...' : '开机'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleBatchAction('stop')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex h-8 items-center gap-1 px-2.5 text-xs text-gray-700 hover:bg-gray-100 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
<Square className="w-3 h-3" />{batchLoading ? '执行中...' : '关机'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleBatchAction('restart')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex h-8 items-center gap-1 px-2.5 text-xs text-gray-700 hover:bg-gray-100 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
<RotateCcw className="w-3 h-3" />{batchLoading ? '执行中...' : '重启'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleBatchAction('delete')} disabled={batchLoading || hasActiveTasks(tasks)} className="inline-flex h-8 items-center gap-1 px-2.5 text-xs text-red-600 hover:bg-red-50 rounded border border-red-200 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
<Trash2 className="w-3 h-3" />{batchLoading ? '执行中...' : '删除'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{displayContainers.length === 0 ? (
|
{displayContainers.length === 0 ? (
|
||||||
<div className="bg-white border border-gray-200 rounded-lg p-12 text-center">
|
<div className="bg-white border border-gray-200 rounded-lg p-12 text-center">
|
||||||
<div className="w-16 h-16 bg-gray-100 rounded-lg flex items-center justify-center mx-auto mb-4">
|
<div className="w-16 h-16 bg-gray-100 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||||
@@ -260,14 +359,15 @@ export default function Containers() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[1200px]">
|
<table className="w-full min-w-[1260px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-gray-200 bg-gray-50">
|
<tr className="border-b border-gray-200 bg-gray-50">
|
||||||
<th className="w-10 px-3 py-3">
|
<th className="w-10 px-3 py-3">
|
||||||
{!isSubUser && (
|
{!isSubUser && (
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={displayContainers.length > 0 && selected.size === displayContainers.filter((container) => !container.isPlaceholder && !taskStatusMap[container.id] && !taskNameMap[container.name]).length}
|
checked={allFilteredSelected}
|
||||||
|
disabled={selectableIDs.length === 0}
|
||||||
onChange={toggleAll}
|
onChange={toggleAll}
|
||||||
className="w-4 h-4 rounded border-gray-300 text-black focus:ring-black accent-black"
|
className="w-4 h-4 rounded border-gray-300 text-black focus:ring-black accent-black"
|
||||||
/>
|
/>
|
||||||
@@ -277,6 +377,7 @@ export default function Containers() {
|
|||||||
<TableHead>名称</TableHead>
|
<TableHead>名称</TableHead>
|
||||||
<TableHead>状态</TableHead>
|
<TableHead>状态</TableHead>
|
||||||
<TableHead>系统</TableHead>
|
<TableHead>系统</TableHead>
|
||||||
|
<TableHead>类型</TableHead>
|
||||||
<TableHead icon><Cpu className="w-3.5 h-3.5" />CPU</TableHead>
|
<TableHead icon><Cpu className="w-3.5 h-3.5" />CPU</TableHead>
|
||||||
<TableHead icon><MemoryStick className="w-3.5 h-3.5" />MEMORY</TableHead>
|
<TableHead icon><MemoryStick className="w-3.5 h-3.5" />MEMORY</TableHead>
|
||||||
<TableHead icon><HardDrive className="w-3.5 h-3.5" />DISK</TableHead>
|
<TableHead icon><HardDrive className="w-3.5 h-3.5" />DISK</TableHead>
|
||||||
@@ -287,15 +388,21 @@ export default function Containers() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100">
|
<tbody className="divide-y divide-gray-100">
|
||||||
{displayContainers.map((container) => {
|
{pageContainers.map((container) => {
|
||||||
const isRunning = container.status === 'running'
|
const isRunning = container.status === 'running'
|
||||||
const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask
|
const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask
|
||||||
const isPlaceholder = !!container.isPlaceholder
|
const isPlaceholder = !!container.isPlaceholder
|
||||||
const usage = usageByName[container.name]
|
const usage = usageByName[container.name]
|
||||||
|
const isKVM = (container.virtualization || 'lxc') === 'kvm'
|
||||||
|
|
||||||
const cpuPct = isRunning ? clamp(usage?.cpu_usage_pct || 0) : 0
|
const cpuPct = isRunning
|
||||||
const ramPct = isRunning && container.ram_mb > 0
|
? clamp((usage?.cpu_usage_pct || 0) / (isKVM ? (container.vcpu || 1) : 1))
|
||||||
? clamp(((usage?.memory_usage_bytes || 0) / (container.ram_mb * 1024 * 1024)) * 100)
|
: 0
|
||||||
|
const ramTotalBytes = usage?.memory_total_bytes && usage.memory_total_bytes > 0
|
||||||
|
? usage.memory_total_bytes
|
||||||
|
: container.ram_mb * 1024 * 1024
|
||||||
|
const ramPct = isRunning && ramTotalBytes > 0
|
||||||
|
? clamp(((usage?.memory_usage_bytes || 0) / ramTotalBytes) * 100)
|
||||||
: 0
|
: 0
|
||||||
const diskPct = container.disk_gb > 0
|
const diskPct = container.disk_gb > 0
|
||||||
? clamp(((usage?.disk_usage_bytes || 0) / (container.disk_gb * 1024 * 1024 * 1024)) * 100)
|
? clamp(((usage?.disk_usage_bytes || 0) / (container.disk_gb * 1024 * 1024 * 1024)) * 100)
|
||||||
@@ -337,6 +444,9 @@ export default function Containers() {
|
|||||||
{getTemplateName(container.template)}
|
{getTemplateName(container.template)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-2.5 py-2 align-top">
|
||||||
|
<RuntimeBadge runtime={container.virtualization || 'lxc'} />
|
||||||
|
</td>
|
||||||
<td className="px-2.5 py-2 align-top">
|
<td className="px-2.5 py-2 align-top">
|
||||||
<ProgressCell pct={cpuPct} />
|
<ProgressCell pct={cpuPct} />
|
||||||
</td>
|
</td>
|
||||||
@@ -398,10 +508,54 @@ export default function Containers() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{filteredContainers.length === 0 ? (
|
||||||
|
<div className="border-t border-gray-100 px-4 py-10 text-center text-sm text-gray-500">
|
||||||
|
没有匹配的容器
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-gray-100 px-4 py-3">
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
显示 {pageStart + 1}-{Math.min(pageStart + pageSize, filteredContainers.length)} / {filteredContainers.length}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
首页
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((value) => Math.max(1, value - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<span className="px-2 text-xs text-gray-500">
|
||||||
|
{currentPage} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((value) => Math.min(totalPages, value + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(totalPages)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
末页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CreateContainerModal isOpen={showCreate} onClose={() => setShowCreate(false)} onSuccess={handleCreateQueued} />
|
<CreateContainerModal isOpen={showCreate} onClose={() => setShowCreate(false)} onSuccess={handleCreateQueued} existingNames={containers.map(c => c.name)} />
|
||||||
{showTasks && (
|
{showTasks && (
|
||||||
<TaskQueueModal
|
<TaskQueueModal
|
||||||
tasks={tasks}
|
tasks={tasks}
|
||||||
@@ -484,6 +638,15 @@ function StatusBadge({ running, task, placeholder }: { running: boolean; task?:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RuntimeBadge({ runtime }: { runtime: string }) {
|
||||||
|
const normalized = runtime === 'kvm' ? 'kvm' : 'lxc'
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex rounded px-2 py-0.5 text-[11px] font-medium ${normalized === 'kvm' ? 'bg-indigo-50 text-indigo-700' : 'bg-gray-100 text-gray-700'}`}>
|
||||||
|
{normalized.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function buildDisplayContainers(
|
function buildDisplayContainers(
|
||||||
containers: Container[],
|
containers: Container[],
|
||||||
queuedCreates: Record<string, CreateContainerRequest>,
|
queuedCreates: Record<string, CreateContainerRequest>,
|
||||||
@@ -515,6 +678,7 @@ function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
|
|||||||
id: 0,
|
id: 0,
|
||||||
uuid: '',
|
uuid: '',
|
||||||
name: cfg.name,
|
name: cfg.name,
|
||||||
|
virtualization: cfg.virtualization || 'lxc',
|
||||||
template: cfg.template_id,
|
template: cfg.template_id,
|
||||||
vcpu: cfg.vcpu,
|
vcpu: cfg.vcpu,
|
||||||
ram_mb: cfg.ram_mb,
|
ram_mb: cfg.ram_mb,
|
||||||
@@ -585,6 +749,92 @@ function hasActiveTasks(tasks: Task[]) {
|
|||||||
return tasks.some((task) => task.status === 'pending' || task.status === 'running')
|
return tasks.some((task) => task.status === 'pending' || task.status === 'running')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ContainerFilters = {
|
||||||
|
search: string
|
||||||
|
type: string
|
||||||
|
system: string
|
||||||
|
status: string
|
||||||
|
taskStatusMap: Record<number, Task>
|
||||||
|
taskNameMap: Record<string, Task>
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterContainers(containers: DisplayContainer[], filters: ContainerFilters): DisplayContainer[] {
|
||||||
|
const keyword = filters.search.trim().toLowerCase()
|
||||||
|
return containers.filter((container) => {
|
||||||
|
const task = (container.id > 0 ? filters.taskStatusMap[container.id] : filters.taskNameMap[container.name]) || container.createTask
|
||||||
|
if (filters.system !== 'all' && getSystemFilterValue(container.template) !== filters.system) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (filters.type !== 'all' && (container.virtualization || 'lxc') !== filters.type) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (filters.status !== 'all' && getContainerStatusFilterValue(container, task) !== filters.status) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!keyword) return true
|
||||||
|
|
||||||
|
const fields = [
|
||||||
|
String(container.id),
|
||||||
|
container.name,
|
||||||
|
container.uuid,
|
||||||
|
container.ip,
|
||||||
|
container.ipv6,
|
||||||
|
container.template,
|
||||||
|
container.virtualization || 'lxc',
|
||||||
|
getTemplateName(container.template),
|
||||||
|
getSystemFilterLabel(getSystemFilterValue(container.template)),
|
||||||
|
String(container.ssh_port || ''),
|
||||||
|
]
|
||||||
|
return fields.some((field) => field.toLowerCase().includes(keyword))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSystemOptions(containers: DisplayContainer[]) {
|
||||||
|
const systems = new Map<string, string>()
|
||||||
|
for (const container of containers) {
|
||||||
|
const value = getSystemFilterValue(container.template)
|
||||||
|
systems.set(value, getSystemFilterLabel(value))
|
||||||
|
}
|
||||||
|
return Array.from(systems.entries())
|
||||||
|
.map(([value, label]) => ({ value, label }))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSystemFilterValue(template: string) {
|
||||||
|
const normalized = template.startsWith('kvm-') ? template.slice(4) : template
|
||||||
|
if (normalized.startsWith('ubuntu')) return 'ubuntu'
|
||||||
|
if (normalized.startsWith('debian')) return 'debian'
|
||||||
|
if (normalized.startsWith('alpine')) return 'alpine'
|
||||||
|
if (normalized.startsWith('centos')) return 'centos'
|
||||||
|
if (normalized.startsWith('archlinux')) return 'archlinux'
|
||||||
|
if (normalized.startsWith('fedora')) return 'fedora'
|
||||||
|
if (normalized.startsWith('rockylinux')) return 'rockylinux'
|
||||||
|
if (normalized.startsWith('windows')) return 'windows'
|
||||||
|
return normalized || 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSystemFilterLabel(system: string) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
ubuntu: 'Ubuntu',
|
||||||
|
debian: 'Debian',
|
||||||
|
alpine: 'Alpine',
|
||||||
|
centos: 'CentOS',
|
||||||
|
archlinux: 'Arch Linux',
|
||||||
|
fedora: 'Fedora',
|
||||||
|
rockylinux: 'Rocky Linux',
|
||||||
|
windows: 'Windows',
|
||||||
|
unknown: '未知系统',
|
||||||
|
}
|
||||||
|
return labels[system] || system
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContainerStatusFilterValue(container: DisplayContainer, task?: Task) {
|
||||||
|
if (task?.status === 'failed') return 'failed'
|
||||||
|
if (container.isPlaceholder || task?.type === 'create') return 'creating'
|
||||||
|
if (task && task.status !== 'done' && task.status !== 'failed') return 'task'
|
||||||
|
return container.status === 'running' ? 'running' : 'stopped'
|
||||||
|
}
|
||||||
|
|
||||||
function taskLineLabel(task: Task, actionLabels: Record<string, string>) {
|
function taskLineLabel(task: Task, actionLabels: Record<string, string>) {
|
||||||
if (task.status === 'failed') return task.type === 'create' ? '初始化失败' : '处理失败'
|
if (task.status === 'failed') return task.type === 'create' ? '初始化失败' : '处理失败'
|
||||||
if (task.type === 'create' && task.status === 'done') return '初始化完成'
|
if (task.type === 'create' && task.status === 'done') return '初始化完成'
|
||||||
@@ -697,12 +947,19 @@ function getTemplateName(id: string) {
|
|||||||
'archlinux-current': 'Arch Linux',
|
'archlinux-current': 'Arch Linux',
|
||||||
'fedora-44': 'Fedora 44',
|
'fedora-44': 'Fedora 44',
|
||||||
'rockylinux-10': 'Rocky 10',
|
'rockylinux-10': 'Rocky 10',
|
||||||
|
'kvm-ubuntu-noble': 'Ubuntu 24.04',
|
||||||
|
'kvm-ubuntu-jammy': 'Ubuntu 22.04',
|
||||||
|
'kvm-debian-bookworm': 'Debian 12',
|
||||||
|
'kvm-debian-bullseye': 'Debian 11',
|
||||||
|
'kvm-rockylinux-9': 'Rocky 9',
|
||||||
|
'kvm-windows-10': 'Windows 10',
|
||||||
}
|
}
|
||||||
return map[id] || id
|
return map[id] || id
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTemplateIcon(id: string): ReactNode {
|
function getTemplateIcon(id: string): ReactNode {
|
||||||
const size = 'w-4 h-4'
|
const size = 'w-4 h-4'
|
||||||
|
id = id.startsWith('kvm-') ? id.slice(4) : id
|
||||||
if (id.startsWith('debian')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M935.473 375.359a558.602 558.602 0 0 0-22.351-114.655l13.308 4.436c-35.66-81.385-90.086-163.623-153.556-199.282-8.701-5.118-35.147 4.948-26.616-12.113s-37.536-8.19-56.816-4.778c-26.275 4.266-30.028-29.175-75.071-35.83-25.593-3.582-32.247 18.427-44.702 13.309-23.545-9.384-20.816-27.64-57.669-9.384-18.427 9.042 11.602-26.105-49.138-4.607L457.744 0C349.23 41.63 318.69 76.266 288.15 79.337c-6.996 0-34.124 32.759-53.574 53.062-17.062 17.062-26.275 36.512-49.138 39.583l-17.062 70.636A136.494 136.494 0 0 0 119.41 339.7a66.711 66.711 0 0 1 4.436-52.892c-17.062 6.825-45.896 17.062-29.687 96.91 12.796 63.13-5.29 135.13 10.066 204.742 4.777 20.986 0 40.095 6.142 51.185 107.66 235.794 208.836 392.08 472.44 384.06l4.436-8.872c-28.152-6.825-55.11-17.062-111.584-30.711-18.597-4.436-23.033-34.124-40.265-44.19-9.384-5.46-28.323-4.095-37.195-9.896s4.266-21.668-19.962-14.332c-8.531 2.56-13.82-10.92-20.133-17.061s0-23.716-23.375-24.74-18.426-29.687-19.791-44.702c-12.114 1.536-1.195-1.535-13.308 4.436a63.64 63.64 0 0 1-23.887-31.735c-10.237-48.967-10.578-21.497-15.014-32.417a322.297 322.297 0 0 0-19.28-42.142l26.787 8.872h4.436l4.436-13.309-26.616-8.701h31.223c-7.678 13.99 2.047 5.29-13.479 8.872v13.308l22.35-8.872v-13.308c-20.644-10.237-28.663-13.308-49.137-22.01l9.043 8.872v4.436h-49.138c-22.01-14.843-13.99-31.734-17.915-53.062 17.062 0 9.213 6.655 17.062-13.137l-17.062 8.872 13.308-33.953-13.308 13.138c-29.176-38.73-16.209-97.764-11.943-152.02A180.684 180.684 0 0 1 211.2 372.97c8.872-10.067 5.119-25.251 5.46-37.195l31.223-26.445H265.8c7.678 17.061 4.777 5.46 0 22.01l8.872 4.435c7.166-8.701 6.142-5.971 8.872-22.01-10.066-10.578-6.995-9.895-26.616-13.308A119.432 119.432 0 0 1 368.51 243.13l4.436-13.308-17.915 9.043-4.436-13.138a109.536 109.536 0 0 1 76.095-27.128c6.313 0 6.996-17.062 12.797-19.45 161.574-60.57 309.33 9.383 371.093 147.413a324.173 324.173 0 0 1 8.19 34.123c17.061 56.987-7.167 121.48 9.725 155.604-7.849 36-36.683 13.82-40.266 30.881-8.531 41.29-14.844 59.717-40.778 78.826a196.38 196.38 0 0 1-30.711 22.35 84.285 84.285 0 0 0 22.35-39.753c-106.294 111.584-262.58 63.981-290.049-105.954a101.176 101.176 0 0 1 35.147-93.157c92.987-87.527 150.144-52.38 205.765-20.474l-8.872-30.711c-32.93-24.398-17.062-19.792-9.043-57.328v-4.436l-17.915-13.137c2.56 10.066 1.024 5.289 9.043 17.061-4.436 16.039 0 9.043-8.872 17.062-15.014 9.725-23.716 7.337-44.702 4.436l4.436-13.308-13.308-13.308c0 11.773-4.095 2.73 0 17.062-126.086 9.896-218.05 80.02-178.636 260.191a220.608 220.608 0 0 0 8.872 44.19l-8.872 8.702-4.436-26.446h-13.48l-4.435 13.308c-12.626-25.763-0.853 10.75 40.265 52.892a149.29 149.29 0 0 0 12.797 12.625c47.773 34.124 113.29 81.385 201.328 49.138h9.043v-4.436l-102.37-13.308-4.436-8.701c106.806 24.74 176.93-8.531 236.646-48.456 13.138-17.062 11.431-24.057 22.18-9.043 19.28-17.061 3.925-26.786 13.48-44.019 6.483-11.772 32.587-17.062 44.7-35.318l40.096-136.494h-17.062c3.071-14.332 22.522-34.123-4.436-48.455-2.559-1.536 9.043-1.365 8.872-4.266a145.537 145.537 0 0 0-22.18-66.37c33.1 21.669 36.342 68.247 53.574 105.783v8.872h4.436V375.36zM453.308 595.455l-9.555-26.446 62.446 57.328zM146.196 211.736l-23.204-4.436v39.754c16.72-10.578 18.939-10.407 22.35-35.318z m574.981 176.419a57.498 57.498 0 0 0-17.062 44.19l13.48 8.701a37.877 37.877 0 0 0 4.435-52.891zM868.42 555.872c26.275-11.602 54.598-58.01 35.83-97.081l-35.83 96.91z m-174.03-79.508c-15.697 11.773-19.791 13.308-22.35 39.754l13.307 8.872 17.915-8.872a60.228 60.228 0 0 0 4.436-48.455c-8.36 13.478-2.559 20.644-13.308 8.701z m-67.053 79.508c15.868-10.92 11.944-14.844 17.915-22.18v-4.778a292.097 292.097 0 0 1-62.446 0c-13.137-13.99-13.308-29.346-31.223-39.583 17.062 35.147 3.242 38.218 31.223 61.764a158.162 158.162 0 0 0 40.095 4.436c1.536 0-6.824-1.024 4.436 0zM207.79 520.554H194.31l-8.872 8.702c9.555 10.237 5.46 7.166 13.308-4.436L212.225 547l4.436-17.062-8.872-8.701z m17.062 57.328l4.436-8.873c-10.067-8.701 0-3.583-13.308 0l-13.309-17.061 4.436 17.061v8.873h17.062z" fill="#CE0C48"/></svg>
|
if (id.startsWith('debian')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M935.473 375.359a558.602 558.602 0 0 0-22.351-114.655l13.308 4.436c-35.66-81.385-90.086-163.623-153.556-199.282-8.701-5.118-35.147 4.948-26.616-12.113s-37.536-8.19-56.816-4.778c-26.275 4.266-30.028-29.175-75.071-35.83-25.593-3.582-32.247 18.427-44.702 13.309-23.545-9.384-20.816-27.64-57.669-9.384-18.427 9.042 11.602-26.105-49.138-4.607L457.744 0C349.23 41.63 318.69 76.266 288.15 79.337c-6.996 0-34.124 32.759-53.574 53.062-17.062 17.062-26.275 36.512-49.138 39.583l-17.062 70.636A136.494 136.494 0 0 0 119.41 339.7a66.711 66.711 0 0 1 4.436-52.892c-17.062 6.825-45.896 17.062-29.687 96.91 12.796 63.13-5.29 135.13 10.066 204.742 4.777 20.986 0 40.095 6.142 51.185 107.66 235.794 208.836 392.08 472.44 384.06l4.436-8.872c-28.152-6.825-55.11-17.062-111.584-30.711-18.597-4.436-23.033-34.124-40.265-44.19-9.384-5.46-28.323-4.095-37.195-9.896s4.266-21.668-19.962-14.332c-8.531 2.56-13.82-10.92-20.133-17.061s0-23.716-23.375-24.74-18.426-29.687-19.791-44.702c-12.114 1.536-1.195-1.535-13.308 4.436a63.64 63.64 0 0 1-23.887-31.735c-10.237-48.967-10.578-21.497-15.014-32.417a322.297 322.297 0 0 0-19.28-42.142l26.787 8.872h4.436l4.436-13.309-26.616-8.701h31.223c-7.678 13.99 2.047 5.29-13.479 8.872v13.308l22.35-8.872v-13.308c-20.644-10.237-28.663-13.308-49.137-22.01l9.043 8.872v4.436h-49.138c-22.01-14.843-13.99-31.734-17.915-53.062 17.062 0 9.213 6.655 17.062-13.137l-17.062 8.872 13.308-33.953-13.308 13.138c-29.176-38.73-16.209-97.764-11.943-152.02A180.684 180.684 0 0 1 211.2 372.97c8.872-10.067 5.119-25.251 5.46-37.195l31.223-26.445H265.8c7.678 17.061 4.777 5.46 0 22.01l8.872 4.435c7.166-8.701 6.142-5.971 8.872-22.01-10.066-10.578-6.995-9.895-26.616-13.308A119.432 119.432 0 0 1 368.51 243.13l4.436-13.308-17.915 9.043-4.436-13.138a109.536 109.536 0 0 1 76.095-27.128c6.313 0 6.996-17.062 12.797-19.45 161.574-60.57 309.33 9.383 371.093 147.413a324.173 324.173 0 0 1 8.19 34.123c17.061 56.987-7.167 121.48 9.725 155.604-7.849 36-36.683 13.82-40.266 30.881-8.531 41.29-14.844 59.717-40.778 78.826a196.38 196.38 0 0 1-30.711 22.35 84.285 84.285 0 0 0 22.35-39.753c-106.294 111.584-262.58 63.981-290.049-105.954a101.176 101.176 0 0 1 35.147-93.157c92.987-87.527 150.144-52.38 205.765-20.474l-8.872-30.711c-32.93-24.398-17.062-19.792-9.043-57.328v-4.436l-17.915-13.137c2.56 10.066 1.024 5.289 9.043 17.061-4.436 16.039 0 9.043-8.872 17.062-15.014 9.725-23.716 7.337-44.702 4.436l4.436-13.308-13.308-13.308c0 11.773-4.095 2.73 0 17.062-126.086 9.896-218.05 80.02-178.636 260.191a220.608 220.608 0 0 0 8.872 44.19l-8.872 8.702-4.436-26.446h-13.48l-4.435 13.308c-12.626-25.763-0.853 10.75 40.265 52.892a149.29 149.29 0 0 0 12.797 12.625c47.773 34.124 113.29 81.385 201.328 49.138h9.043v-4.436l-102.37-13.308-4.436-8.701c106.806 24.74 176.93-8.531 236.646-48.456 13.138-17.062 11.431-24.057 22.18-9.043 19.28-17.061 3.925-26.786 13.48-44.019 6.483-11.772 32.587-17.062 44.7-35.318l40.096-136.494h-17.062c3.071-14.332 22.522-34.123-4.436-48.455-2.559-1.536 9.043-1.365 8.872-4.266a145.537 145.537 0 0 0-22.18-66.37c33.1 21.669 36.342 68.247 53.574 105.783v8.872h4.436V375.36zM453.308 595.455l-9.555-26.446 62.446 57.328zM146.196 211.736l-23.204-4.436v39.754c16.72-10.578 18.939-10.407 22.35-35.318z m574.981 176.419a57.498 57.498 0 0 0-17.062 44.19l13.48 8.701a37.877 37.877 0 0 0 4.435-52.891zM868.42 555.872c26.275-11.602 54.598-58.01 35.83-97.081l-35.83 96.91z m-174.03-79.508c-15.697 11.773-19.791 13.308-22.35 39.754l13.307 8.872 17.915-8.872a60.228 60.228 0 0 0 4.436-48.455c-8.36 13.478-2.559 20.644-13.308 8.701z m-67.053 79.508c15.868-10.92 11.944-14.844 17.915-22.18v-4.778a292.097 292.097 0 0 1-62.446 0c-13.137-13.99-13.308-29.346-31.223-39.583 17.062 35.147 3.242 38.218 31.223 61.764a158.162 158.162 0 0 0 40.095 4.436c1.536 0-6.824-1.024 4.436 0zM207.79 520.554H194.31l-8.872 8.702c9.555 10.237 5.46 7.166 13.308-4.436L212.225 547l4.436-17.062-8.872-8.701z m17.062 57.328l4.436-8.873c-10.067-8.701 0-3.583-13.308 0l-13.309-17.061 4.436 17.061v8.873h17.062z" fill="#CE0C48"/></svg>
|
||||||
if (id.startsWith('ubuntu')) return <svg className={size} viewBox="0 0 1024 1024"><circle cx="512" cy="512" r="511" fill="#DD4814"/><path d="M164.532 442.532c-37.676 0-68.2 30.524-68.2 68.2 0 37.656 30.524 68.184 68.2 68.184 37.66 0 68.184-30.528 68.184-68.184 0-37.676-30.524-68.2-68.184-68.2z m486.86 309.912c-32.612 18.84-43.8 60.52-24.96 93.116 18.82 32.616 60.5 43.796 93.116 24.96 32.612-18.82 43.796-60.5 24.96-93.12-18.82-32.592-60.524-43.772-93.116-24.956z m-338.744-241.712c0-67.384 33.472-126.92 84.684-162.968L347.48 264.268c-59.656 39.88-104.048 100.816-122.496 172.188 21.528 17.56 35.304 44.3 35.304 74.272 0 29.956-13.776 56.696-35.304 74.26C243.408 656.376 287.8 717.32 347.48 757.2l49.852-83.52c-51.212-36.028-84.684-95.56-84.684-162.948z m199.168-199.188c104.052 0 189.42 79.776 198.38 181.52l97.16-1.432c-4.776-75.112-37.592-142.544-88.008-192.128-25.928 9.796-55.88 8.296-81.76-6.624-25.932-14.964-42.192-40.208-46.636-67.608a297.04 297.04 0 0 0-79.14-10.76 295.148 295.148 0 0 0-131.276 30.652l47.38 84.908a198.384 198.384 0 0 1 83.9-18.528z m0 398.36a198.404 198.404 0 0 1-83.896-18.528l-47.38 84.9a294.848 294.848 0 0 0 131.28 30.684 296.16 296.16 0 0 0 79.136-10.788c4.444-27.4 20.708-52.62 46.632-67.608 25.904-14.948 55.836-16.42 81.76-6.624 50.42-49.584 83.232-117.016 88.016-192.128l-97.188-1.432c-8.94 101.772-94.304 181.52-198.36 181.52z m139.552-440.924c32.616 18.832 74.3 7.68 93.116-24.936 18.84-32.616 7.68-74.3-24.936-93.14-32.616-18.816-74.296-7.64-93.14 24.976-18.812 32.6-7.632 74.28 24.96 93.1z" fill="#FFF"/></svg>
|
if (id.startsWith('ubuntu')) return <svg className={size} viewBox="0 0 1024 1024"><circle cx="512" cy="512" r="511" fill="#DD4814"/><path d="M164.532 442.532c-37.676 0-68.2 30.524-68.2 68.2 0 37.656 30.524 68.184 68.2 68.184 37.66 0 68.184-30.528 68.184-68.184 0-37.676-30.524-68.2-68.184-68.2z m486.86 309.912c-32.612 18.84-43.8 60.52-24.96 93.116 18.82 32.616 60.5 43.796 93.116 24.96 32.612-18.82 43.796-60.5 24.96-93.12-18.82-32.592-60.524-43.772-93.116-24.956z m-338.744-241.712c0-67.384 33.472-126.92 84.684-162.968L347.48 264.268c-59.656 39.88-104.048 100.816-122.496 172.188 21.528 17.56 35.304 44.3 35.304 74.272 0 29.956-13.776 56.696-35.304 74.26C243.408 656.376 287.8 717.32 347.48 757.2l49.852-83.52c-51.212-36.028-84.684-95.56-84.684-162.948z m199.168-199.188c104.052 0 189.42 79.776 198.38 181.52l97.16-1.432c-4.776-75.112-37.592-142.544-88.008-192.128-25.928 9.796-55.88 8.296-81.76-6.624-25.932-14.964-42.192-40.208-46.636-67.608a297.04 297.04 0 0 0-79.14-10.76 295.148 295.148 0 0 0-131.276 30.652l47.38 84.908a198.384 198.384 0 0 1 83.9-18.528z m0 398.36a198.404 198.404 0 0 1-83.896-18.528l-47.38 84.9a294.848 294.848 0 0 0 131.28 30.684 296.16 296.16 0 0 0 79.136-10.788c4.444-27.4 20.708-52.62 46.632-67.608 25.904-14.948 55.836-16.42 81.76-6.624 50.42-49.584 83.232-117.016 88.016-192.128l-97.188-1.432c-8.94 101.772-94.304 181.52-198.36 181.52z m139.552-440.924c32.616 18.832 74.3 7.68 93.116-24.936 18.84-32.616 7.68-74.3-24.936-93.14-32.616-18.816-74.296-7.64-93.14 24.976-18.812 32.6-7.632 74.28 24.96 93.1z" fill="#FFF"/></svg>
|
||||||
if (id.startsWith('alpine')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M255.914667 68.565333L0 512l255.914667 443.434667h512.170666L1024 512 768.085333 68.565333H255.914667zM425.173333 303.786667L540.16 422.4l68.181333 68.053333 0.085334-0.085333 102.826666 100.821333c-8.533333 5.973333-16.469333 10.752-24.021333 14.677334a160.256 160.256 0 0 1-21.162667 9.258666 115.285333 115.285333 0 0 1-18.133333 4.736c-5.589333 0.981333-10.666667 1.450667-15.274667 1.450667-5.546667 0-10.325333-0.597333-14.421333-1.450667a56.192 56.192 0 0 1-10.24-3.072 40.533333 40.533333 0 0 1-8.533333-4.821333l-45.312-46.592-129.664-129.749333-46.933334 44.928-130.986666 131.072a41.557333 41.557333 0 0 1-8.533334 4.736 54.357333 54.357333 0 0 1-10.112 3.114666 70.826667 70.826667 0 0 1-14.421333 1.408c-4.608 0-9.685333-0.384-15.274667-1.322666a115.2 115.2 0 0 1-18.133333-4.864 159.914667 159.914667 0 0 1-21.162667-9.258667 223.061333 223.061333 0 0 1-24.021333-14.634667L425.173333 303.786667z m201.386667 33.493333l195.370667 196.181333 58.965333 57.728a223.573333 223.573333 0 0 1-24.064 14.677334 159.146667 159.146667 0 0 1-21.077333 9.258666 115.072 115.072 0 0 1-18.176 4.736c-5.546667 0.981333-10.709333 1.450667-15.36 1.450667-5.504 0-10.282667-0.597333-14.378667-1.450667a54.826667 54.826667 0 0 1-16.426667-6.229333 10.197333 10.197333 0 0 1-2.261333-1.706667l-52.565333-51.968-90.069334-90.069333-14.250666 14.250667L545.706667 418.133333l80.896-80.938666z m-254.549333 175.786667v107.904a90.794667 90.794667 0 0 1-15.189334-1.493334 117.973333 117.973333 0 0 1-18.005333-4.949333 158.208 158.208 0 0 1-20.821333-9.130667 222.592 222.592 0 0 1-23.68-14.506666l77.653333-77.866667z" fill="#0D597F"/></svg>
|
if (id.startsWith('alpine')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M255.914667 68.565333L0 512l255.914667 443.434667h512.170666L1024 512 768.085333 68.565333H255.914667zM425.173333 303.786667L540.16 422.4l68.181333 68.053333 0.085334-0.085333 102.826666 100.821333c-8.533333 5.973333-16.469333 10.752-24.021333 14.677334a160.256 160.256 0 0 1-21.162667 9.258666 115.285333 115.285333 0 0 1-18.133333 4.736c-5.589333 0.981333-10.666667 1.450667-15.274667 1.450667-5.546667 0-10.325333-0.597333-14.421333-1.450667a56.192 56.192 0 0 1-10.24-3.072 40.533333 40.533333 0 0 1-8.533333-4.821333l-45.312-46.592-129.664-129.749333-46.933334 44.928-130.986666 131.072a41.557333 41.557333 0 0 1-8.533334 4.736 54.357333 54.357333 0 0 1-10.112 3.114666 70.826667 70.826667 0 0 1-14.421333 1.408c-4.608 0-9.685333-0.384-15.274667-1.322666a115.2 115.2 0 0 1-18.133333-4.864 159.914667 159.914667 0 0 1-21.162667-9.258667 223.061333 223.061333 0 0 1-24.021333-14.634667L425.173333 303.786667z m201.386667 33.493333l195.370667 196.181333 58.965333 57.728a223.573333 223.573333 0 0 1-24.064 14.677334 159.146667 159.146667 0 0 1-21.077333 9.258666 115.072 115.072 0 0 1-18.176 4.736c-5.546667 0.981333-10.709333 1.450667-15.36 1.450667-5.504 0-10.282667-0.597333-14.378667-1.450667a54.826667 54.826667 0 0 1-16.426667-6.229333 10.197333 10.197333 0 0 1-2.261333-1.706667l-52.565333-51.968-90.069334-90.069333-14.250666 14.250667L545.706667 418.133333l80.896-80.938666z m-254.549333 175.786667v107.904a90.794667 90.794667 0 0 1-15.189334-1.493334 117.973333 117.973333 0 0 1-18.005333-4.949333 158.208 158.208 0 0 1-20.821333-9.130667 222.592 222.592 0 0 1-23.68-14.506666l77.653333-77.866667z" fill="#0D597F"/></svg>
|
||||||
@@ -710,6 +967,7 @@ function getTemplateIcon(id: string): ReactNode {
|
|||||||
if (id.startsWith('archlinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M504.149333 7.850667c-44.373333 108.544-70.997333 179.2-120.149333 284.330666 30.037333 32.085333 67.242667 69.290667 127.317333 111.274667-64.512-26.624-108.544-53.248-141.653333-80.896-63.146667 131.413333-161.792 318.464-361.813333 678.229333 157.696-90.794667 279.552-146.773333 393.216-168.277333-4.778667-21.162667-7.509333-43.690667-7.509334-67.584l0.341334-5.12c2.389333-100.693333 54.954667-178.517333 117.077333-173.056s110.592 91.477333 107.861333 192.170667c-0.341333 18.090667-2.389333 36.522667-6.485333 54.272 112.64 21.845333 233.130667 77.824 388.437333 167.594666l-83.968-155.648c-40.96-31.744-83.968-73.386667-171.349333-118.101333 60.074667 15.701333 103.082667 33.792 136.533333 53.930667-265.557333-493.909333-287.061333-559.786667-377.856-773.12z" fill="#1793D1"/></svg>
|
if (id.startsWith('archlinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M504.149333 7.850667c-44.373333 108.544-70.997333 179.2-120.149333 284.330666 30.037333 32.085333 67.242667 69.290667 127.317333 111.274667-64.512-26.624-108.544-53.248-141.653333-80.896-63.146667 131.413333-161.792 318.464-361.813333 678.229333 157.696-90.794667 279.552-146.773333 393.216-168.277333-4.778667-21.162667-7.509333-43.690667-7.509334-67.584l0.341334-5.12c2.389333-100.693333 54.954667-178.517333 117.077333-173.056s110.592 91.477333 107.861333 192.170667c-0.341333 18.090667-2.389333 36.522667-6.485333 54.272 112.64 21.845333 233.130667 77.824 388.437333 167.594666l-83.968-155.648c-40.96-31.744-83.968-73.386667-171.349333-118.101333 60.074667 15.701333 103.082667 33.792 136.533333 53.930667-265.557333-493.909333-287.061333-559.786667-377.856-773.12z" fill="#1793D1"/></svg>
|
||||||
if (id.startsWith('fedora')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M512 0C229.344 0 0.224 229.024 0 511.648V907.84a116.384 116.384 0 0 0 116.384 116.128h395.808c282.656-0.128 511.776-229.28 511.776-512 0-282.752-229.248-512-512-512z m196.064 237.952c-16.16 0-22.016-3.104-45.728-3.104a126.848 126.848 0 0 0-126.848 126.624v110.208c0 9.888 8.032 17.92 17.92 17.92h83.328c31.072 0 56.16 24.736 56.16 55.904 0 31.328-25.344 55.968-56.736 55.968h-100.608v127.36a240.32 240.32 0 0 1-240.288 240.288h-1.248a190.944 190.944 0 0 1-53.216-7.52l1.344 0.32c-27.168-7.072-49.376-29.408-49.376-55.296 0-31.328 22.752-54.112 56.736-54.112 16.128 0 22.016 3.072 45.696 3.072a126.848 126.848 0 0 0 126.848-126.624v-110.208a17.92 17.92 0 0 0-17.92-17.888h-83.328a55.808 55.808 0 0 1-56.096-55.904c0-31.328 25.344-55.968 56.736-55.968h100.576v-127.36a240.32 240.32 0 0 1 240.288-240.288c20.128 0 34.432 2.272 53.088 7.136 27.168 7.136 49.408 29.44 49.408 55.296 0 31.36-22.752 54.144-56.736 54.144z" fill="#294172"/></svg>
|
if (id.startsWith('fedora')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M512 0C229.344 0 0.224 229.024 0 511.648V907.84a116.384 116.384 0 0 0 116.384 116.128h395.808c282.656-0.128 511.776-229.28 511.776-512 0-282.752-229.248-512-512-512z m196.064 237.952c-16.16 0-22.016-3.104-45.728-3.104a126.848 126.848 0 0 0-126.848 126.624v110.208c0 9.888 8.032 17.92 17.92 17.92h83.328c31.072 0 56.16 24.736 56.16 55.904 0 31.328-25.344 55.968-56.736 55.968h-100.608v127.36a240.32 240.32 0 0 1-240.288 240.288h-1.248a190.944 190.944 0 0 1-53.216-7.52l1.344 0.32c-27.168-7.072-49.376-29.408-49.376-55.296 0-31.328 22.752-54.112 56.736-54.112 16.128 0 22.016 3.072 45.696 3.072a126.848 126.848 0 0 0 126.848-126.624v-110.208a17.92 17.92 0 0 0-17.92-17.888h-83.328a55.808 55.808 0 0 1-56.096-55.904c0-31.328 25.344-55.968 56.736-55.968h100.576v-127.36a240.32 240.32 0 0 1 240.288-240.288c20.128 0 34.432 2.272 53.088 7.136 27.168 7.136 49.408 29.44 49.408 55.296 0 31.36-22.752 54.144-56.736 54.144z" fill="#294172"/></svg>
|
||||||
if (id.startsWith('rockylinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M995.498667 680.362667c18.474667-52.778667 28.501333-109.568 28.501333-168.704C1024 229.077333 794.752 0 512 0S0 229.077333 0 511.658667c0 139.818667 56.106667 266.496 147.114667 358.826666L666.453333 351.530667l128.213334 128.170666 200.832 200.704z m-93.525334 162.816l-235.52-235.349334-368.896 368.597334A510.506667 510.506667 0 0 0 512 1023.274667c156.16 0 296.106667-69.888 389.973333-180.053334h0.042667z" fill="#10B981"/></svg>
|
if (id.startsWith('rockylinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M995.498667 680.362667c18.474667-52.778667 28.501333-109.568 28.501333-168.704C1024 229.077333 794.752 0 512 0S0 229.077333 0 511.658667c0 139.818667 56.106667 266.496 147.114667 358.826666L666.453333 351.530667l128.213334 128.170666 200.832 200.704z m-93.525334 162.816l-235.52-235.349334-368.896 368.597334A510.506667 510.506667 0 0 0 512 1023.274667c156.16 0 296.106667-69.888 389.973333-180.053334h0.042667z" fill="#10B981"/></svg>
|
||||||
|
if (id.startsWith('windows')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M56.888889 227.555556l398.222222-70.542223V512H56.888889V227.555556z m0 625.777777l398.222222 70.542223V568.888889H56.888889v284.444444zM512 147.342222L1024 56.888889v455.111111H512V147.342222z m0 786.204445L1024 1024v-455.111111H512v364.657778z" fill="#16C6FE"/></svg>
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,8 +119,7 @@ export default function Dashboard() {
|
|||||||
ramPercent={host.ram.total_mb > 0 ? (host.ram.used_mb / host.ram.total_mb) * 100 : 0}
|
ramPercent={host.ram.total_mb > 0 ? (host.ram.used_mb / host.ram.total_mb) * 100 : 0}
|
||||||
ramUsed={host.ram.used_mb}
|
ramUsed={host.ram.used_mb}
|
||||||
ramTotal={host.ram.total_mb}
|
ramTotal={host.ram.total_mb}
|
||||||
loadPercent={Math.min((host.load.load1 / host.cpu.cores) * 100, 100)}
|
loadPercent={(host.load.load1 / Math.max(host.cpu.cores, 1)) * 100}
|
||||||
loadStatus={host.load.load1 < host.cpu.cores * 0.7 ? '正常' : host.load.load1 < host.cpu.cores * 1.0 ? '中等' : '高'}
|
|
||||||
diskPercent={host.disk.total_gb > 0 ? (host.disk.used_gb / host.disk.total_gb) * 100 : 0}
|
diskPercent={host.disk.total_gb > 0 ? (host.disk.used_gb / host.disk.total_gb) * 100 : 0}
|
||||||
diskUsed={host.disk.used_gb * 1024}
|
diskUsed={host.disk.used_gb * 1024}
|
||||||
diskTotal={host.disk.total_gb * 1024}
|
diskTotal={host.disk.total_gb * 1024}
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { getImages, downloadImage, deleteImage, toggleImage, ImageInfo } from '../services/api'
|
import { getImages, downloadImage, deleteImage, toggleImage, ImageInfo } from '../services/api'
|
||||||
|
import { useDialog } from '../components/Dialog'
|
||||||
|
|
||||||
export default function ImageManagement() {
|
export default function ImageManagement() {
|
||||||
|
const dialog = useDialog()
|
||||||
const [images, setImages] = useState<ImageInfo[]>([])
|
const [images, setImages] = useState<ImageInfo[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||||
@@ -43,15 +45,14 @@ export default function ImageManagement() {
|
|||||||
await downloadImage(templateId)
|
await downloadImage(templateId)
|
||||||
await fetchImages()
|
await fetchImages()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : '下载失败'
|
setError(apiErrorMessage(err, '下载失败'))
|
||||||
setError(msg)
|
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(null)
|
setActionLoading(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (templateId: string) => {
|
const handleDelete = async (templateId: string) => {
|
||||||
if (!window.confirm('确定要删除该镜像缓存吗?删除后需要重新下载才能使用。')) return
|
if (!(await dialog.confirm('删除镜像', '确定要删除该镜像缓存吗?删除后需要重新下载才能使用。'))) return
|
||||||
setActionLoading(templateId)
|
setActionLoading(templateId)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
@@ -80,6 +81,8 @@ export default function ImageManagement() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const downloadedCount = images.filter((img) => img.downloaded).length
|
const downloadedCount = images.filter((img) => img.downloaded).length
|
||||||
|
const lxcImages = images.filter((img) => img.type === 'lxc')
|
||||||
|
const kvmImages = images.filter((img) => img.type === 'kvm')
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -95,7 +98,7 @@ export default function ImageManagement() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-black">镜像管理</h1>
|
<h1 className="text-2xl font-bold text-black">镜像管理</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
管理 LXC 系统镜像模板,下载后的镜像才能用于创建容器。
|
管理 LXC / KVM 系统镜像,下载后的镜像才能用于创建容器/虚拟机。
|
||||||
已下载 {downloadedCount}/{images.length}
|
已下载 {downloadedCount}/{images.length}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,6 +118,58 @@ export default function ImageManagement() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ImageTable
|
||||||
|
title="LXC 容器镜像"
|
||||||
|
images={lxcImages}
|
||||||
|
actionLoading={actionLoading}
|
||||||
|
downloadedCount={lxcImages.filter((img) => img.downloaded).length}
|
||||||
|
totalCount={lxcImages.length}
|
||||||
|
onDownload={handleDownload}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ImageTable
|
||||||
|
title="KVM 虚拟机镜像"
|
||||||
|
images={kvmImages}
|
||||||
|
actionLoading={actionLoading}
|
||||||
|
downloadedCount={kvmImages.filter((img) => img.downloaded).length}
|
||||||
|
totalCount={kvmImages.length}
|
||||||
|
onDownload={handleDownload}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800">{title}</h2>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
已下载 {downloadedCount}/{totalCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
@@ -153,6 +208,7 @@ export default function ImageManagement() {
|
|||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-gray-900 text-sm">{img.name}</span>
|
<span className="font-medium text-gray-900 text-sm">{img.name}</span>
|
||||||
<p className="text-[11px] text-gray-400">{img.description}</p>
|
<p className="text-[11px] text-gray-400">{img.description}</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -172,7 +228,7 @@ export default function ImageManagement() {
|
|||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
{!img.downloaded && !img.downloading && (
|
{!img.downloaded && !img.downloading && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDownload(img.id)}
|
onClick={() => onDownload(img.id)}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md hover:bg-gray-800 transition-colors text-xs font-medium disabled:opacity-50"
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md hover:bg-gray-800 transition-colors text-xs font-medium disabled:opacity-50"
|
||||||
>
|
>
|
||||||
@@ -195,7 +251,7 @@ export default function ImageManagement() {
|
|||||||
{img.downloaded && (
|
{img.downloaded && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleToggle(img.id, img.enabled)}
|
onClick={() => onToggle(img.id, img.enabled)}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
className={`inline-flex items-center gap-1 px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors disabled:opacity-50 ${
|
className={`inline-flex items-center gap-1 px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors disabled:opacity-50 ${
|
||||||
img.enabled
|
img.enabled
|
||||||
@@ -207,7 +263,7 @@ export default function ImageManagement() {
|
|||||||
{img.enabled ? '启用' : '禁用'}
|
{img.enabled ? '启用' : '禁用'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(img.id)}
|
onClick={() => onDelete(img.id)}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-md border border-red-200 text-red-600 hover:bg-red-50 transition-colors text-xs font-medium disabled:opacity-50"
|
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-md border border-red-200 text-red-600 hover:bg-red-50 transition-colors text-xs font-medium disabled:opacity-50"
|
||||||
title="删除镜像缓存"
|
title="删除镜像缓存"
|
||||||
@@ -262,8 +318,13 @@ function StatusBadge({ img }: { img: ImageInfo }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isWindowsImage(img: ImageInfo) {
|
||||||
|
return img.distro === 'windows' || img.id.toLowerCase().includes('windows')
|
||||||
|
}
|
||||||
|
|
||||||
function getTemplateIcon(id: string): ReactNode {
|
function getTemplateIcon(id: string): ReactNode {
|
||||||
const size = 'w-5 h-5'
|
const size = 'w-5 h-5'
|
||||||
|
id = id.startsWith('kvm-') ? id.slice(4) : id
|
||||||
if (id.startsWith('debian')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M935.473 375.359a558.602 558.602 0 0 0-22.351-114.655l13.308 4.436c-35.66-81.385-90.086-163.623-153.556-199.282-8.701-5.118-35.147 4.948-26.616-12.113s-37.536-8.19-56.816-4.778c-26.275 4.266-30.028-29.175-75.071-35.83-25.593-3.582-32.247 18.427-44.702 13.309-23.545-9.384-20.816-27.64-57.669-9.384-18.427 9.042 11.602-26.105-49.138-4.607L457.744 0C349.23 41.63 318.69 76.266 288.15 79.337c-6.996 0-34.124 32.759-53.574 53.062-17.062 17.062-26.275 36.512-49.138 39.583l-17.062 70.636A136.494 136.494 0 0 0 119.41 339.7a66.711 66.711 0 0 1 4.436-52.892c-17.062 6.825-45.896 17.062-29.687 96.91 12.796 63.13-5.29 135.13 10.066 204.742 4.777 20.986 0 40.095 6.142 51.185 107.66 235.794 208.836 392.08 472.44 384.06l4.436-8.872c-28.152-6.825-55.11-17.062-111.584-30.711-18.597-4.436-23.033-34.124-40.265-44.19-9.384-5.46-28.323-4.095-37.195-9.896s4.266-21.668-19.962-14.332c-8.531 2.56-13.82-10.92-20.133-17.061s0-23.716-23.375-24.74-18.426-29.687-19.791-44.702c-12.114 1.536-1.195-1.535-13.308 4.436a63.64 63.64 0 0 1-23.887-31.735c-10.237-48.967-10.578-21.497-15.014-32.417a322.297 322.297 0 0 0-19.28-42.142l26.787 8.872h4.436l4.436-13.309-26.616-8.701h31.223c-7.678 13.99 2.047 5.29-13.479 8.872v13.308l22.35-8.872v-13.308c-20.644-10.237-28.663-13.308-49.137-22.01l9.043 8.872v4.436h-49.138c-22.01-14.843-13.99-31.734-17.915-53.062 17.062 0 9.213 6.655 17.062-13.137l-17.062 8.872 13.308-33.953-13.308 13.138c-29.176-38.73-16.209-97.764-11.943-152.02A180.684 180.684 0 0 1 211.2 372.97c8.872-10.067 5.119-25.251 5.46-37.195l31.223-26.445H265.8c7.678 17.061 4.777 5.46 0 22.01l8.872 4.435c7.166-8.701 6.142-5.971 8.872-22.01-10.066-10.578-6.995-9.895-26.616-13.308A119.432 119.432 0 0 1 368.51 243.13l4.436-13.308-17.915 9.043-4.436-13.138a109.536 109.536 0 0 1 76.095-27.128c6.313 0 6.996-17.062 12.797-19.45 161.574-60.57 309.33 9.383 371.093 147.413a324.173 324.173 0 0 1 8.19 34.123c17.061 56.987-7.167 121.48 9.725 155.604-7.849 36-36.683 13.82-40.266 30.881-8.531 41.29-14.844 59.717-40.778 78.826a196.38 196.38 0 0 1-30.711 22.35 84.285 84.285 0 0 0 22.35-39.753c-106.294 111.584-262.58 63.981-290.049-105.954a101.176 101.176 0 0 1 35.147-93.157c92.987-87.527 150.144-52.38 205.765-20.474l-8.872-30.711c-32.93-24.398-17.062-19.792-9.043-57.328v-4.436l-17.915-13.137c2.56 10.066 1.024 5.289 9.043 17.061-4.436 16.039 0 9.043-8.872 17.062-15.014 9.725-23.716 7.337-44.702 4.436l4.436-13.308-13.308-13.308c0 11.773-4.095 2.73 0 17.062-126.086 9.896-218.05 80.02-178.636 260.191a220.608 220.608 0 0 0 8.872 44.19l-8.872 8.702-4.436-26.446h-13.48l-4.435 13.308c-12.626-25.763-0.853 10.75 40.265 52.892a149.29 149.29 0 0 0 12.797 12.625c47.773 34.124 113.29 81.385 201.328 49.138h9.043v-4.436l-102.37-13.308-4.436-8.701c106.806 24.74 176.93-8.531 236.646-48.456 13.138-17.062 11.431-24.057 22.18-9.043 19.28-17.061 3.925-26.786 13.48-44.019 6.483-11.772 32.587-17.062 44.7-35.318l40.096-136.494h-17.062c3.071-14.332 22.522-34.123-4.436-48.455-2.559-1.536 9.043-1.365 8.872-4.266a145.537 145.537 0 0 0-22.18-66.37c33.1 21.669 36.342 68.247 53.574 105.783v8.872h4.436V375.36zM453.308 595.455l-9.555-26.446 62.446 57.328zM146.196 211.736l-23.204-4.436v39.754c16.72-10.578 18.939-10.407 22.35-35.318z m574.981 176.419a57.498 57.498 0 0 0-17.062 44.19l13.48 8.701a37.877 37.877 0 0 0 4.435-52.891zM868.42 555.872c26.275-11.602 54.598-58.01 35.83-97.081l-35.83 96.91z m-174.03-79.508c-15.697 11.773-19.791 13.308-22.35 39.754l13.307 8.872 17.915-8.872a60.228 60.228 0 0 0 4.436-48.455c-8.36 13.478-2.559 20.644-13.308 8.701z m-67.053 79.508c15.868-10.92 11.944-14.844 17.915-22.18v-4.778a292.097 292.097 0 0 1-62.446 0c-13.137-13.99-13.308-29.346-31.223-39.583 17.062 35.147 3.242 38.218 31.223 61.764a158.162 158.162 0 0 0 40.095 4.436c1.536 0-6.824-1.024 4.436 0zM207.79 520.554H194.31l-8.872 8.702c9.555 10.237 5.46 7.166 13.308-4.436L212.225 547l4.436-17.062-8.872-8.701z m17.062 57.328l4.436-8.873c-10.067-8.701 0-3.583-13.308 0l-13.309-17.061 4.436 17.061v8.873h17.062z" fill="#CE0C48"/></svg>
|
if (id.startsWith('debian')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M935.473 375.359a558.602 558.602 0 0 0-22.351-114.655l13.308 4.436c-35.66-81.385-90.086-163.623-153.556-199.282-8.701-5.118-35.147 4.948-26.616-12.113s-37.536-8.19-56.816-4.778c-26.275 4.266-30.028-29.175-75.071-35.83-25.593-3.582-32.247 18.427-44.702 13.309-23.545-9.384-20.816-27.64-57.669-9.384-18.427 9.042 11.602-26.105-49.138-4.607L457.744 0C349.23 41.63 318.69 76.266 288.15 79.337c-6.996 0-34.124 32.759-53.574 53.062-17.062 17.062-26.275 36.512-49.138 39.583l-17.062 70.636A136.494 136.494 0 0 0 119.41 339.7a66.711 66.711 0 0 1 4.436-52.892c-17.062 6.825-45.896 17.062-29.687 96.91 12.796 63.13-5.29 135.13 10.066 204.742 4.777 20.986 0 40.095 6.142 51.185 107.66 235.794 208.836 392.08 472.44 384.06l4.436-8.872c-28.152-6.825-55.11-17.062-111.584-30.711-18.597-4.436-23.033-34.124-40.265-44.19-9.384-5.46-28.323-4.095-37.195-9.896s4.266-21.668-19.962-14.332c-8.531 2.56-13.82-10.92-20.133-17.061s0-23.716-23.375-24.74-18.426-29.687-19.791-44.702c-12.114 1.536-1.195-1.535-13.308 4.436a63.64 63.64 0 0 1-23.887-31.735c-10.237-48.967-10.578-21.497-15.014-32.417a322.297 322.297 0 0 0-19.28-42.142l26.787 8.872h4.436l4.436-13.309-26.616-8.701h31.223c-7.678 13.99 2.047 5.29-13.479 8.872v13.308l22.35-8.872v-13.308c-20.644-10.237-28.663-13.308-49.137-22.01l9.043 8.872v4.436h-49.138c-22.01-14.843-13.99-31.734-17.915-53.062 17.062 0 9.213 6.655 17.062-13.137l-17.062 8.872 13.308-33.953-13.308 13.138c-29.176-38.73-16.209-97.764-11.943-152.02A180.684 180.684 0 0 1 211.2 372.97c8.872-10.067 5.119-25.251 5.46-37.195l31.223-26.445H265.8c7.678 17.061 4.777 5.46 0 22.01l8.872 4.435c7.166-8.701 6.142-5.971 8.872-22.01-10.066-10.578-6.995-9.895-26.616-13.308A119.432 119.432 0 0 1 368.51 243.13l4.436-13.308-17.915 9.043-4.436-13.138a109.536 109.536 0 0 1 76.095-27.128c6.313 0 6.996-17.062 12.797-19.45 161.574-60.57 309.33 9.383 371.093 147.413a324.173 324.173 0 0 1 8.19 34.123c17.061 56.987-7.167 121.48 9.725 155.604-7.849 36-36.683 13.82-40.266 30.881-8.531 41.29-14.844 59.717-40.778 78.826a196.38 196.38 0 0 1-30.711 22.35 84.285 84.285 0 0 0 22.35-39.753c-106.294 111.584-262.58 63.981-290.049-105.954a101.176 101.176 0 0 1 35.147-93.157c92.987-87.527 150.144-52.38 205.765-20.474l-8.872-30.711c-32.93-24.398-17.062-19.792-9.043-57.328v-4.436l-17.915-13.137c2.56 10.066 1.024 5.289 9.043 17.061-4.436 16.039 0 9.043-8.872 17.062-15.014 9.725-23.716 7.337-44.702 4.436l4.436-13.308-13.308-13.308c0 11.773-4.095 2.73 0 17.062-126.086 9.896-218.05 80.02-178.636 260.191a220.608 220.608 0 0 0 8.872 44.19l-8.872 8.702-4.436-26.446h-13.48l-4.435 13.308c-12.626-25.763-0.853 10.75 40.265 52.892a149.29 149.29 0 0 0 12.797 12.625c47.773 34.124 113.29 81.385 201.328 49.138h9.043v-4.436l-102.37-13.308-4.436-8.701c106.806 24.74 176.93-8.531 236.646-48.456 13.138-17.062 11.431-24.057 22.18-9.043 19.28-17.061 3.925-26.786 13.48-44.019 6.483-11.772 32.587-17.062 44.7-35.318l40.096-136.494h-17.062c3.071-14.332 22.522-34.123-4.436-48.455-2.559-1.536 9.043-1.365 8.872-4.266a145.537 145.537 0 0 0-22.18-66.37c33.1 21.669 36.342 68.247 53.574 105.783v8.872h4.436V375.36zM453.308 595.455l-9.555-26.446 62.446 57.328zM146.196 211.736l-23.204-4.436v39.754c16.72-10.578 18.939-10.407 22.35-35.318z m574.981 176.419a57.498 57.498 0 0 0-17.062 44.19l13.48 8.701a37.877 37.877 0 0 0 4.435-52.891zM868.42 555.872c26.275-11.602 54.598-58.01 35.83-97.081l-35.83 96.91z m-174.03-79.508c-15.697 11.773-19.791 13.308-22.35 39.754l13.307 8.872 17.915-8.872a60.228 60.228 0 0 0 4.436-48.455c-8.36 13.478-2.559 20.644-13.308 8.701z m-67.053 79.508c15.868-10.92 11.944-14.844 17.915-22.18v-4.778a292.097 292.097 0 0 1-62.446 0c-13.137-13.99-13.308-29.346-31.223-39.583 17.062 35.147 3.242 38.218 31.223 61.764a158.162 158.162 0 0 0 40.095 4.436c1.536 0-6.824-1.024 4.436 0zM207.79 520.554H194.31l-8.872 8.702c9.555 10.237 5.46 7.166 13.308-4.436L212.225 547l4.436-17.062-8.872-8.701z m17.062 57.328l4.436-8.873c-10.067-8.701 0-3.583-13.308 0l-13.309-17.061 4.436 17.061v8.873h17.062z" fill="#CE0C48"/></svg>
|
||||||
if (id.startsWith('ubuntu')) return <svg className={size} viewBox="0 0 1024 1024"><circle cx="512" cy="512" r="511" fill="#DD4814"/><path d="M164.532 442.532c-37.676 0-68.2 30.524-68.2 68.2 0 37.656 30.524 68.184 68.2 68.184 37.66 0 68.184-30.528 68.184-68.184 0-37.676-30.524-68.2-68.184-68.2z m486.86 309.912c-32.612 18.84-43.8 60.52-24.96 93.116 18.82 32.616 60.5 43.796 93.116 24.96 32.612-18.82 43.796-60.5 24.96-93.12-18.82-32.592-60.524-43.772-93.116-24.956z m-338.744-241.712c0-67.384 33.472-126.92 84.684-162.968L347.48 264.268c-59.656 39.88-104.048 100.816-122.496 172.188 21.528 17.56 35.304 44.3 35.304 74.272 0 29.956-13.776 56.696-35.304 74.26C243.408 656.376 287.8 717.32 347.48 757.2l49.852-83.52c-51.212-36.028-84.684-95.56-84.684-162.948z m199.168-199.188c104.052 0 189.42 79.776 198.38 181.52l97.16-1.432c-4.776-75.112-37.592-142.544-88.008-192.128-25.928 9.796-55.88 8.296-81.76-6.624-25.932-14.964-42.192-40.208-46.636-67.608a297.04 297.04 0 0 0-79.14-10.76 295.148 295.148 0 0 0-131.276 30.652l47.38 84.908a198.384 198.384 0 0 1 83.9-18.528z m0 398.36a198.404 198.404 0 0 1-83.896-18.528l-47.38 84.9a294.848 294.848 0 0 0 131.28 30.684 296.16 296.16 0 0 0 79.136-10.788c4.444-27.4 20.708-52.62 46.632-67.608 25.904-14.948 55.836-16.42 81.76-6.624 50.42-49.584 83.232-117.016 88.016-192.128l-97.188-1.432c-8.94 101.772-94.304 181.52-198.36 181.52z m139.552-440.924c32.616 18.832 74.3 7.68 93.116-24.936 18.84-32.616 7.68-74.3-24.936-93.14-32.616-18.816-74.296-7.64-93.14 24.976-18.812 32.6-7.632 74.28 24.96 93.1z" fill="#FFF"/></svg>
|
if (id.startsWith('ubuntu')) return <svg className={size} viewBox="0 0 1024 1024"><circle cx="512" cy="512" r="511" fill="#DD4814"/><path d="M164.532 442.532c-37.676 0-68.2 30.524-68.2 68.2 0 37.656 30.524 68.184 68.2 68.184 37.66 0 68.184-30.528 68.184-68.184 0-37.676-30.524-68.2-68.184-68.2z m486.86 309.912c-32.612 18.84-43.8 60.52-24.96 93.116 18.82 32.616 60.5 43.796 93.116 24.96 32.612-18.82 43.796-60.5 24.96-93.12-18.82-32.592-60.524-43.772-93.116-24.956z m-338.744-241.712c0-67.384 33.472-126.92 84.684-162.968L347.48 264.268c-59.656 39.88-104.048 100.816-122.496 172.188 21.528 17.56 35.304 44.3 35.304 74.272 0 29.956-13.776 56.696-35.304 74.26C243.408 656.376 287.8 717.32 347.48 757.2l49.852-83.52c-51.212-36.028-84.684-95.56-84.684-162.948z m199.168-199.188c104.052 0 189.42 79.776 198.38 181.52l97.16-1.432c-4.776-75.112-37.592-142.544-88.008-192.128-25.928 9.796-55.88 8.296-81.76-6.624-25.932-14.964-42.192-40.208-46.636-67.608a297.04 297.04 0 0 0-79.14-10.76 295.148 295.148 0 0 0-131.276 30.652l47.38 84.908a198.384 198.384 0 0 1 83.9-18.528z m0 398.36a198.404 198.404 0 0 1-83.896-18.528l-47.38 84.9a294.848 294.848 0 0 0 131.28 30.684 296.16 296.16 0 0 0 79.136-10.788c4.444-27.4 20.708-52.62 46.632-67.608 25.904-14.948 55.836-16.42 81.76-6.624 50.42-49.584 83.232-117.016 88.016-192.128l-97.188-1.432c-8.94 101.772-94.304 181.52-198.36 181.52z m139.552-440.924c32.616 18.832 74.3 7.68 93.116-24.936 18.84-32.616 7.68-74.3-24.936-93.14-32.616-18.816-74.296-7.64-93.14 24.976-18.812 32.6-7.632 74.28 24.96 93.1z" fill="#FFF"/></svg>
|
||||||
if (id.startsWith('alpine')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M255.914667 68.565333L0 512l255.914667 443.434667h512.170666L1024 512 768.085333 68.565333H255.914667zM425.173333 303.786667L540.16 422.4l68.181333 68.053333 0.085334-0.085333 102.826666 100.821333c-8.533333 5.973333-16.469333 10.752-24.021333 14.677334a160.256 160.256 0 0 1-21.162667 9.258666 115.285333 115.285333 0 0 1-18.133333 4.736c-5.589333 0.981333-10.666667 1.450667-15.274667 1.450667-5.546667 0-10.325333-0.597333-14.421333-1.450667a56.192 56.192 0 0 1-10.24-3.072 40.533333 40.533333 0 0 1-8.533333-4.821333l-45.312-46.592-129.664-129.749333-46.933334 44.928-130.986666 131.072a41.557333 41.557333 0 0 1-8.533334 4.736 54.357333 54.357333 0 0 1-10.112 3.114666 70.826667 70.826667 0 0 1-14.421333 1.408c-4.608 0-9.685333-0.384-15.274667-1.322666a115.2 115.2 0 0 1-18.133333-4.864 159.914667 159.914667 0 0 1-21.162667-9.258667 223.061333 223.061333 0 0 1-24.021333-14.634667L425.173333 303.786667z m201.386667 33.493333l195.370667 196.181333 58.965333 57.728a223.573333 223.573333 0 0 1-24.064 14.677334 159.146667 159.146667 0 0 1-21.077333 9.258666 115.072 115.072 0 0 1-18.176 4.736c-5.546667 0.981333-10.709333 1.450667-15.36 1.450667-5.504 0-10.282667-0.597333-14.378667-1.450667a54.826667 54.826667 0 0 1-16.426667-6.229333 10.197333 10.197333 0 0 1-2.261333-1.706667l-52.565333-51.968-90.069334-90.069333-14.250666 14.250667L545.706667 418.133333l80.896-80.938666z m-254.549333 175.786667v107.904a90.794667 90.794667 0 0 1-15.189334-1.493334 117.973333 117.973333 0 0 1-18.005333-4.949333 158.208 158.208 0 0 1-20.821333-9.130667 222.592 222.592 0 0 1-23.68-14.506666l77.653333-77.866667z" fill="#0D597F"/></svg>
|
if (id.startsWith('alpine')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M255.914667 68.565333L0 512l255.914667 443.434667h512.170666L1024 512 768.085333 68.565333H255.914667zM425.173333 303.786667L540.16 422.4l68.181333 68.053333 0.085334-0.085333 102.826666 100.821333c-8.533333 5.973333-16.469333 10.752-24.021333 14.677334a160.256 160.256 0 0 1-21.162667 9.258666 115.285333 115.285333 0 0 1-18.133333 4.736c-5.589333 0.981333-10.666667 1.450667-15.274667 1.450667-5.546667 0-10.325333-0.597333-14.421333-1.450667a56.192 56.192 0 0 1-10.24-3.072 40.533333 40.533333 0 0 1-8.533333-4.821333l-45.312-46.592-129.664-129.749333-46.933334 44.928-130.986666 131.072a41.557333 41.557333 0 0 1-8.533334 4.736 54.357333 54.357333 0 0 1-10.112 3.114666 70.826667 70.826667 0 0 1-14.421333 1.408c-4.608 0-9.685333-0.384-15.274667-1.322666a115.2 115.2 0 0 1-18.133333-4.864 159.914667 159.914667 0 0 1-21.162667-9.258667 223.061333 223.061333 0 0 1-24.021333-14.634667L425.173333 303.786667z m201.386667 33.493333l195.370667 196.181333 58.965333 57.728a223.573333 223.573333 0 0 1-24.064 14.677334 159.146667 159.146667 0 0 1-21.077333 9.258666 115.072 115.072 0 0 1-18.176 4.736c-5.546667 0.981333-10.709333 1.450667-15.36 1.450667-5.504 0-10.282667-0.597333-14.378667-1.450667a54.826667 54.826667 0 0 1-16.426667-6.229333 10.197333 10.197333 0 0 1-2.261333-1.706667l-52.565333-51.968-90.069334-90.069333-14.250666 14.250667L545.706667 418.133333l80.896-80.938666z m-254.549333 175.786667v107.904a90.794667 90.794667 0 0 1-15.189334-1.493334 117.973333 117.973333 0 0 1-18.005333-4.949333 158.208 158.208 0 0 1-20.821333-9.130667 222.592 222.592 0 0 1-23.68-14.506666l77.653333-77.866667z" fill="#0D597F"/></svg>
|
||||||
@@ -271,9 +332,15 @@ function getTemplateIcon(id: string): ReactNode {
|
|||||||
if (id.startsWith('archlinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M504.149333 7.850667c-44.373333 108.544-70.997333 179.2-120.149333 284.330666 30.037333 32.085333 67.242667 69.290667 127.317333 111.274667-64.512-26.624-108.544-53.248-141.653333-80.896-63.146667 131.413333-161.792 318.464-361.813333 678.229333 157.696-90.794667 279.552-146.773333 393.216-168.277333-4.778667-21.162667-7.509333-43.690667-7.509334-67.584l0.341334-5.12c2.389333-100.693333 54.954667-178.517333 117.077333-173.056s110.592 91.477333 107.861333 192.170667c-0.341333 18.090667-2.389333 36.522667-6.485333 54.272 112.64 21.845333 233.130667 77.824 388.437333 167.594666l-83.968-155.648c-40.96-31.744-83.968-73.386667-171.349333-118.101333 60.074667 15.701333 103.082667 33.792 136.533333 53.930667-265.557333-493.909333-287.061333-559.786667-377.856-773.12z" fill="#1793D1"/></svg>
|
if (id.startsWith('archlinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M504.149333 7.850667c-44.373333 108.544-70.997333 179.2-120.149333 284.330666 30.037333 32.085333 67.242667 69.290667 127.317333 111.274667-64.512-26.624-108.544-53.248-141.653333-80.896-63.146667 131.413333-161.792 318.464-361.813333 678.229333 157.696-90.794667 279.552-146.773333 393.216-168.277333-4.778667-21.162667-7.509333-43.690667-7.509334-67.584l0.341334-5.12c2.389333-100.693333 54.954667-178.517333 117.077333-173.056s110.592 91.477333 107.861333 192.170667c-0.341333 18.090667-2.389333 36.522667-6.485333 54.272 112.64 21.845333 233.130667 77.824 388.437333 167.594666l-83.968-155.648c-40.96-31.744-83.968-73.386667-171.349333-118.101333 60.074667 15.701333 103.082667 33.792 136.533333 53.930667-265.557333-493.909333-287.061333-559.786667-377.856-773.12z" fill="#1793D1"/></svg>
|
||||||
if (id.startsWith('fedora')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M512 0C229.344 0 0.224 229.024 0 511.648V907.84a116.384 116.384 0 0 0 116.384 116.128h395.808c282.656-0.128 511.776-229.28 511.776-512 0-282.752-229.248-512-512-512z m196.064 237.952c-16.16 0-22.016-3.104-45.728-3.104a126.848 126.848 0 0 0-126.848 126.624v110.208c0 9.888 8.032 17.92 17.92 17.92h83.328c31.072 0 56.16 24.736 56.16 55.904 0 31.328-25.344 55.968-56.736 55.968h-100.608v127.36a240.32 240.32 0 0 1-240.288 240.288h-1.248a190.944 190.944 0 0 1-53.216-7.52l1.344 0.32c-27.168-7.072-49.376-29.408-49.376-55.296 0-31.328 22.752-54.112 56.736-54.112 16.128 0 22.016 3.072 45.696 3.072a126.848 126.848 0 0 0 126.848-126.624v-110.208a17.92 17.92 0 0 0-17.92-17.888h-83.328a55.808 55.808 0 0 1-56.096-55.904c0-31.328 25.344-55.968 56.736-55.968h100.576v-127.36a240.32 240.32 0 0 1 240.288-240.288c20.128 0 34.432 2.272 53.088 7.136 27.168 7.136 49.408 29.44 49.408 55.296 0 31.36-22.752 54.144-56.736 54.144z" fill="#294172"/></svg>
|
if (id.startsWith('fedora')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M512 0C229.344 0 0.224 229.024 0 511.648V907.84a116.384 116.384 0 0 0 116.384 116.128h395.808c282.656-0.128 511.776-229.28 511.776-512 0-282.752-229.248-512-512-512z m196.064 237.952c-16.16 0-22.016-3.104-45.728-3.104a126.848 126.848 0 0 0-126.848 126.624v110.208c0 9.888 8.032 17.92 17.92 17.92h83.328c31.072 0 56.16 24.736 56.16 55.904 0 31.328-25.344 55.968-56.736 55.968h-100.608v127.36a240.32 240.32 0 0 1-240.288 240.288h-1.248a190.944 190.944 0 0 1-53.216-7.52l1.344 0.32c-27.168-7.072-49.376-29.408-49.376-55.296 0-31.328 22.752-54.112 56.736-54.112 16.128 0 22.016 3.072 45.696 3.072a126.848 126.848 0 0 0 126.848-126.624v-110.208a17.92 17.92 0 0 0-17.92-17.888h-83.328a55.808 55.808 0 0 1-56.096-55.904c0-31.328 25.344-55.968 56.736-55.968h100.576v-127.36a240.32 240.32 0 0 1 240.288-240.288c20.128 0 34.432 2.272 53.088 7.136 27.168 7.136 49.408 29.44 49.408 55.296 0 31.36-22.752 54.144-56.736 54.144z" fill="#294172"/></svg>
|
||||||
if (id.startsWith('rockylinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M995.498667 680.362667c18.474667-52.778667 28.501333-109.568 28.501333-168.704C1024 229.077333 794.752 0 512 0S0 229.077333 0 511.658667c0 139.818667 56.106667 266.496 147.114667 358.826666L666.453333 351.530667l128.213334 128.170666 200.832 200.704z m-93.525334 162.816l-235.52-235.349334-368.896 368.597334A510.506667 510.506667 0 0 0 512 1023.274667c156.16 0 296.106667-69.888 389.973333-180.053334h0.042667z" fill="#10B981"/></svg>
|
if (id.startsWith('rockylinux')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M995.498667 680.362667c18.474667-52.778667 28.501333-109.568 28.501333-168.704C1024 229.077333 794.752 0 512 0S0 229.077333 0 511.658667c0 139.818667 56.106667 266.496 147.114667 358.826666L666.453333 351.530667l128.213334 128.170666 200.832 200.704z m-93.525334 162.816l-235.52-235.349334-368.896 368.597334A510.506667 510.506667 0 0 0 512 1023.274667c156.16 0 296.106667-69.888 389.973333-180.053334h0.042667z" fill="#10B981"/></svg>
|
||||||
|
if (id.startsWith('windows')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M56.888889 227.555556l398.222222-70.542223V512H56.888889V227.555556z m0 625.777777l398.222222 70.542223V568.888889H56.888889v284.444444zM512 147.342222L1024 56.888889v455.111111H512V147.342222z m0 786.204445L1024 1024v-455.111111H512v364.657778z" fill="#16C6FE"/></svg>
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function apiErrorMessage(err: unknown, fallback: string) {
|
||||||
|
const error = err as { response?: { data?: { message?: string } }; message?: string }
|
||||||
|
return error.response?.data?.message || error.message || fallback
|
||||||
|
}
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
function formatSize(bytes: number): string {
|
||||||
if (bytes <= 0) return '-'
|
if (bytes <= 0) return '-'
|
||||||
if (bytes < 1024) return `${bytes} B`
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
|||||||
@@ -1,490 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback, type ReactNode } from 'react'
|
|
||||||
import { Cpu, MemoryStick, HardDrive, RefreshCw, Save, RotateCcw } from 'lucide-react'
|
|
||||||
import {
|
|
||||||
getOversell,
|
|
||||||
updateOversell,
|
|
||||||
getOversellStatus,
|
|
||||||
getHostInfo,
|
|
||||||
reclaimMemory,
|
|
||||||
HostInfo,
|
|
||||||
OversellConfig,
|
|
||||||
OversellStatus,
|
|
||||||
} from '../services/api'
|
|
||||||
import { useDialog } from '../components/Dialog'
|
|
||||||
import { formatMB } from '../utils/labels'
|
|
||||||
|
|
||||||
export default function Oversell() {
|
|
||||||
const dialog = useDialog()
|
|
||||||
const [config, setConfig] = useState<OversellConfig | null>(null)
|
|
||||||
const [status, setStatus] = useState<OversellStatus | null>(null)
|
|
||||||
const [host, setHost] = useState<HostInfo | null>(null)
|
|
||||||
const [estimateSpec, setEstimateSpec] = useState({ vcpu: 1, ramMb: 1024, diskGb: 10 })
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [reclaiming, setReclaiming] = useState(false)
|
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const [cfgRes, stRes, hostRes] = await Promise.all([
|
|
||||||
getOversell(),
|
|
||||||
getOversellStatus(),
|
|
||||||
getHostInfo(),
|
|
||||||
])
|
|
||||||
if (cfgRes.data.data) setConfig(cfgRes.data.data)
|
|
||||||
if (stRes.data.data) setStatus(stRes.data.data)
|
|
||||||
if (hostRes.data.data) setHost(hostRes.data.data)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => { fetchData() }, [fetchData])
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!config) return
|
|
||||||
if (config.cpu_overcommit < 1 || config.ram_overcommit < 1 || config.disk_overcommit < 1) {
|
|
||||||
await dialog.alert('参数错误', '超售倍数不能小于 1。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (config.swappiness < 0 || config.swappiness > 100) {
|
|
||||||
await dialog.alert('参数错误', 'Swap 倾向必须在 0 到 100 之间。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setSaving(true)
|
|
||||||
try {
|
|
||||||
await updateOversell(config)
|
|
||||||
await fetchData()
|
|
||||||
await dialog.alert('已应用', '宿主机控制参数已保存。')
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
await dialog.alert('保存失败', getErrorMessage(err, '请检查宿主机权限或稍后重试。'))
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleReclaimMemory = async () => {
|
|
||||||
setReclaiming(true)
|
|
||||||
try {
|
|
||||||
const res = await reclaimMemory()
|
|
||||||
await fetchData()
|
|
||||||
const result = res.data.data
|
|
||||||
const errors = result?.errors?.length ? `\n失败: ${result.errors.join('; ')}` : ''
|
|
||||||
await dialog.alert(
|
|
||||||
'回收已触发',
|
|
||||||
`已处理 ${result?.attempted || 0} 个运行中容器,成功 ${result?.reclaimed || 0} 个,不支持 ${result?.unsupported || 0} 个。${errors}`
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
await dialog.alert('回收失败', getErrorMessage(err, '请检查宿主机是否支持 cgroup v2 memory.reclaim。'))
|
|
||||||
} finally {
|
|
||||||
setReclaiming(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center py-20">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black"></div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!config) return null
|
|
||||||
|
|
||||||
const estimate = host ? buildCapacityEstimate(host, status, config, estimateSpec) : null
|
|
||||||
const ksmSupported = status?.ksm_supported !== false
|
|
||||||
const reclaimSupported = status?.reclaim_supported !== false
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-semibold text-black">宿主机控制</h1>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">超售容量、KSM 与宿主机内存参数</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={fetchData}
|
|
||||||
className="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 text-sm"
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-4 h-4" />
|
|
||||||
刷新
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<ResourceCard
|
|
||||||
icon={<Cpu className="w-3.5 h-3.5" />}
|
|
||||||
label="已分配 vCPU"
|
|
||||||
value={String(status?.allocated_cpu || 0)}
|
|
||||||
hint={`超售倍数: ${config.cpu_overcommit}x`}
|
|
||||||
/>
|
|
||||||
<ResourceCard
|
|
||||||
icon={<MemoryStick className="w-3.5 h-3.5" />}
|
|
||||||
label="已分配内存"
|
|
||||||
value={formatMB(status?.allocated_ram_mb || 0)}
|
|
||||||
hint={`超售倍数: ${config.ram_overcommit}x`}
|
|
||||||
/>
|
|
||||||
<ResourceCard
|
|
||||||
icon={<HardDrive className="w-3.5 h-3.5" />}
|
|
||||||
label="已分配磁盘"
|
|
||||||
value={`${status?.allocated_disk_gb || 0} GB`}
|
|
||||||
hint={`超售倍数: ${config.disk_overcommit}x`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white border border-gray-200 rounded-lg p-5">
|
|
||||||
<h2 className="text-sm font-semibold text-black mb-4">超售倍数</h2>
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
<SliderField
|
|
||||||
label="CPU 超售"
|
|
||||||
value={config.cpu_overcommit}
|
|
||||||
min={1}
|
|
||||||
max={32}
|
|
||||||
suffix="x"
|
|
||||||
onChange={(v) => setConfig({ ...config, cpu_overcommit: v })}
|
|
||||||
hint="只用于容量估算,不改变单台容器限制"
|
|
||||||
/>
|
|
||||||
<SliderField
|
|
||||||
label="内存超售"
|
|
||||||
value={config.ram_overcommit}
|
|
||||||
min={1}
|
|
||||||
max={16}
|
|
||||||
suffix="x"
|
|
||||||
onChange={(v) => setConfig({ ...config, ram_overcommit: v })}
|
|
||||||
hint="只用于容量估算,不改变单台容器限制"
|
|
||||||
/>
|
|
||||||
<SliderField
|
|
||||||
label="磁盘超售"
|
|
||||||
value={config.disk_overcommit}
|
|
||||||
min={1}
|
|
||||||
max={16}
|
|
||||||
suffix="x"
|
|
||||||
onChange={(v) => setConfig({ ...config, disk_overcommit: v })}
|
|
||||||
hint="用于容量预估,实际写入仍受文件系统限制"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white border border-gray-200 rounded-lg p-5">
|
|
||||||
<div className="flex items-center justify-between gap-4 mb-4">
|
|
||||||
<h2 className="text-sm font-semibold text-black">容量预估</h2>
|
|
||||||
<span className="text-xs text-gray-500">按单台容器配置计算</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[320px_1fr] gap-5">
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
|
||||||
<NumberField
|
|
||||||
label="vCPU"
|
|
||||||
value={estimateSpec.vcpu}
|
|
||||||
min={0.25}
|
|
||||||
step={0.25}
|
|
||||||
onChange={(value) => setEstimateSpec({ ...estimateSpec, vcpu: value })}
|
|
||||||
/>
|
|
||||||
<NumberField
|
|
||||||
label="内存 MB"
|
|
||||||
value={estimateSpec.ramMb}
|
|
||||||
min={128}
|
|
||||||
step={128}
|
|
||||||
onChange={(value) => setEstimateSpec({ ...estimateSpec, ramMb: value })}
|
|
||||||
/>
|
|
||||||
<NumberField
|
|
||||||
label="磁盘 GB"
|
|
||||||
value={estimateSpec.diskGb}
|
|
||||||
min={1}
|
|
||||||
onChange={(value) => setEstimateSpec({ ...estimateSpec, diskGb: value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{estimate && (
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-[220px_1fr] gap-4">
|
|
||||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
|
||||||
<div className="text-xs text-gray-500">预计最多可开</div>
|
|
||||||
<div className="mt-1 text-3xl font-bold text-black">{estimate.remainingCount}</div>
|
|
||||||
<div className="mt-1 text-xs text-gray-400">
|
|
||||||
理论上限 {estimate.totalCount} 台,当前受 {estimate.bottleneckLabel} 限制
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-hidden rounded-lg border border-gray-200">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="bg-gray-50 text-xs text-gray-500">
|
|
||||||
<tr>
|
|
||||||
<th className="px-3 py-2 text-left font-medium">资源</th>
|
|
||||||
<th className="px-3 py-2 text-right font-medium">实际</th>
|
|
||||||
<th className="px-3 py-2 text-right font-medium">超售后</th>
|
|
||||||
<th className="px-3 py-2 text-right font-medium">已分配</th>
|
|
||||||
<th className="px-3 py-2 text-right font-medium">剩余可开</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-100">
|
|
||||||
{estimate.rows.map((row) => (
|
|
||||||
<tr key={row.label}>
|
|
||||||
<td className="px-3 py-2 text-gray-700">{row.label}</td>
|
|
||||||
<td className="px-3 py-2 text-right font-mono text-xs text-gray-600">{row.actual}</td>
|
|
||||||
<td className="px-3 py-2 text-right font-mono text-xs text-gray-600">{row.capacity}</td>
|
|
||||||
<td className="px-3 py-2 text-right font-mono text-xs text-gray-600">{row.allocated}</td>
|
|
||||||
<td className="px-3 py-2 text-right font-semibold text-black">{row.remainingCount}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white border border-gray-200 rounded-lg p-5">
|
|
||||||
<h2 className="text-sm font-semibold text-black mb-4">内存优化</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<ToggleRow
|
|
||||||
label="KSM 合并"
|
|
||||||
desc="合并容器间相同内存页,减少实际内存占用"
|
|
||||||
value={config.ksm_enabled && ksmSupported}
|
|
||||||
disabled={!ksmSupported}
|
|
||||||
onChange={(v) => setConfig({ ...config, ksm_enabled: v })}
|
|
||||||
extra={ksmSupported ? `已合并 ${status?.ksm_pages || 0} 页` : '当前内核不支持 KSM'}
|
|
||||||
/>
|
|
||||||
<SliderField
|
|
||||||
label="Swap 倾向"
|
|
||||||
value={config.swappiness}
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
suffix=""
|
|
||||||
onChange={(v) => setConfig({ ...config, swappiness: v })}
|
|
||||||
hint="写入 /proc/sys/vm/swappiness,值越低越少使用 swap"
|
|
||||||
/>
|
|
||||||
<ActionRow
|
|
||||||
title="立即回收缓存"
|
|
||||||
desc={reclaimSupported ? '对运行中容器触发一次 cgroup v2 memory.reclaim' : '当前环境未检测到 memory.reclaim'}
|
|
||||||
disabled={!reclaimSupported || reclaiming}
|
|
||||||
busy={reclaiming}
|
|
||||||
onClick={handleReclaimMemory}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving}
|
|
||||||
className="flex items-center gap-2 px-6 py-2.5 bg-black text-white rounded-md hover:bg-gray-800 transition-colors text-sm font-medium disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Save className="w-4 h-4" />
|
|
||||||
{saving ? '保存中...' : '应用设置'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ResourceCard({ icon, label, value, hint }: {
|
|
||||||
icon: ReactNode
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
hint: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-500 mb-1">
|
|
||||||
{icon}{label}
|
|
||||||
</div>
|
|
||||||
<div className="text-2xl font-bold text-black">{value}</div>
|
|
||||||
<div className="text-xs text-gray-400 mt-0.5">{hint}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="text-sm font-medium text-gray-700">{label}</span>
|
|
||||||
<span className="text-sm text-gray-500 font-mono">{value}{suffix}</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={min}
|
|
||||||
max={max}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(parseInt(e.target.value, 10) || min)}
|
|
||||||
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-black"
|
|
||||||
/>
|
|
||||||
<div className="flex justify-between text-[10px] text-gray-300 mt-0.5">
|
|
||||||
<span>{min}{suffix}</span><span>{max}{suffix}</span>
|
|
||||||
</div>
|
|
||||||
{hint && <div className="text-[10px] text-gray-400 mt-1">{hint}</div>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NumberField({ label, value, min, step = 1, onChange }: {
|
|
||||||
label: string
|
|
||||||
value: number
|
|
||||||
min: number
|
|
||||||
step?: number
|
|
||||||
onChange: (value: number) => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<label className="block">
|
|
||||||
<span className="mb-1.5 block text-xs font-medium text-gray-600">{label}</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={min}
|
|
||||||
step={step}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => {
|
|
||||||
const parsed = step % 1 === 0 ? parseInt(e.target.value, 10) : parseFloat(e.target.value)
|
|
||||||
onChange(Math.max(min, Number.isFinite(parsed) ? parsed : min))
|
|
||||||
}}
|
|
||||||
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black focus:border-black focus:outline-none focus:ring-2 focus:ring-black"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToggleRow({ label, desc, value, disabled = false, onChange, extra }: {
|
|
||||||
label: string
|
|
||||||
desc: string
|
|
||||||
value: boolean
|
|
||||||
disabled?: boolean
|
|
||||||
onChange: (v: boolean) => void
|
|
||||||
extra?: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between py-2">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-gray-700">{label}</div>
|
|
||||||
<div className="text-xs text-gray-400">{desc}</div>
|
|
||||||
{extra && <div className="text-xs text-gray-500 mt-0.5">{extra}</div>}
|
|
||||||
</div>
|
|
||||||
<label className={`relative inline-flex items-center ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={value}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => onChange(e.target.checked)}
|
|
||||||
className="sr-only peer"
|
|
||||||
/>
|
|
||||||
<div className="w-9 h-5 bg-gray-300 peer-checked:bg-black rounded-full after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-4"></div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActionRow({ title, desc, disabled, busy, onClick }: {
|
|
||||||
title: string
|
|
||||||
desc: string
|
|
||||||
disabled: boolean
|
|
||||||
busy: boolean
|
|
||||||
onClick: () => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between py-2">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-gray-700">{title}</div>
|
|
||||||
<div className="text-xs text-gray-400">{desc}</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={disabled}
|
|
||||||
className="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 text-sm disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<RotateCcw className={`w-4 h-4 ${busy ? 'animate-spin' : ''}`} />
|
|
||||||
{busy ? '回收中...' : '执行'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
+112
-24
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Network, RefreshCw, Route, Server } from 'lucide-react'
|
import { RefreshCw, Search, Server, X } from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { getRoutingInfo, RoutingInfo } from '../services/api'
|
import { getRoutingInfo, RoutingInfo, NAT4Route, IPv6Route } from '../services/api'
|
||||||
|
|
||||||
export default function Routing() {
|
export default function Routing() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -10,6 +10,8 @@ export default function Routing() {
|
|||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const [nat4Page, setNat4Page] = useState(1)
|
const [nat4Page, setNat4Page] = useState(1)
|
||||||
const [ipv6Page, setIPv6Page] = useState(1)
|
const [ipv6Page, setIPv6Page] = useState(1)
|
||||||
|
const [nat4Search, setNat4Search] = useState('')
|
||||||
|
const [ipv6Search, setIPv6Search] = useState('')
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -25,6 +27,39 @@ export default function Routing() {
|
|||||||
|
|
||||||
useEffect(() => { fetchData() }, [fetchData])
|
useEffect(() => { fetchData() }, [fetchData])
|
||||||
|
|
||||||
|
const nat4Mappings = routing?.nat4_mappings || []
|
||||||
|
const ipv6Assignments = routing?.ipv6_assignments || []
|
||||||
|
const ipv6Prefix = routing?.ipv6_prefixes?.[0]?.prefix || '-'
|
||||||
|
|
||||||
|
// Filter helpers
|
||||||
|
const matchesNat4Search = (m: NAT4Route, query: string) => {
|
||||||
|
if (!query) return true
|
||||||
|
const q = query.toLowerCase()
|
||||||
|
return (
|
||||||
|
String(m.host_port).includes(q) ||
|
||||||
|
String(m.container_port).includes(q) ||
|
||||||
|
m.container_name.toLowerCase().includes(q) ||
|
||||||
|
m.lxc_name.toLowerCase().includes(q) ||
|
||||||
|
(m.ip || '').toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const matchesIPv6Search = (item: IPv6Route, query: string) => {
|
||||||
|
if (!query) return true
|
||||||
|
const q = query.toLowerCase()
|
||||||
|
return (
|
||||||
|
(item.address || '').toLowerCase().includes(q) ||
|
||||||
|
item.container_name.toLowerCase().includes(q) ||
|
||||||
|
item.lxc_name.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredNat4 = useMemo(() => nat4Mappings.filter(m => matchesNat4Search(m, nat4Search)), [nat4Mappings, nat4Search])
|
||||||
|
const filteredIPv6 = useMemo(() => ipv6Assignments.filter(m => matchesIPv6Search(m, ipv6Search)), [ipv6Assignments, ipv6Search])
|
||||||
|
|
||||||
|
// Reset page on search change
|
||||||
|
useEffect(() => { setNat4Page(1) }, [nat4Search])
|
||||||
|
useEffect(() => { setIPv6Page(1) }, [ipv6Search])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
@@ -33,16 +68,13 @@ export default function Routing() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const nat4Mappings = routing?.nat4_mappings || []
|
|
||||||
const ipv6Assignments = routing?.ipv6_assignments || []
|
|
||||||
const ipv6Prefix = routing?.ipv6_prefixes?.[0]?.prefix || '-'
|
|
||||||
const pageSize = 10
|
const pageSize = 10
|
||||||
const nat4TotalPages = Math.max(1, Math.ceil(nat4Mappings.length / pageSize))
|
const nat4TotalPages = Math.max(1, Math.ceil(filteredNat4.length / pageSize))
|
||||||
const ipv6TotalPages = Math.max(1, Math.ceil(ipv6Assignments.length / pageSize))
|
const ipv6TotalPages = Math.max(1, Math.ceil(filteredIPv6.length / pageSize))
|
||||||
const currentNat4Page = Math.min(nat4Page, nat4TotalPages)
|
const currentNat4Page = Math.min(nat4Page, nat4TotalPages)
|
||||||
const currentIPv6Page = Math.min(ipv6Page, ipv6TotalPages)
|
const currentIPv6Page = Math.min(ipv6Page, ipv6TotalPages)
|
||||||
const pagedNat4Mappings = nat4Mappings.slice((currentNat4Page - 1) * pageSize, currentNat4Page * pageSize)
|
const pagedNat4Mappings = filteredNat4.slice((currentNat4Page - 1) * pageSize, currentNat4Page * pageSize)
|
||||||
const pagedIPv6Assignments = ipv6Assignments.slice((currentIPv6Page - 1) * pageSize, currentIPv6Page * pageSize)
|
const pagedIPv6Assignments = filteredIPv6.slice((currentIPv6Page - 1) * pageSize, currentIPv6Page * pageSize)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
@@ -64,7 +96,7 @@ export default function Routing() {
|
|||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<CapacityCard
|
<CapacityCard
|
||||||
title="NAT4 端口"
|
title="NAT4 端口"
|
||||||
icon={<Route className="h-5 w-5 text-gray-600" />}
|
icon={<Nat4Icon />}
|
||||||
remaining={routing?.nat4.remaining || '0'}
|
remaining={routing?.nat4.remaining || '0'}
|
||||||
total={routing?.nat4.total || '0'}
|
total={routing?.nat4.total || '0'}
|
||||||
used={routing?.nat4.used || 0}
|
used={routing?.nat4.used || 0}
|
||||||
@@ -72,7 +104,7 @@ export default function Routing() {
|
|||||||
/>
|
/>
|
||||||
<CapacityCard
|
<CapacityCard
|
||||||
title="IPv6 地址"
|
title="IPv6 地址"
|
||||||
icon={<Network className="h-5 w-5 text-gray-600" />}
|
icon={<IPv6Icon />}
|
||||||
remaining={formatCapacity(routing?.ipv6.remaining || '0')}
|
remaining={formatCapacity(routing?.ipv6.remaining || '0')}
|
||||||
total={formatCapacity(routing?.ipv6.total || '0')}
|
total={formatCapacity(routing?.ipv6.total || '0')}
|
||||||
used={routing?.ipv6.used || 0}
|
used={routing?.ipv6.used || 0}
|
||||||
@@ -80,13 +112,32 @@ export default function Routing() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
|
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
|
||||||
<div className="border-b border-gray-200 px-4 py-3">
|
<div className="border-b border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between gap-3">
|
||||||
<div className="text-sm font-medium text-black">NAT4 端口分配</div>
|
<div>
|
||||||
<div className="mt-1 text-xs text-gray-500">共 {nat4Mappings.length} 条映射</div>
|
<div className="text-sm font-medium text-black dark:text-white">NAT4 端口分配</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{nat4Search ? `搜索 "${nat4Search}" 结果 ${filteredNat4.length} 条,` : ''}共 {nat4Mappings.length} 条映射
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative w-48">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={nat4Search}
|
||||||
|
onChange={e => setNat4Search(e.target.value)}
|
||||||
|
placeholder="搜索端口/容器..."
|
||||||
|
className="w-full pl-8 pr-7 py-1.5 text-xs border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-black dark:text-white focus:outline-none focus:ring-1 focus:ring-black dark:focus:ring-white"
|
||||||
|
/>
|
||||||
|
{nat4Search && (
|
||||||
|
<button onClick={() => setNat4Search('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{nat4Mappings.length === 0 ? (
|
{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">
|
<div className="overflow-x-auto">
|
||||||
@@ -130,7 +181,7 @@ export default function Routing() {
|
|||||||
<Pagination
|
<Pagination
|
||||||
page={currentNat4Page}
|
page={currentNat4Page}
|
||||||
totalPages={nat4TotalPages}
|
totalPages={nat4TotalPages}
|
||||||
totalItems={nat4Mappings.length}
|
totalItems={filteredNat4.length}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
onPageChange={setNat4Page}
|
onPageChange={setNat4Page}
|
||||||
/>
|
/>
|
||||||
@@ -138,13 +189,32 @@ export default function Routing() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
|
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
|
||||||
<div className="border-b border-gray-200 px-4 py-3">
|
<div className="border-b border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between gap-3">
|
||||||
<div className="text-sm font-medium text-black">IPv6 地址分配</div>
|
<div>
|
||||||
<div className="mt-1 text-xs text-gray-500">共 {ipv6Assignments.length} 个地址</div>
|
<div className="text-sm font-medium text-black dark:text-white">IPv6 地址分配</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{ipv6Search ? `搜索 "${ipv6Search}" 结果 ${filteredIPv6.length} 条,` : ''}共 {ipv6Assignments.length} 个地址
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative w-48">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={ipv6Search}
|
||||||
|
onChange={e => setIPv6Search(e.target.value)}
|
||||||
|
placeholder="搜索地址/容器..."
|
||||||
|
className="w-full pl-8 pr-7 py-1.5 text-xs border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-black dark:text-white focus:outline-none focus:ring-1 focus:ring-black dark:focus:ring-white"
|
||||||
|
/>
|
||||||
|
{ipv6Search && (
|
||||||
|
<button onClick={() => setIPv6Search('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{ipv6Assignments.length === 0 ? (
|
{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">
|
<div className="overflow-x-auto">
|
||||||
@@ -184,7 +254,7 @@ export default function Routing() {
|
|||||||
<Pagination
|
<Pagination
|
||||||
page={currentIPv6Page}
|
page={currentIPv6Page}
|
||||||
totalPages={ipv6TotalPages}
|
totalPages={ipv6TotalPages}
|
||||||
totalItems={ipv6Assignments.length}
|
totalItems={filteredIPv6.length}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
onPageChange={setIPv6Page}
|
onPageChange={setIPv6Page}
|
||||||
/>
|
/>
|
||||||
@@ -287,3 +357,21 @@ function formatCapacity(value: string): string {
|
|||||||
if (value === 'large') return '充足'
|
if (value === 'large') return '充足'
|
||||||
return value
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Camera, RefreshCw, Server } from 'lucide-react'
|
import { Camera, RefreshCw, Server, Trash2 } from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { getSnapshots, Snapshot } from '../services/api'
|
import { deleteContainerSnapshot, getSnapshots, Snapshot } from '../services/api'
|
||||||
|
import { useDialog } from '../components/Dialog'
|
||||||
|
|
||||||
export default function Snapshots() {
|
export default function Snapshots() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const dialog = useDialog()
|
||||||
const [snapshots, setSnapshots] = useState<Snapshot[]>([])
|
const [snapshots, setSnapshots] = useState<Snapshot[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [deleting, setDeleting] = useState<string | null>(null)
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -23,6 +26,25 @@ export default function Snapshots() {
|
|||||||
|
|
||||||
useEffect(() => { fetchData() }, [fetchData])
|
useEffect(() => { fetchData() }, [fetchData])
|
||||||
|
|
||||||
|
const handleDelete = async (snapshot: Snapshot) => {
|
||||||
|
const confirmed = await dialog.confirm(
|
||||||
|
'删除快照',
|
||||||
|
`确认删除容器 ${snapshot.container_name} 的快照吗?此操作不可恢复。`
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
setDeleting(snapshot.id)
|
||||||
|
try {
|
||||||
|
await deleteContainerSnapshot(snapshot.container_id, snapshot.id)
|
||||||
|
setSnapshots(prev => prev.filter(s => s.id !== snapshot.id))
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { message?: string } } }
|
||||||
|
dialog.alert('删除失败', error.response?.data?.message || '请稍后重试')
|
||||||
|
} finally {
|
||||||
|
setDeleting(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
@@ -66,6 +88,7 @@ export default function Snapshots() {
|
|||||||
<th className="px-4 py-3 text-left font-medium">类型</th>
|
<th className="px-4 py-3 text-left font-medium">类型</th>
|
||||||
<th className="px-4 py-3 text-left font-medium">创建者</th>
|
<th className="px-4 py-3 text-left font-medium">创建者</th>
|
||||||
<th className="px-4 py-3 text-right font-medium">大小</th>
|
<th className="px-4 py-3 text-right font-medium">大小</th>
|
||||||
|
<th className="px-4 py-3 text-center font-medium">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100">
|
<tbody className="divide-y divide-gray-100">
|
||||||
@@ -89,6 +112,16 @@ export default function Snapshots() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-600">{snapshot.created_by || '-'}</td>
|
<td className="px-4 py-3 text-gray-600">{snapshot.created_by || '-'}</td>
|
||||||
<td className="px-4 py-3 text-right font-mono text-xs text-gray-600">{formatBytes(snapshot.size_bytes || 0)}</td>
|
<td className="px-4 py-3 text-right font-mono text-xs text-gray-600">{formatBytes(snapshot.size_bytes || 0)}</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(snapshot)}
|
||||||
|
disabled={deleting === snapshot.id}
|
||||||
|
className="inline-flex items-center justify-center p-1.5 rounded text-red-500 hover:bg-red-50 transition-colors disabled:opacity-50"
|
||||||
|
title="删除快照"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Copy, KeyRound, LogIn, RefreshCw, ScrollText, UserCog, X } from 'lucide-react'
|
||||||
|
import { useDialog } from '../components/Dialog'
|
||||||
|
import api, { AuditLog, LoginLog } from '../services/api'
|
||||||
|
import { copyToClipboard } from '../utils/clipboard'
|
||||||
|
|
||||||
|
interface SubUserItem {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
container_names: string[]
|
||||||
|
container_uuids: string[]
|
||||||
|
container_name: string
|
||||||
|
container_uuid: string
|
||||||
|
access_code: string
|
||||||
|
password?: string
|
||||||
|
created_at: string
|
||||||
|
last_login: string
|
||||||
|
last_login_ip: string
|
||||||
|
last_login_ua: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuditLogExt extends AuditLog {
|
||||||
|
ip?: string
|
||||||
|
user_agent?: string
|
||||||
|
success?: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubUserManagement() {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const [users, setUsers] = useState<SubUserItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [auditLogs, setAuditLogs] = useState<AuditLogExt[] | null>(null)
|
||||||
|
const [loginLogs, setLoginLogs] = useState<LoginLog[] | null>(null)
|
||||||
|
const [modalTitle, setModalTitle] = useState('')
|
||||||
|
const [passwordUser, setPasswordUser] = useState<SubUserItem | null>(null)
|
||||||
|
const [rotatingPassword, setRotatingPassword] = useState(false)
|
||||||
|
const [logPage, setLogPage] = useState(1)
|
||||||
|
const [logPageSize, setLogPageSize] = useState(10)
|
||||||
|
|
||||||
|
const fetchUsers = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get<{ success: boolean; data: SubUserItem[] }>('/sub-users')
|
||||||
|
setUsers(res.data.data || [])
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { fetchUsers() }, [fetchUsers])
|
||||||
|
|
||||||
|
const managementUrl = (user: SubUserItem) => `${window.location.origin}/login?code=${user.access_code}`
|
||||||
|
|
||||||
|
const copyText = async (text: string) => {
|
||||||
|
await copyToClipboard(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rotatePassword = async (user: SubUserItem) => {
|
||||||
|
setRotatingPassword(true)
|
||||||
|
try {
|
||||||
|
const res = await api.post(`/sub-users/${user.id}/rotate-password`)
|
||||||
|
const data = res.data.data
|
||||||
|
const updatedUser = {
|
||||||
|
...user,
|
||||||
|
username: data?.username || user.username,
|
||||||
|
access_code: data?.access_code || user.access_code,
|
||||||
|
password: data?.password || '',
|
||||||
|
}
|
||||||
|
setUsers((prev) => prev.map((item) => (item.id === user.id ? updatedUser : item)))
|
||||||
|
setPasswordUser(updatedUser)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { message?: string } } }
|
||||||
|
dialog.alert('轮换失败', error.response?.data?.message || '请稍后重试')
|
||||||
|
} finally {
|
||||||
|
setRotatingPassword(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showAuditLogs = async (user: SubUserItem) => {
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/sub-users/${user.id}/audit-logs`)
|
||||||
|
setAuditLogs(res.data.data || [])
|
||||||
|
setLoginLogs(null)
|
||||||
|
setModalTitle(`${user.username} - 操作日志`)
|
||||||
|
setLogPage(1)
|
||||||
|
} catch {
|
||||||
|
dialog.alert('错误', '获取操作日志失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showLoginLogs = async (user: SubUserItem) => {
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/sub-users/${user.id}/login-logs`)
|
||||||
|
setLoginLogs(res.data.data || [])
|
||||||
|
setAuditLogs(null)
|
||||||
|
setModalTitle(`${user.username} - 登录日志`)
|
||||||
|
setLogPage(1)
|
||||||
|
} catch {
|
||||||
|
dialog.alert('错误', '获取登录日志失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setAuditLogs(null)
|
||||||
|
setLoginLogs(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentLogTotal = auditLogs?.length ?? loginLogs?.length ?? 0
|
||||||
|
const logTotalPages = Math.max(1, Math.ceil(currentLogTotal / logPageSize))
|
||||||
|
const currentLogPage = Math.min(logPage, logTotalPages)
|
||||||
|
const logStart = (currentLogPage - 1) * logPageSize
|
||||||
|
const currentAuditLogs = auditLogs?.slice(logStart, logStart + logPageSize)
|
||||||
|
const currentLoginLogs = loginLogs?.slice(logStart, logStart + logPageSize)
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-black" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-black dark:text-white">子用户管理</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">容器分配的子用户列表,共 {users.length} 个</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
|
||||||
|
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-lg bg-gray-100 dark:bg-gray-800">
|
||||||
|
<UserCog className="h-7 w-7 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">暂无子用户</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full min-w-[820px] text-sm">
|
||||||
|
<thead className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-medium w-12">#</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">容器名称</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">UUID</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium">最后登录</th>
|
||||||
|
<th className="px-4 py-3 text-center font-medium">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
|
{users.map((user, index) => (
|
||||||
|
<tr key={user.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||||
|
<td className="px-4 py-3 text-gray-400 dark:text-gray-500">{index + 1}</td>
|
||||||
|
<td className="px-4 py-3 font-medium text-black dark:text-white">{user.container_name || '-'}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-400">{user.container_uuid || '-'}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
|
||||||
|
{user.last_login ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs">{user.last_login}</div>
|
||||||
|
<div className="text-xs text-gray-400 dark:text-gray-500">{user.last_login_ip}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">从未登录</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setPasswordUser(user)}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1.5 rounded text-xs text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/30 transition-colors"
|
||||||
|
title="查看密码"
|
||||||
|
>
|
||||||
|
<KeyRound className="w-3.5 h-3.5" />
|
||||||
|
查看密码
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => showAuditLogs(user)}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1.5 rounded text-xs text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/30 transition-colors"
|
||||||
|
title="查看操作日志"
|
||||||
|
>
|
||||||
|
<ScrollText className="w-3.5 h-3.5" />
|
||||||
|
操作日志
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => showLoginLogs(user)}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1.5 rounded text-xs text-green-600 hover:bg-green-50 dark:hover:bg-green-900/30 transition-colors"
|
||||||
|
title="查看登录日志"
|
||||||
|
>
|
||||||
|
<LogIn className="w-3.5 h-3.5" />
|
||||||
|
登录日志
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{passwordUser && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 shadow-xl w-full max-w-lg overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between gap-3 px-5 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="text-sm font-semibold text-black dark:text-white">查看密码</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => rotatePassword(passwordUser)}
|
||||||
|
disabled={rotatingPassword}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded text-xs text-amber-700 bg-amber-50 hover:bg-amber-100 dark:text-amber-300 dark:bg-amber-900/30 dark:hover:bg-amber-900/50 disabled:opacity-50"
|
||||||
|
title="轮换密码"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 ${rotatingPassword ? 'animate-spin' : ''}`} />
|
||||||
|
{rotatingPassword ? '轮换中...' : '轮换密码'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setPasswordUser(null)} className="p-1 text-gray-400 hover:text-black dark:hover:text-white rounded">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 text-sm space-y-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<span className="shrink-0 text-gray-500 dark:text-gray-400">用户</span>
|
||||||
|
<span className="min-w-0 text-right font-medium text-black dark:text-white break-all">{passwordUser.username}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<span className="shrink-0 text-gray-500 dark:text-gray-400">地址</span>
|
||||||
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
|
<span className="font-mono text-xs text-black dark:text-white break-all">{managementUrl(passwordUser)}</span>
|
||||||
|
<button onClick={() => copyText(managementUrl(passwordUser))} className="shrink-0 p-0.5 text-gray-400 hover:text-black dark:hover:text-white rounded" title="复制">
|
||||||
|
<Copy className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<span className="shrink-0 text-gray-500 dark:text-gray-400">密码</span>
|
||||||
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
|
<span className="font-mono text-xs text-black dark:text-white break-all">
|
||||||
|
{passwordUser.password || '未保存,请轮换生成新密码'}
|
||||||
|
</span>
|
||||||
|
{passwordUser.password && (
|
||||||
|
<button onClick={() => copyText(passwordUser.password || '')} className="shrink-0 p-0.5 text-gray-400 hover:text-black dark:hover:text-white rounded" title="复制">
|
||||||
|
<Copy className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Log Modal */}
|
||||||
|
{(auditLogs || loginLogs) && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 shadow-xl w-full max-w-3xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||||
|
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="text-sm font-semibold text-black dark:text-white">{modalTitle}</h3>
|
||||||
|
<button onClick={closeModal} className="p-1 text-gray-400 hover:text-black dark:hover:text-white rounded">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-auto flex-1">
|
||||||
|
{auditLogs && (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-xs text-gray-500 dark:text-gray-400 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left">操作时间</th>
|
||||||
|
<th className="px-4 py-2 text-left">操作</th>
|
||||||
|
<th className="px-4 py-2 text-left">IP</th>
|
||||||
|
<th className="px-4 py-2 text-left">UA</th>
|
||||||
|
<th className="px-4 py-2 text-center">结果</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
|
{auditLogs.length === 0 ? (
|
||||||
|
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-400">暂无操作日志</td></tr>
|
||||||
|
) : currentAuditLogs?.map((log, i) => (
|
||||||
|
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||||
|
<td className="px-4 py-2 text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">{log.time}</td>
|
||||||
|
<td className="px-4 py-2 text-xs text-gray-700 dark:text-gray-300">{log.action}</td>
|
||||||
|
<td className="px-4 py-2 text-xs font-mono text-gray-500 dark:text-gray-400">{log.ip || '-'}</td>
|
||||||
|
<td className="px-4 py-2 text-xs text-gray-500 dark:text-gray-400 max-w-[200px] truncate" title={log.user_agent}>{log.user_agent || '-'}</td>
|
||||||
|
<td className="px-4 py-2 text-center">
|
||||||
|
{log.success !== undefined ? (
|
||||||
|
log.success ? (
|
||||||
|
<span className="inline-flex px-2 py-0.5 rounded text-xs bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400">成功</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex px-2 py-0.5 rounded text-xs bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-400" title={log.error}>{log.error ? '失败' : '失败'}</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
{loginLogs && (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-xs text-gray-500 dark:text-gray-400 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left">登录时间</th>
|
||||||
|
<th className="px-4 py-2 text-left">登录 IP</th>
|
||||||
|
<th className="px-4 py-2 text-left">UA</th>
|
||||||
|
<th className="px-4 py-2 text-center">结果</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
|
{loginLogs.length === 0 ? (
|
||||||
|
<tr><td colSpan={4} className="px-4 py-8 text-center text-gray-400">暂无登录日志</td></tr>
|
||||||
|
) : currentLoginLogs?.map((log, i) => (
|
||||||
|
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||||
|
<td className="px-4 py-2 text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">{log.time}</td>
|
||||||
|
<td className="px-4 py-2 text-xs font-mono text-gray-500 dark:text-gray-400">{log.ip}</td>
|
||||||
|
<td className="px-4 py-2 text-xs text-gray-500 dark:text-gray-400 max-w-[250px] truncate" title={log.user_agent}>{log.user_agent}</td>
|
||||||
|
<td className="px-4 py-2 text-center">
|
||||||
|
{log.success ? (
|
||||||
|
<span className="inline-flex px-2 py-0.5 rounded text-xs bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400">成功</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex px-2 py-0.5 rounded text-xs bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-400">失败</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{currentLogTotal > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-gray-200 dark:border-gray-700 px-5 py-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span>
|
||||||
|
显示 {logStart + 1}-{Math.min(logStart + logPageSize, currentLogTotal)} / {currentLogTotal}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={logPageSize}
|
||||||
|
onChange={(event) => {
|
||||||
|
setLogPageSize(Number(event.target.value))
|
||||||
|
setLogPage(1)
|
||||||
|
}}
|
||||||
|
className="h-7 rounded border border-gray-300 bg-white px-2 text-xs text-gray-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<option value={10}>10 / 页</option>
|
||||||
|
<option value={20}>20 / 页</option>
|
||||||
|
<option value={50}>50 / 页</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={() => setLogPage(1)} disabled={currentLogPage === 1} className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800">首页</button>
|
||||||
|
<button onClick={() => setLogPage((page) => Math.max(1, page - 1))} disabled={currentLogPage === 1} className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800">上一页</button>
|
||||||
|
<span className="px-2 text-xs text-gray-500 dark:text-gray-400">{currentLogPage} / {logTotalPages}</span>
|
||||||
|
<button onClick={() => setLogPage((page) => Math.min(logTotalPages, page + 1))} disabled={currentLogPage === logTotalPages} className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800">下一页</button>
|
||||||
|
<button onClick={() => setLogPage(logTotalPages)} disabled={currentLogPage === logTotalPages} className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800">末页</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ export interface Container {
|
|||||||
id: number
|
id: number
|
||||||
uuid: string
|
uuid: string
|
||||||
name: string
|
name: string
|
||||||
|
virtualization?: string
|
||||||
template: string
|
template: string
|
||||||
vcpu: number
|
vcpu: number
|
||||||
ram_mb: number
|
ram_mb: number
|
||||||
@@ -85,6 +86,7 @@ export interface Container {
|
|||||||
export interface Template {
|
export interface Template {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
type?: string
|
||||||
distro: string
|
distro: string
|
||||||
release: string
|
release: string
|
||||||
arch: string
|
arch: string
|
||||||
@@ -94,6 +96,7 @@ export interface Template {
|
|||||||
|
|
||||||
export interface CreateContainerRequest {
|
export interface CreateContainerRequest {
|
||||||
name: string
|
name: string
|
||||||
|
virtualization: string
|
||||||
template_id: string
|
template_id: string
|
||||||
vcpu: number
|
vcpu: number
|
||||||
cpu_percent: number
|
cpu_percent: number
|
||||||
@@ -156,6 +159,7 @@ export interface HostInfo {
|
|||||||
|
|
||||||
export interface ContainerUsage {
|
export interface ContainerUsage {
|
||||||
memory_usage_bytes: number
|
memory_usage_bytes: number
|
||||||
|
memory_total_bytes?: number
|
||||||
cpu_usage_usec: number
|
cpu_usage_usec: number
|
||||||
cpu_usage_pct: number
|
cpu_usage_pct: number
|
||||||
disk_usage_bytes: number
|
disk_usage_bytes: number
|
||||||
@@ -167,6 +171,10 @@ export interface ContainerUsage {
|
|||||||
disk_write_bytes: number
|
disk_write_bytes: number
|
||||||
disk_read_bps: number
|
disk_read_bps: number
|
||||||
disk_write_bps: number
|
disk_write_bps: number
|
||||||
|
load1: number
|
||||||
|
load5: number
|
||||||
|
load15: number
|
||||||
|
guest_metrics?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface APIResponse<T = unknown> {
|
export interface APIResponse<T = unknown> {
|
||||||
@@ -197,6 +205,14 @@ export interface LoginLog {
|
|||||||
success: boolean
|
success: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AuditLog {
|
||||||
|
time: string
|
||||||
|
action: string
|
||||||
|
target: string
|
||||||
|
detail: string
|
||||||
|
user: string
|
||||||
|
}
|
||||||
|
|
||||||
export const getLoginLogs = () =>
|
export const getLoginLogs = () =>
|
||||||
api.get<APIResponse<LoginLog[]>>('/login-logs')
|
api.get<APIResponse<LoginLog[]>>('/login-logs')
|
||||||
|
|
||||||
@@ -330,6 +346,7 @@ export const getTemplates = () =>
|
|||||||
export interface ImageInfo {
|
export interface ImageInfo {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
type: string
|
||||||
distro: string
|
distro: string
|
||||||
release: string
|
release: string
|
||||||
arch: string
|
arch: string
|
||||||
@@ -338,13 +355,14 @@ export interface ImageInfo {
|
|||||||
enabled: boolean
|
enabled: boolean
|
||||||
downloading: boolean
|
downloading: boolean
|
||||||
size_bytes: number
|
size_bytes: number
|
||||||
|
manual_path?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getImages = () =>
|
export const getImages = () =>
|
||||||
api.get<APIResponse<ImageInfo[]>>('/images')
|
api.get<APIResponse<ImageInfo[]>>('/images')
|
||||||
|
|
||||||
export const downloadImage = (templateId: string) =>
|
export const downloadImage = (templateId: string) =>
|
||||||
api.post<APIResponse>('/images/download', { template_id: templateId }, { timeout: 600000 }) // 10min timeout
|
api.post<APIResponse>('/images/download', { template_id: templateId }, { timeout: 1800000 }) // 30min timeout
|
||||||
|
|
||||||
export const deleteImage = (templateId: string) =>
|
export const deleteImage = (templateId: string) =>
|
||||||
api.delete<APIResponse>('/images/delete', { data: { template_id: templateId } })
|
api.delete<APIResponse>('/images/delete', { data: { template_id: templateId } })
|
||||||
@@ -352,8 +370,8 @@ export const deleteImage = (templateId: string) =>
|
|||||||
export const toggleImage = (templateId: string, enabled: boolean) =>
|
export const toggleImage = (templateId: string, enabled: boolean) =>
|
||||||
api.put<APIResponse>('/images/toggle', { template_id: templateId, enabled })
|
api.put<APIResponse>('/images/toggle', { template_id: templateId, enabled })
|
||||||
|
|
||||||
export const getEnabledImages = () =>
|
export const getEnabledImages = (virtualization = 'lxc') =>
|
||||||
api.get<APIResponse<Template[]>>('/images/enabled')
|
api.get<APIResponse<Template[]>>('/images/enabled', { params: { type: virtualization } })
|
||||||
|
|
||||||
// Dashboard
|
// Dashboard
|
||||||
export const getDashboard = () =>
|
export const getDashboard = () =>
|
||||||
@@ -362,45 +380,6 @@ export const getDashboard = () =>
|
|||||||
export const getHostInfo = () =>
|
export const getHostInfo = () =>
|
||||||
api.get<APIResponse<HostInfo>>('/host-info')
|
api.get<APIResponse<HostInfo>>('/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<APIResponse<OversellConfig>>('/oversell')
|
|
||||||
|
|
||||||
export const updateOversell = (data: OversellConfig) =>
|
|
||||||
api.post<APIResponse<OversellConfig>>('/oversell', data)
|
|
||||||
|
|
||||||
export const getOversellStatus = () =>
|
|
||||||
api.get<APIResponse<OversellStatus>>('/oversell/status')
|
|
||||||
|
|
||||||
export const reclaimMemory = () =>
|
|
||||||
api.post<APIResponse<ReclaimResult>>('/oversell/reclaim')
|
|
||||||
|
|
||||||
// Snapshots
|
// Snapshots
|
||||||
export interface Snapshot {
|
export interface Snapshot {
|
||||||
id: string
|
id: string
|
||||||
@@ -464,6 +443,13 @@ export const getWebSSHUrl = (containerName: string) => {
|
|||||||
return `${protocol}//${window.location.host}/api/ssh?${params.toString()}`
|
return `${protocol}//${window.location.host}/api/ssh?${params.toString()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getWebVNCUrl = (containerName: string, ticket?: string) => {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
const params = new URLSearchParams({ container: containerName })
|
||||||
|
if (ticket) params.set('ticket', ticket)
|
||||||
|
return `${protocol}//${window.location.host}/api/vnc?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
// Task Queue
|
// Task Queue
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: string
|
id: string
|
||||||
@@ -553,6 +539,9 @@ export const getSecuritySummary = () =>
|
|||||||
export const createWebSSHTicket = (containerName: string) =>
|
export const createWebSSHTicket = (containerName: string) =>
|
||||||
api.post<APIResponse<{ ticket: string }>>('/ssh-ticket', { container_name: containerName })
|
api.post<APIResponse<{ ticket: string }>>('/ssh-ticket', { container_name: containerName })
|
||||||
|
|
||||||
|
export const createVNCTicket = (containerName: string) =>
|
||||||
|
api.post<APIResponse<{ ticket: string }>>('/vnc-ticket', { container_name: containerName })
|
||||||
|
|
||||||
// Version
|
// Version
|
||||||
export const getVersion = () =>
|
export const getVersion = () =>
|
||||||
api.get<APIResponse<{ version: string }>>('/version')
|
api.get<APIResponse<{ version: string }>>('/version')
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||||
|
if (!text) return false
|
||||||
|
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
// Fall through for non-secure HTTP origins where Clipboard API is blocked.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const textarea = document.createElement('textarea')
|
||||||
|
textarea.value = text
|
||||||
|
textarea.setAttribute('readonly', '')
|
||||||
|
textarea.style.position = 'fixed'
|
||||||
|
textarea.style.top = '0'
|
||||||
|
textarea.style.left = '0'
|
||||||
|
textarea.style.width = '1px'
|
||||||
|
textarea.style.height = '1px'
|
||||||
|
textarea.style.opacity = '0'
|
||||||
|
textarea.style.pointerEvents = 'none'
|
||||||
|
|
||||||
|
const selection = document.getSelection()
|
||||||
|
const selectedRange = selection?.rangeCount ? selection.getRangeAt(0) : null
|
||||||
|
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.focus({ preventScroll: true })
|
||||||
|
textarea.select()
|
||||||
|
textarea.setSelectionRange(0, textarea.value.length)
|
||||||
|
|
||||||
|
let copied = false
|
||||||
|
try {
|
||||||
|
copied = document.execCommand('copy')
|
||||||
|
} finally {
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
if (selection && selectedRange) {
|
||||||
|
selection.removeAllRanges()
|
||||||
|
selection.addRange(selectedRange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return copied
|
||||||
|
}
|
||||||
@@ -14,5 +14,6 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
|
target: 'es2022',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+9
-1
@@ -249,7 +249,12 @@ install_apt() {
|
|||||||
quota \
|
quota \
|
||||||
e2fsprogs \
|
e2fsprogs \
|
||||||
xfsprogs \
|
xfsprogs \
|
||||||
dnsmasq-base
|
dnsmasq-base \
|
||||||
|
qemu-kvm \
|
||||||
|
libvirt-daemon-system \
|
||||||
|
libvirt-clients \
|
||||||
|
virtinst \
|
||||||
|
virt-manager
|
||||||
}
|
}
|
||||||
|
|
||||||
enable_el_repos() {
|
enable_el_repos() {
|
||||||
@@ -372,6 +377,7 @@ setup_lxc_services() {
|
|||||||
systemctl enable --now lxcfs >/dev/null 2>&1 || true
|
systemctl enable --now lxcfs >/dev/null 2>&1 || true
|
||||||
systemctl enable --now lxc-net >/dev/null 2>&1 || true
|
systemctl enable --now lxc-net >/dev/null 2>&1 || true
|
||||||
systemctl enable --now lxc >/dev/null 2>&1 || true
|
systemctl enable --now lxc >/dev/null 2>&1 || true
|
||||||
|
systemctl enable --now libvirtd >/dev/null 2>&1 || true
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -382,6 +388,8 @@ setup_lxc_services() {
|
|||||||
rc-service lxc start >/dev/null 2>&1 || true
|
rc-service lxc start >/dev/null 2>&1 || true
|
||||||
rc-update add lxcfs default >/dev/null 2>&1 || true
|
rc-update add lxcfs default >/dev/null 2>&1 || true
|
||||||
rc-service lxcfs start >/dev/null 2>&1 || true
|
rc-service lxcfs start >/dev/null 2>&1 || true
|
||||||
|
rc-update add libvirtd default >/dev/null 2>&1 || true
|
||||||
|
rc-service libvirtd start >/dev/null 2>&1 || true
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user