first commit

This commit is contained in:
MengMengCode
2026-06-05 19:23:28 +08:00
commit e306d2d06b
66 changed files with 18825 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
name: Build
on:
push:
branches:
- main
- master
tags:
- "v*"
pull_request:
workflow_dispatch:
permissions:
contents: write
jobs:
linux-amd64:
name: Linux amd64
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.22.x"
cache-dependency-path: backend/go.sum
- name: Build
shell: bash
run: bash build.sh
- name: Package
shell: bash
run: |
mkdir -p dist package/clicd-linux-amd64
cp build/clicd package/clicd-linux-amd64/clicd
cp build/install.sh package/clicd-linux-amd64/install.sh
chmod +x package/clicd-linux-amd64/clicd package/clicd-linux-amd64/install.sh
tar -C package -czf dist/clicd-linux-amd64.tar.gz clicd-linux-amd64
cp build/clicd dist/clicd-linux-amd64
sha256sum dist/* > dist/SHA256SUMS
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: clicd-linux-amd64
path: dist/*
- name: Publish GitHub Release
if: startsWith(github.ref, 'refs/tags/v')
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
gh release create "$GITHUB_REF_NAME" dist/* --generate-notes || \
gh release upload "$GITHUB_REF_NAME" dist/* --clobber
+64
View File
@@ -0,0 +1,64 @@
# Dependencies
node_modules/
frontend/node_modules/
# Frontend build output
/frontend/dist/
/web/
# Go embedded frontend build output.
# Keep only the placeholder so `go build` can compile before frontend assets exist.
backend/internal/server/web/*
!backend/internal/server/web/.gitkeep
# Build artifacts
/build/
*.exe
*.dll
*.so
*.dylib
*.test
*.out
*.prof
# Local deploy and debug scripts
deploy.py
check_*.py
reset_pass.py
# Runtime/config data
.clicd/
config.json
*.db
*.sqlite
*.sqlite3
# Environment and secrets
.env
.env.*
*.pem
*.key
id_rsa*
# Logs
*.log
logs/
# Python cache
__pycache__/
*.py[cod]
# Go
backend/vendor/
backend/tmp/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
+129
View File
@@ -0,0 +1,129 @@
<p align="center">
<img src="frontend/public/favicon.svg" width="96" alt="CLICD">
</p>
<h1 align="center">CLICD</h1>
<p align="center">
<img alt="Go" src="https://img.shields.io/badge/Go-1.22-00ADD8?style=flat-square&logo=go&logoColor=white">
<img alt="React" src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=111111">
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5-3178C6?style=flat-square&logo=typescript&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="LXC" src="https://img.shields.io/badge/LXC-container-111111?style=flat-square">
</p>
CLICD 是一个面向 LXC 的轻量容器管理面板,提供 Web 控制台、CLI、批量任务、镜像管理、NAT 端口、IPv6 分配、WebSSH、资源限制、流量限制和安全告警能力。它适合用来管理小型 VPS 上的 LXC 容器,也适合需要批量创建和分发子用户管理链接的场景。
## 功能介绍
1. 支持 Ubuntu、Debian、Alpine、CentOS、Arch Linux、Fedora、Rocky Linux 等系统镜像。镜像可以在镜像管理中按需下载;如果宿主机资源比较小,建议优先选择 Alpine 这类轻量镜像。
2. 支持 WebSSH 管理,可以在浏览器里一键进入容器终端,不需要手动复制 SSH 密码。
3. 支持子用户管理链接,管理员可以把指定容器分发给拼车用户,子用户只能管理自己被授权的容器。
4. 支持设置 NAT4 端口数量、NAT 端口映射和协议限制,并支持分配公网 IPv6。IPv6 分配要求宿主机本身拥有可路由的 IPv6 地址段。
5. 支持超售容量估算。宿主机控制页提供 KSM 合并、Swap 倾向和 cgroup v2 `memory.reclaim` 一次性回收能力;不会展示 LXC 下无实际通用效果的内存气球回收开关。
6. 支持 API 接入,可以通过 API 完成容器、任务、镜像、端口、流量、安全告警等功能的自动化控制。
7. 支持仅使用 CLI 管理。需要关闭 Web 控制台时,可以停止并禁用 systemd 服务,然后使用 `clicd cli --no-web` 进入命令行模式。
8. 支持设置容器有效期。到期后容器会自动关机,子用户无法继续操作,只有管理员重新设置延期日期后才能恢复使用。
9. 支持单向和双向网络流量限制。达到限制后容器会自动关机,避免流量超额。
10. 内置基于 conntrack 的轻量安全告警。系统不会保存完整正常连接日志,但会对端口扫描、横向扫描、爆破倾向、SMTP 滥用、UDP 反射、挖矿端口、代理/VPN/Tor 等可疑行为生成告警并写入审计日志。
## 技术栈
- Backend: Go, net/http, LXC, cgroup v2, iptables, conntrack
- Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js
- Runtime: Linux, systemd, LXC
- Build: GitHub Actions, Node.js 20, Go 1.22
## 安装
推荐使用 GitHub Actions 构建出的 Release 产物。下载 `clicd-linux-amd64.tar.gz` 后在目标服务器上执行:
```bash
tar -xzf clicd-linux-amd64.tar.gz
cd clicd-linux-amd64
sudo ./install.sh
```
安装完成后访问:
```text
http://YOUR_SERVER_IP:8999
```
首次启动时会自动初始化管理员账号:
```text
Username: admin
Password: 随机 16 位密码
```
安装脚本会尝试从 systemd 日志中输出初始账号密码。如果机器上已经存在 `/root/.clicd/config.json`,则不会重新生成密码。
查看初始密码日志:
```bash
journalctl -u clicd --no-pager -n 80 | grep -E "Username:|Password:"
```
## GitHub Actions 构建
仓库内置 `.github/workflows/build.yml`
- 推送到 `main``master` 时自动构建 Linux amd64 产物。
- 创建 `v*` 标签时自动发布 GitHub Release。
- 支持手动 `workflow_dispatch` 构建。
发布版本示例:
```bash
git tag v1.0.0
git push origin v1.0.0
```
Release 会包含:
```text
clicd-linux-amd64.tar.gz
clicd-linux-amd64
SHA256SUMS
```
## CLI 模式
进入 CLI
```bash
clicd cli
```
仅使用 CLI,不自动拉起 Web 服务:
```bash
systemctl stop clicd
systemctl disable clicd
clicd cli --no-web
```
重新启用 Web 控制台:
```bash
systemctl enable --now clicd
```
## 常用服务命令
```bash
systemctl status clicd
systemctl restart clicd
journalctl -u clicd -f
```
## 注意事项
- 需要 root 权限安装和运行。
- 宿主机需要支持 LXC。
- NAT 和端口映射依赖 iptables。
- 安全告警依赖 conntrack 或 `/proc/net/nf_conntrack`
- IPv6 分配要求宿主机拥有可用公网 IPv6 地址段。
- 配置文件位于 `/root/.clicd/config.json`,其中包含敏感信息,不要提交到公开仓库。
+12
View File
@@ -0,0 +1,12 @@
module clicd
go 1.22.0
require (
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/gorilla/websocket v1.5.3
golang.org/x/crypto v0.28.0
golang.org/x/term v0.28.0
)
require golang.org/x/sys v0.29.0 // indirect
+10
View File
@@ -0,0 +1,10 @@
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
+263
View File
@@ -0,0 +1,263 @@
package api
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net"
"net/http"
"strconv"
"strings"
"time"
"clicd/internal/config"
"github.com/golang-jwt/jwt/v5"
)
type ApiKey struct {
ID string `json:"id"`
Name string `json:"name"`
Key string `json:"key,omitempty"`
Prefix string `json:"prefix"`
IPWhitelist string `json:"ip_whitelist"`
CreatedAt string `json:"created_at"`
LastUsed string `json:"last_used"`
}
// HandleApiKeys handles GET (list) and POST (create) for API keys
func HandleApiKeys(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
listApiKeys(w, r)
case http.MethodPost:
createApiKey(w, r)
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
}
}
// HandleApiKeyDelete handles DELETE for a specific API key
func HandleApiKeyDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
keyID := strings.TrimPrefix(r.URL.Path, "/api/api-keys/")
if keyID == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Key ID required"})
return
}
config.DeleteApiKey(keyID)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "API key deleted"})
}
func listApiKeys(w http.ResponseWriter, r *http.Request) {
keys := make([]ApiKey, 0)
for _, k := range config.AppConfig.ApiKeys {
keys = append(keys, ApiKey{
ID: k.ID,
Name: k.Name,
Prefix: k.Prefix,
IPWhitelist: k.IPWhitelist,
CreatedAt: k.CreatedAt,
LastUsed: k.LastUsed,
})
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: keys})
}
func createApiKey(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
IPWhitelist string `json:"ip_whitelist"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Name is required"})
return
}
// Generate key: clicd_sk_ + 32 hex chars
rawBytes := make([]byte, 16)
rand.Read(rawBytes)
rawKey := "clicd_sk_" + hex.EncodeToString(rawBytes)
now := time.Now().Format("2006-01-02 15:04:05")
key := config.ApiKeyConfig{
ID: generateShortID(),
Name: req.Name,
KeyHash: hashKey(rawKey),
Prefix: rawKey[:13] + "...",
IPWhitelist: strings.TrimSpace(req.IPWhitelist),
CreatedAt: now,
}
config.AppConfig.ApiKeys = append(config.AppConfig.ApiKeys, key)
config.SaveConfig()
jsonResponse(w, http.StatusCreated, APIResponse{
Success: true,
Message: "API key created. Save this key now - it won't be shown again.",
Data: ApiKey{
ID: key.ID,
Name: key.Name,
Key: rawKey,
Prefix: key.Prefix,
IPWhitelist: key.IPWhitelist,
CreatedAt: key.CreatedAt,
},
})
}
func generateShortID() string {
b := make([]byte, 4)
rand.Read(b)
return hex.EncodeToString(b)
}
// hashKey creates a simple hash for storage (not reversible)
func hashKey(key string) string {
b := make([]byte, 32)
for i := range key {
b[i%32] ^= key[i]
}
return hex.EncodeToString(b)
}
// validateApiKey checks if the given key is valid and IP is allowed
func validateApiKey(rawKey, clientIP string) bool {
hashed := hashKey(rawKey)
for _, k := range config.AppConfig.ApiKeys {
if k.KeyHash == hashed {
if k.IPWhitelist == "" {
return true
}
return isIPAllowed(clientIP, k.IPWhitelist)
}
}
return false
}
// isIPAllowed checks if clientIP matches any entry in the whitelist
func isIPAllowed(clientIP, whitelist string) bool {
clientIP = strings.TrimSpace(clientIP)
// Strip port if present
if idx := strings.LastIndex(clientIP, ":"); idx > strings.LastIndex(clientIP, "]") {
clientIP = clientIP[:idx]
}
for _, entry := range strings.Split(whitelist, "\n") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
if strings.Contains(entry, "/") {
// CIDR match
if ipInCIDR(clientIP, entry) {
return true
}
} else if entry == clientIP {
return true
}
}
return false
}
func ipInCIDR(ipStr, cidr string) bool {
parts := strings.Split(cidr, "/")
if len(parts) != 2 {
return false
}
// Simple prefix match for IPv4
ip := netParseIP(ipStr)
cidrIP := netParseIP(parts[0])
if ip == nil || cidrIP == nil {
return false
}
bits, err := strconv.Atoi(parts[1])
if err != nil || bits < 0 || bits > 32 {
return false
}
mask := uint32(0xFFFFFFFF) << (32 - bits)
ipVal := ip4ToUint32(ip)
cidrVal := ip4ToUint32(cidrIP)
return (ipVal & mask) == (cidrVal & mask)
}
func netParseIP(s string) net.IP {
s = strings.TrimSpace(s)
if idx := strings.LastIndex(s, ":"); idx > strings.LastIndex(s, "]") {
s = s[:idx]
}
return net.ParseIP(s)
}
func ip4ToUint32(ip net.IP) uint32 {
ip = ip.To4()
if ip == nil {
return 0
}
return uint32(ip[0])<<24 | uint32(ip[1])<<16 | uint32(ip[2])<<8 | uint32(ip[3])
}
// updateApiKeyLastUsed marks the key as recently used
func updateApiKeyLastUsed(rawKey string) {
hashed := hashKey(rawKey)
now := time.Now().Format("2006-01-02 15:04:05")
for i := range config.AppConfig.ApiKeys {
if config.AppConfig.ApiKeys[i].KeyHash == hashed {
config.AppConfig.ApiKeys[i].LastUsed = now
config.SaveConfig()
return
}
}
}
// ApiKeyMiddleware authenticates requests via X-API-Key header or ?api_key query param
func ApiKeyMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Check header
apiKey := r.Header.Get("X-API-Key")
if apiKey == "" {
// Check query param
apiKey = r.URL.Query().Get("api_key")
}
if apiKey == "" {
// Check Bearer token (some clients use this)
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer clicd_sk_") {
apiKey = strings.TrimPrefix(auth, "Bearer ")
}
}
// Get client IP
clientIP := r.RemoteAddr
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
clientIP = strings.Split(forwarded, ",")[0]
}
if apiKey == "" || !validateApiKey(apiKey, clientIP) {
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid API key or IP not in whitelist"})
return
}
// Generate a short-lived JWT so downstream admin middleware passes
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"username": config.AppConfig.AdminUser,
"api_key": true,
"exp": time.Now().Add(5 * time.Minute).Unix(),
"iat": time.Now().Unix(),
})
tokenString, _ := token.SignedString([]byte(config.AppConfig.JWTSecret))
// Set cookie for subsequent requests
http.SetCookie(w, &http.Cookie{
Name: "clicd_token",
Value: tokenString,
Path: "/",
HttpOnly: false,
SameSite: http.SameSiteLaxMode,
MaxAge: 300,
})
updateApiKeyLastUsed(apiKey)
next(w, r)
}
}
+213
View File
@@ -0,0 +1,213 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"time"
"clicd/internal/config"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type LoginResponse struct {
Token string `json:"token"`
Username string `json:"username"`
}
type APIResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Data interface{} `json:"data,omitempty"`
}
func jsonResponse(w http.ResponseWriter, status int, resp APIResponse) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(resp)
}
func tokenFromRequest(r *http.Request) string {
authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
return strings.TrimPrefix(authHeader, "Bearer ")
}
cookie, err := r.Cookie("clicd_token")
if err == nil {
return cookie.Value
}
return ""
}
func isValidToken(tokenString string) bool {
_, ok := claimsFromToken(tokenString)
return ok
}
func claimsFromToken(tokenString string) (jwt.MapClaims, bool) {
if tokenString == "" {
return nil, false
}
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return []byte(config.AppConfig.JWTSecret), nil
})
if err != nil || !token.Valid {
return nil, false
}
claims, ok := token.Claims.(jwt.MapClaims)
return claims, ok
}
func claimsFromRequest(r *http.Request) (jwt.MapClaims, bool) {
return claimsFromToken(tokenFromRequest(r))
}
func isSubUserRequest(r *http.Request) bool {
claims, ok := claimsFromRequest(r)
if !ok {
return false
}
_, ok = claims["sub_user"]
return ok
}
func isAuthenticatedRequest(r *http.Request) bool {
return isValidToken(tokenFromRequest(r))
}
// HandleLogin processes login requests
func HandleLogin(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 LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
ip := r.RemoteAddr
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
ip = forwarded
}
ua := r.Header.Get("User-Agent")
if req.Username != config.AppConfig.AdminUser {
RecordLoginLog(req.Username, ip, ua, false)
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid credentials"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(config.AppConfig.AdminPassHash), []byte(req.Password)); err != nil {
RecordLoginLog(req.Username, ip, ua, false)
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid credentials"})
return
}
RecordLoginLog(req.Username, ip, ua, true)
// Generate JWT token
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"username": req.Username,
"exp": time.Now().Add(24 * time.Hour).Unix(),
"iat": time.Now().Unix(),
})
tokenString, err := token.SignedString([]byte(config.AppConfig.JWTSecret))
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate token"})
return
}
jsonResponse(w, http.StatusOK, APIResponse{
Success: true,
Data: LoginResponse{
Token: tokenString,
Username: req.Username,
},
})
}
// HandleChangePassword processes password change requests
func HandleChangePassword(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 {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
if len(req.NewPassword) < 8 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "New password must be at least 8 characters"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(config.AppConfig.AdminPassHash), []byte(req.OldPassword)); err != nil {
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Current password is incorrect"})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to hash password"})
return
}
config.AppConfig.AdminPassHash = string(hash)
if err := config.SaveConfig(); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save configuration"})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Password changed successfully"})
}
// HandleCheckAuth checks if the user is authenticated
func HandleCheckAuth(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Authenticated"})
}
// AuthMiddleware extracts JWT from cookies or Authorization header
func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tokenString := tokenFromRequest(r)
if !isValidToken(tokenString) {
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Authentication required"})
return
}
next(w, r)
}
}
// AdminMiddleware requires a valid administrator token and rejects sub-user tokens.
func AdminMiddleware(next http.HandlerFunc) http.HandlerFunc {
return AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
if isSubUserRequest(r) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Administrator permission required"})
return
}
next(w, r)
})
}
+440
View File
@@ -0,0 +1,440 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"clicd/internal/config"
"clicd/internal/lxc"
)
var lxcManager = lxc.NewManager()
// HandleContainers handles container list and creation
func HandleContainers(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
listContainers(w, r)
case http.MethodPost:
createContainer(w, r)
default:
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
}
}
// HandleSingleContainer handles individual container operations by ID or name: /api/containers/{id-or-name}/...
func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/containers/")
parts := strings.SplitN(path, "/", 2)
c := containerByIdentifier(parts[0])
if c == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
}
id := c.ID
action := ""
if len(parts) > 1 {
action = parts[1]
}
switch {
case action == "start" && r.Method == http.MethodPost:
HandleSingleTaskAction(w, r, id, "start")
case action == "stop" && r.Method == http.MethodPost:
HandleSingleTaskAction(w, r, id, "stop")
case action == "restart" && r.Method == http.MethodPost:
HandleSingleTaskAction(w, r, id, "restart")
case action == "reinstall" && r.Method == http.MethodPost:
HandleSingleTaskAction(w, r, id, "reinstall")
case action == "delete" && r.Method == http.MethodDelete:
HandleSingleTaskAction(w, r, id, "delete")
case action == "reset-password" && r.Method == http.MethodPost:
resetSSHPassword(w, r, id)
case action == "usage" && r.Method == http.MethodGet:
getUsage(w, r, id)
case action == "traffic" && r.Method == http.MethodGet:
getTraffic(w, r, id)
case action == "traffic-reset" && r.Method == http.MethodPost:
resetTraffic(w, r, id)
case action == "traffic-limit" && r.Method == http.MethodPut:
updateTrafficLimit(w, r, id)
case action == "resource-limit" && r.Method == http.MethodPut:
updateResourceLimit(w, r, id)
case action == "random-port" && r.Method == http.MethodGet:
getRandomPort(w, r, id)
case action == "expiry" && r.Method == http.MethodPut:
updateExpiry(w, r, id)
case action == "ipv6" && r.Method == http.MethodPost:
assignIPv6(w, r, id)
case action == "port-mappings" && r.Method == http.MethodPost:
addPortMapping(w, r, id)
case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodPut:
updatePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/"))
case strings.HasPrefix(action, "port-mappings/") && r.Method == http.MethodDelete:
deletePortMapping(w, r, id, strings.TrimPrefix(action, "port-mappings/"))
case r.Method == http.MethodGet:
getContainer(w, r, id)
default:
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"})
}
}
func listContainers(w http.ResponseWriter, r *http.Request) {
containers, err := lxcManager.ListContainers()
if err != nil {
containers = config.AppConfig.Containers
}
containers = filterContainersForRequest(r, containers)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: containers})
}
func createContainer(w http.ResponseWriter, r *http.Request) {
var cfg lxc.ContainerConfig
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
if cfg.Name == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Container name is required"})
return
}
if cfg.TemplateID == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Template is required"})
return
}
if cfg.VCPU <= 0 {
cfg.VCPU = 1
}
if cfg.RAMMB < 128 {
cfg.RAMMB = 512
}
if cfg.DiskGB < 1 {
cfg.DiskGB = 5
}
if cfg.PortMappingCount < 2 {
cfg.PortMappingCount = 2
}
if cfg.PortMappingCount > 64 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Port mapping count cannot exceed 64"})
return
}
if err := validateContainerResourceRequest(cfg.VCPU, cfg.RAMMB, cfg.DiskGB); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
if cfg.ExpiresAt != "" {
expiresAt, ok := lxc.ParseExpiration(cfg.ExpiresAt)
if !ok {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid expiration date"})
return
}
if !time.Now().Before(expiresAt) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Expiration date must be in the future"})
return
}
}
if err := lxcManager.CreateContainer(cfg); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
jsonResponse(w, http.StatusCreated, APIResponse{Success: true, Message: "Container created successfully"})
}
func getContainer(w http.ResponseWriter, r *http.Request, id int) {
c := config.FindContainer(id)
if c == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: c})
}
func getUsage(w http.ResponseWriter, r *http.Request, id int) {
usage, err := lxcManager.GetResourceUsage(id)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: usage})
}
func getTraffic(w http.ResponseWriter, r *http.Request, id int) {
info := lxcManager.GetTrafficInfo(id)
if info == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
}
func updateExpiry(w http.ResponseWriter, r *http.Request, id int) {
var req struct {
ExpiresAt string `json:"expires_at"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request"})
return
}
c := config.FindContainer(id)
if c == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
}
c.ExpiresAt = req.ExpiresAt
config.SaveConfig()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Expiry updated"})
}
func resetTraffic(w http.ResponseWriter, r *http.Request, id int) {
c := config.FindContainer(id)
if c == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
}
c.TrafficUsedRX = 0
c.TrafficUsedTX = 0
c.TrafficResetDate = time.Now().Format("2006-01")
config.SaveConfig()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Traffic reset"})
}
func updateTrafficLimit(w http.ResponseWriter, r *http.Request, id int) {
var req struct {
Mode string `json:"traffic_mode"`
MonthlyGB int `json:"monthly_traffic_gb"`
TrafficInGB int `json:"traffic_in_gb"`
TrafficOutGB int `json:"traffic_out_gb"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request"})
return
}
c := config.FindContainer(id)
if c == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
}
c.TrafficMode = req.Mode
c.MonthlyTrafficGB = req.MonthlyGB
c.TrafficInGB = req.TrafficInGB
c.TrafficOutGB = req.TrafficOutGB
config.SaveConfig()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Traffic limit updated"})
}
func updateResourceLimit(w http.ResponseWriter, r *http.Request, id int) {
var req struct {
VCPU float64 `json:"vcpu"`
RAMMB int `json:"ram_mb"`
IOMBps int `json:"io_speed_mbps"`
BWMbps int `json:"network_bw_mbps"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request"})
return
}
c := config.FindContainer(id)
if c == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
}
// Update config
nextVCPU := c.VCPU
nextRAMMB := c.RAMMB
if req.VCPU > 0 {
nextVCPU = req.VCPU
}
if req.RAMMB > 0 {
nextRAMMB = req.RAMMB
}
if err := validateContainerResourceRequest(nextVCPU, nextRAMMB, c.DiskGB); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
c.VCPU = nextVCPU
c.RAMMB = nextRAMMB
c.IOSpeedMBps = req.IOMBps
c.NetworkBWMbps = req.BWMbps
config.SaveConfig()
// Re-apply resource limits to running container
if c.Status == "running" {
if err := lxcManager.ApplyContainerLimits(c); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Resource limits updated"})
}
func getRandomPort(w http.ResponseWriter, r *http.Request, id int) {
c := config.FindContainer(id)
if c == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
}
// Find a random unused port between 10000-65535
used := map[int]bool{}
for _, pm := range c.PortMappings {
used[pm.HostPort] = true
}
// Also check all containers
for _, oc := range config.AppConfig.Containers {
if oc.ID == id {
continue
}
for _, pm := range oc.PortMappings {
used[pm.HostPort] = true
}
}
// Try random ports
for tries := 0; tries < 100; tries++ {
port := 10000 + (int(time.Now().UnixNano()) % 55535)
if !used[port] {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]int{"port": port}})
return
}
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]int{"port": 0}})
}
// HandleTemplates returns available LXC templates
func HandleTemplates(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
templates := lxc.GetTemplates()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: templates})
}
// HandleDashboard returns dashboard stats
func HandleDashboard(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
containers, err := lxcManager.ListContainers()
if err != nil {
containers = config.AppConfig.Containers
}
running := 0
stopped := 0
for _, c := range containers {
if c.Status == "running" {
running++
} else {
stopped++
}
}
stats := map[string]interface{}{
"total_containers": len(containers),
"running": running,
"stopped": stopped,
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: stats})
}
// HandleHostInfo returns host machine resource info
func HandleHostInfo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
info := getHostInfo()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
}
func resetSSHPassword(w http.ResponseWriter, r *http.Request, id int) {
c := config.FindContainer(id)
if c != nil && lxc.IsExpired(*c) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "容器已到期,不允许此操作"})
return
}
newPassword, err := lxcManager.ResetSSHPassword(id)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
jsonResponse(w, http.StatusOK, APIResponse{
Success: true,
Message: "SSH password reset successfully",
Data: map[string]string{"password": newPassword},
})
}
func addPortMapping(w http.ResponseWriter, r *http.Request, id int) {
var pm config.PortMapping
if err := json.NewDecoder(r.Body).Decode(&pm); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
mappings, err := lxcManager.AddPortMapping(id, pm)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: mappings})
}
func updatePortMapping(w http.ResponseWriter, r *http.Request, id int, indexStr string) {
index, err := strconv.Atoi(indexStr)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid port mapping index"})
return
}
var pm config.PortMapping
if err := json.NewDecoder(r.Body).Decode(&pm); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
if isSubUserRequest(r) {
c := config.FindContainer(id)
if c == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
}
if index < 0 || index >= len(c.PortMappings) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid port mapping index"})
return
}
if pm.ContainerPort < 1 || pm.ContainerPort > 65535 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "container port must be 1-65535"})
return
}
existing := c.PortMappings[index]
pm = config.PortMapping{
ContainerPort: pm.ContainerPort,
HostPort: existing.HostPort,
Protocol: existing.Protocol,
Description: existing.Description,
}
}
mappings, err := lxcManager.UpdatePortMapping(id, index, pm)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: mappings})
}
func deletePortMapping(w http.ResponseWriter, r *http.Request, id int, indexStr string) {
index, err := strconv.Atoi(indexStr)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid port mapping index"})
return
}
mappings, err := lxcManager.DeletePortMapping(id, index)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: mappings})
}
+376
View File
@@ -0,0 +1,376 @@
package api
import (
"bufio"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"sync"
"syscall"
"time"
"clicd/internal/lxc"
)
type HostInfo struct {
CPU CpuInfo `json:"cpu"`
RAM MemoryInfo `json:"ram"`
Disk DiskInfo `json:"disk"`
Network NetworkInfo `json:"network"`
DiskIO DiskIOInfo `json:"disk_io"`
Load LoadInfo `json:"load"`
}
type LoadInfo struct {
Load1 float64 `json:"load1"`
Load5 float64 `json:"load5"`
Load15 float64 `json:"load15"`
}
type CpuInfo struct {
Cores int `json:"cores"`
Usage float64 `json:"usage_pct"`
}
type MemoryInfo struct {
TotalMB int64 `json:"total_mb"`
UsedMB int64 `json:"used_mb"`
FreeMB int64 `json:"free_mb"`
}
type DiskInfo struct {
TotalGB float64 `json:"total_gb"`
UsedGB float64 `json:"used_gb"`
FreeGB float64 `json:"free_gb"`
}
type NetworkInfo struct {
RXBytes uint64 `json:"rx_bytes"`
TXBytes uint64 `json:"tx_bytes"`
RXBps float64 `json:"rx_bps"`
TXBps float64 `json:"tx_bps"`
PublicIPv4 string `json:"public_ipv4"`
PublicIPv4Interface string `json:"public_ipv4_interface"`
PublicIPv6 string `json:"public_ipv6"`
PublicIPv6Interface string `json:"public_ipv6_interface"`
IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"`
}
type DiskIOInfo struct {
ReadBytes uint64 `json:"read_bytes"`
WriteBytes uint64 `json:"write_bytes"`
ReadBps float64 `json:"read_bps"`
WriteBps float64 `json:"write_bps"`
}
var hostCPUMu sync.Mutex
var lastHostCPU cpuTimes
var hostIOMu sync.Mutex
var lastHostIO hostIOSample
type cpuTimes struct {
Total uint64
Idle uint64
}
type hostIOSample struct {
RXBytes uint64
TXBytes uint64
ReadBytes uint64
WriteBytes uint64
At int64
}
func getHostInfo() HostInfo {
info := HostInfo{
CPU: CpuInfo{Cores: runtime.NumCPU()},
}
info.RAM = getMemoryInfo()
info.Disk = getDiskInfo()
info.CPU.Usage = getCPUUsage()
info.Network, info.DiskIO = getHostRates()
info.Load = getLoadInfo()
return info
}
func getMemoryInfo() MemoryInfo {
f, err := os.Open("/proc/meminfo")
if err != nil {
return MemoryInfo{TotalMB: 0, UsedMB: 0, FreeMB: 0}
}
defer f.Close()
var total, available, free int64
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
val, _ := strconv.ParseInt(fields[1], 10, 64)
switch fields[0] {
case "MemTotal:":
total = val / 1024
case "MemAvailable:":
available = val / 1024
case "MemFree:":
free = val / 1024
}
}
used := total - available
if available == 0 {
used = total - free
}
return MemoryInfo{
TotalMB: total,
UsedMB: used,
FreeMB: available,
}
}
func getDiskInfo() DiskInfo {
var stat syscall.Statfs_t
if err := syscall.Statfs("/", &stat); err != nil {
// Try command-based fallback
cmd := exec.Command("df", "-BG", "/")
output, err := cmd.Output()
if err == nil {
lines := strings.Split(string(output), "\n")
if len(lines) >= 2 {
fields := strings.Fields(lines[1])
if len(fields) >= 4 {
total, _ := parseSizeGBf(fields[1])
used, _ := parseSizeGBf(fields[2])
free, _ := parseSizeGBf(fields[3])
return DiskInfo{TotalGB: total, UsedGB: used, FreeGB: free}
}
}
}
return DiskInfo{}
}
total := float64(int64(stat.Blocks)*int64(stat.Bsize)) / (1024 * 1024 * 1024)
free := float64(int64(stat.Bavail)*int64(stat.Bsize)) / (1024 * 1024 * 1024)
used := total - free
return DiskInfo{
TotalGB: total,
UsedGB: used,
FreeGB: free,
}
}
func getCPUUsage() float64 {
current, err := readCPUTimes()
if err != nil {
return 0
}
hostCPUMu.Lock()
defer hostCPUMu.Unlock()
if lastHostCPU.Total == 0 {
lastHostCPU = current
return 0
}
totalDelta := current.Total - lastHostCPU.Total
idleDelta := current.Idle - lastHostCPU.Idle
lastHostCPU = current
if totalDelta == 0 {
return 0
}
usage := (1 - float64(idleDelta)/float64(totalDelta)) * 100
if usage < 0 {
return 0
}
if usage > 100 {
return 100
}
return usage
}
func readCPUTimes() (cpuTimes, error) {
f, err := os.Open("/proc/stat")
if err != nil {
return cpuTimes{}, err
}
defer f.Close()
scanner := bufio.NewScanner(f)
if !scanner.Scan() {
return cpuTimes{}, scanner.Err()
}
fields := strings.Fields(scanner.Text())
if len(fields) < 8 || fields[0] != "cpu" {
return cpuTimes{}, nil
}
var values []uint64
for _, field := range fields[1:] {
value, _ := strconv.ParseUint(field, 10, 64)
values = append(values, value)
}
var total uint64
for _, value := range values {
total += value
}
idle := values[3]
if len(values) > 4 {
idle += values[4]
}
return cpuTimes{Total: total, Idle: idle}, nil
}
func parseSizeGB(s string) (int64, error) {
s = strings.TrimSuffix(s, "G")
s = strings.TrimSpace(s)
val, err := strconv.ParseInt(s, 10, 64)
return val, err
}
func parseSizeGBf(s string) (float64, error) {
s = strings.TrimSuffix(s, "G")
s = strings.TrimSpace(s)
val, err := strconv.ParseFloat(s, 64)
return val, err
}
func getHostRates() (NetworkInfo, DiskIOInfo) {
rx, tx := readHostNetworkBytes()
readBytes, writeBytes := readHostDiskBytes()
now := unixNano()
network := NetworkInfo{RXBytes: rx, TXBytes: tx}
publicIPv4 := lxc.DetectPublicIPv4()
network.PublicIPv4 = publicIPv4.Address
network.PublicIPv4Interface = publicIPv4.Interface
network.IPv6Prefixes = lxc.DetectPublicIPv6Prefixes()
if len(network.IPv6Prefixes) > 0 {
network.PublicIPv6 = network.IPv6Prefixes[0].Address
network.PublicIPv6Interface = network.IPv6Prefixes[0].Interface
}
diskIO := DiskIOInfo{ReadBytes: readBytes, WriteBytes: writeBytes}
hostIOMu.Lock()
defer hostIOMu.Unlock()
if lastHostIO.At == 0 {
lastHostIO = hostIOSample{RXBytes: rx, TXBytes: tx, ReadBytes: readBytes, WriteBytes: writeBytes, At: now}
return network, diskIO
}
elapsed := float64(now-lastHostIO.At) / 1_000_000_000
if elapsed > 0 {
if rx >= lastHostIO.RXBytes {
network.RXBps = float64(rx-lastHostIO.RXBytes) / elapsed
}
if tx >= lastHostIO.TXBytes {
network.TXBps = float64(tx-lastHostIO.TXBytes) / elapsed
}
if readBytes >= lastHostIO.ReadBytes {
diskIO.ReadBps = float64(readBytes-lastHostIO.ReadBytes) / elapsed
}
if writeBytes >= lastHostIO.WriteBytes {
diskIO.WriteBps = float64(writeBytes-lastHostIO.WriteBytes) / elapsed
}
}
lastHostIO = hostIOSample{RXBytes: rx, TXBytes: tx, ReadBytes: readBytes, WriteBytes: writeBytes, At: now}
return network, diskIO
}
func readHostNetworkBytes() (uint64, uint64) {
entries, err := os.ReadDir("/sys/class/net")
if err != nil {
return 0, 0
}
var rx, tx uint64
for _, entry := range entries {
name := entry.Name()
if name == "lo" {
continue
}
rx += readUintFile("/sys/class/net/" + name + "/statistics/rx_bytes")
tx += readUintFile("/sys/class/net/" + name + "/statistics/tx_bytes")
}
return rx, tx
}
func readHostDiskBytes() (uint64, uint64) {
f, err := os.Open("/proc/diskstats")
if err != nil {
return 0, 0
}
defer f.Close()
var readSectors, writeSectors uint64
scanner := bufio.NewScanner(f)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 14 {
continue
}
device := fields[2]
if strings.HasPrefix(device, "loop") ||
strings.HasPrefix(device, "ram") ||
strings.HasPrefix(device, "fd") ||
strings.HasPrefix(device, "sr") {
continue
}
read, _ := strconv.ParseUint(fields[5], 10, 64)
write, _ := strconv.ParseUint(fields[9], 10, 64)
readSectors += read
writeSectors += write
}
return readSectors * 512, writeSectors * 512
}
func readUintFile(path string) uint64 {
data, err := os.ReadFile(path)
if err != nil {
return 0
}
value, _ := strconv.ParseUint(strings.TrimSpace(string(data)), 10, 64)
return value
}
func unixNano() int64 {
return time.Now().UnixNano()
}
func getLoadInfo() LoadInfo {
f, err := os.Open("/proc/loadavg")
if err != nil {
return LoadInfo{}
}
defer f.Close()
scanner := bufio.NewScanner(f)
if !scanner.Scan() {
return LoadInfo{}
}
fields := strings.Fields(scanner.Text())
if len(fields) < 3 {
return LoadInfo{}
}
load1, _ := strconv.ParseFloat(fields[0], 64)
load5, _ := strconv.ParseFloat(fields[1], 64)
load15, _ := strconv.ParseFloat(fields[2], 64)
return LoadInfo{Load1: load1, Load5: load5, Load15: load15}
}
+320
View File
@@ -0,0 +1,320 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"sync"
"clicd/internal/config"
"clicd/internal/lxc"
)
// ImageInfo represents a template image with its download/enable status.
type ImageInfo 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"`
Downloaded bool `json:"downloaded"`
Enabled bool `json:"enabled"`
Downloading bool `json:"downloading"`
SizeBytes int64 `json:"size_bytes"`
}
var imageDownloadsMu sync.Mutex
var imageDownloads = map[string]bool{}
// isImageDownloaded checks if the LXC download cache exists for a template.
func isImageDownloaded(distro, release, arch string) bool {
downloaded, _ := imageDownloadedInfo(distro, release, arch)
return downloaded
}
// imageDownloadedInfo returns whether the image is downloaded and its total size in bytes.
func imageDownloadedInfo(distro, release, arch string) (bool, int64) {
cachePath := filepath.Join("/var/cache/lxc/download", distro, release, arch)
info, err := os.Stat(cachePath)
if err != nil || !info.IsDir() {
return false, 0
}
// Check directly for rootfs.tar.xz (some LXC versions store it here)
if fi, err := os.Stat(filepath.Join(cachePath, "rootfs.tar.xz")); err == nil {
return true, fi.Size()
}
if fi, err := os.Stat(filepath.Join(cachePath, "meta.tar.xz")); err == nil {
return true, fi.Size()
}
// Check one level deeper (LXC uses variant subdirectories like "default")
entries, err := os.ReadDir(cachePath)
if err != nil {
return false, 0
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
subPath := filepath.Join(cachePath, entry.Name())
if fi, err := os.Stat(filepath.Join(subPath, "rootfs.tar.xz")); err == nil {
return true, fi.Size()
}
if fi, err := os.Stat(filepath.Join(subPath, "meta.tar.xz")); err == nil {
return true, fi.Size()
}
}
return false, 0
}
// getEnabledImageSet returns the set of enabled image IDs.
// If none have been explicitly set, all templates are enabled by default.
func getEnabledImageSet() map[string]bool {
set := make(map[string]bool)
if len(config.AppConfig.EnabledImages) == 0 {
for _, t := range lxc.GetTemplates() {
set[t.ID] = true
}
} else {
for _, id := range config.AppConfig.EnabledImages {
set[id] = true
}
}
return set
}
// HandleImages returns the list of templates with download/enable status.
func HandleImages(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
templates := lxc.GetTemplates()
enabledSet := getEnabledImageSet()
images := make([]ImageInfo, 0, len(templates))
for _, t := range templates {
_, downloading := imageDownloads[t.ID]
downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch)
images = append(images, ImageInfo{
ID: t.ID,
Name: t.Name,
Distro: t.Distro,
Release: t.Release,
Arch: t.Arch,
Description: t.Description,
Downloaded: downloaded,
Enabled: enabledSet[t.ID],
Downloading: downloading,
SizeBytes: size,
})
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: images})
}
// HandleImageDownload downloads a template image from the LXC image server.
func HandleImageDownload(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 {
TemplateID string `json:"template_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.TemplateID == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"})
return
}
tmpl := lxc.FindTemplate(req.TemplateID)
if tmpl == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
return
}
// Already downloaded? Just enable if needed.
if isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch) {
ensureImageEnabled(tmpl.ID)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Already downloaded"})
return
}
// Already downloading?
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()
}()
// Auto-enable on download
ensureImageEnabled(tmpl.ID)
// Download via lxc-create with a temp container, then destroy it.
tmpName := fmt.Sprintf("clicd-img-dl-%s", tmpl.ID)
args := []string{"-n", tmpName, "-t", "download", "--",
"-d", tmpl.Distro, "-r", tmpl.Release, "-a", tmpl.Arch}
if tmpl.Variant != "" {
args = append(args, "--variant", tmpl.Variant)
}
cmd := exec.Command("lxc-create", args...)
output, err := cmd.CombinedOutput()
// Clean up the temp container unconditionally.
exec.Command("lxc-destroy", "-n", tmpName, "-f").Run()
os.RemoveAll(filepath.Join("/var/lib/lxc", tmpName))
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{
Success: false,
Message: fmt.Sprintf("Download failed: %v, output: %s", err, string(output)),
})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Downloaded successfully"})
}
// HandleImageDelete deletes a cached template image from disk.
func HandleImageDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
var req struct {
TemplateID string `json:"template_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.TemplateID == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"})
return
}
tmpl := lxc.FindTemplate(req.TemplateID)
if tmpl == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
return
}
// Remove cache directory
cachePath := filepath.Join("/var/cache/lxc/download", tmpl.Distro, tmpl.Release, tmpl.Arch)
if err := os.RemoveAll(cachePath); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{
Success: false,
Message: fmt.Sprintf("Failed to delete image cache: %v", err),
})
return
}
// Remove from enabled list
removeImageEnabled(tmpl.ID)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Deleted"})
}
// HandleImageToggle enables or disables a template image.
func HandleImageToggle(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
var req struct {
TemplateID string `json:"template_id"`
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.TemplateID == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "template_id required"})
return
}
if req.Enabled {
ensureImageEnabled(req.TemplateID)
} else {
removeImageEnabled(req.TemplateID)
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "OK"})
}
// HandleEnabledImages returns only the enabled AND downloaded templates.
// Used by container create / reinstall to filter available templates.
func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
templates := lxc.GetTemplates()
enabledSet := getEnabledImageSet()
result := make([]lxc.Template, 0)
for _, t := range templates {
if enabledSet[t.ID] && isImageDownloaded(t.Distro, t.Release, t.Arch) {
result = append(result, t)
}
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: result})
}
func ensureImageEnabled(id string) {
// If the enabled list is empty, all templates are currently enabled by default.
// We must populate the list with all template IDs first so that explicit toggles stick.
if len(config.AppConfig.EnabledImages) == 0 {
for _, t := range lxc.GetTemplates() {
config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID)
}
config.SaveConfig()
return // Already contains all IDs including this one
}
found := false
for _, eid := range config.AppConfig.EnabledImages {
if eid == id {
found = true
break
}
}
if !found {
config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, id)
config.SaveConfig()
}
}
func removeImageEnabled(id string) {
// If the enabled list is empty, populate it first with all templates,
// then remove the one being disabled.
if len(config.AppConfig.EnabledImages) == 0 {
for _, t := range lxc.GetTemplates() {
if t.ID != id {
config.AppConfig.EnabledImages = append(config.AppConfig.EnabledImages, t.ID)
}
}
config.SaveConfig()
return
}
filtered := make([]string, 0, len(config.AppConfig.EnabledImages))
for _, eid := range config.AppConfig.EnabledImages {
if eid != id {
filtered = append(filtered, eid)
}
}
if len(filtered) != len(config.AppConfig.EnabledImages) {
config.AppConfig.EnabledImages = filtered
config.SaveConfig()
}
}
+21
View File
@@ -0,0 +1,21 @@
package api
import "net/http"
func HandleIPv6Status(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
status := lxcManager.DetectIPv6Status()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: status})
}
func assignIPv6(w http.ResponseWriter, r *http.Request, id int) {
c, err := lxcManager.AssignIPv6(id)
if err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "IPv6 assigned", Data: c})
}
+224
View File
@@ -0,0 +1,224 @@
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
}
// 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,38 @@
package api
import (
"fmt"
"math"
)
const minVCPU = 0.25
func validateContainerResourceRequest(vcpu float64, ramMB int, diskGB int) error {
host := getHostInfo()
if vcpu <= 0 {
return fmt.Errorf("vCPU must be greater than 0")
}
if vcpu < minVCPU {
return fmt.Errorf("vCPU must be at least %.2f", minVCPU)
}
if math.Abs(vcpu*4-math.Round(vcpu*4)) > 0.000001 {
return fmt.Errorf("vCPU must use 0.25 increments")
}
if host.CPU.Cores > 0 && vcpu > float64(host.CPU.Cores) {
return fmt.Errorf("vCPU cannot exceed host CPU cores (%d)", host.CPU.Cores)
}
if host.RAM.TotalMB > 0 && ramMB > int(host.RAM.TotalMB) {
return fmt.Errorf("memory cannot exceed host memory (%d MB)", host.RAM.TotalMB)
}
if host.Disk.TotalGB > 0 {
maxDiskGB := int(math.Floor(host.Disk.TotalGB))
if maxDiskGB < 1 {
maxDiskGB = 1
}
if diskGB > maxDiskGB {
return fmt.Errorf("disk cannot exceed host disk (%d GB)", maxDiskGB)
}
}
return nil
}
+771
View File
@@ -0,0 +1,771 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"time"
"clicd/internal/config"
)
// SecurityAlert represents a detected abuse event.
type SecurityAlert struct {
ID string `json:"id"`
ContainerName string `json:"container_name"`
Type string `json:"type"` // port_scan, horizontal_scan, brute_force, ddos, spam, malware, mining, proxy, reflection
Severity string `json:"severity"` // low, medium, high, critical
SourceIP string `json:"source_ip"`
TargetIP string `json:"target_ip"`
TargetPort int `json:"target_port"`
Detail string `json:"detail"`
LogLine string `json:"log_line"`
Timestamp string `json:"timestamp"`
Count int `json:"count"`
}
// SecurityScanner monitors container network activity for abuse patterns.
type SecurityScanner struct {
mu sync.Mutex
alerts []SecurityAlert
nextID int
scanCount map[string]int
stopChan chan struct{}
}
type connEntry struct {
dstIP string
dstPort int
proto string
state string
line string
}
type trafficStats struct {
total int
totalSynSent int
destCounts map[string]int
destPorts map[string]map[int]int
portDestCounts map[int]map[string]int
portTotalCounts map[int]int
udpDestCounts map[int]map[string]int
udpTotalCounts map[int]int
synSentByDst map[string]int
}
var scanner *SecurityScanner
var scannerStarted bool
var bruteForcePorts = map[int]string{
21: "FTP",
22: "SSH",
23: "Telnet",
135: "MS-RPC",
139: "NetBIOS",
445: "SMB",
3306: "MySQL",
3389: "RDP",
5432: "PostgreSQL",
5900: "VNC",
5901: "VNC",
5985: "WinRM",
5986: "WinRM",
6379: "Redis",
9200: "Elasticsearch",
27017: "MongoDB",
}
var smtpPorts = map[int]string{
25: "SMTP",
465: "SMTPS",
587: "SMTP submission",
2525: "SMTP alternate",
}
var reflectionPorts = map[int]string{
17: "QOTD",
19: "Chargen",
53: "DNS",
69: "TFTP",
111: "Portmap",
123: "NTP",
137: "NetBIOS",
161: "SNMP",
389: "CLDAP",
500: "IKE",
1900: "SSDP",
3702: "WS-Discovery",
4500: "IPsec NAT-T",
5353: "mDNS",
11211: "Memcached",
}
var miningPorts = map[int]string{
3333: "Stratum",
3334: "Stratum",
3335: "Stratum",
4444: "Stratum",
5555: "Stratum",
7777: "Stratum",
8888: "Stratum",
9999: "Stratum",
14433: "Stratum",
14444: "Stratum",
}
var proxyPorts = map[int]string{
1080: "SOCKS",
3128: "HTTP proxy",
8118: "Privoxy",
9001: "Tor OR",
9030: "Tor directory",
9050: "Tor SOCKS",
1194: "OpenVPN",
51820: "WireGuard",
}
var malwarePorts = map[int]string{
1337: "common backdoor",
31337: "Back Orifice",
4444: "Metasploit/reverse shell",
5555: "Android debug/reverse shell",
6666: "IRC botnet",
6667: "IRC botnet",
6697: "IRC over TLS",
9050: "Tor/C2 proxy",
}
func InitScanner() {
if scannerStarted {
return
}
scannerStarted = true
scanner = newSecurityScanner()
go scanner.monitorLoop()
}
func newSecurityScanner() *SecurityScanner {
return &SecurityScanner{
alerts: make([]SecurityAlert, 0),
scanCount: make(map[string]int),
stopChan: make(chan struct{}),
}
}
func ensureScanner() *SecurityScanner {
if scanner == nil {
scanner = newSecurityScanner()
}
return scanner
}
func (ss *SecurityScanner) monitorLoop() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ss.stopChan:
return
case <-ticker.C:
ss.checkAllContainers()
}
}
}
func (ss *SecurityScanner) checkAllContainers() {
for _, c := range config.AppConfig.Containers {
if c.Status != "running" || c.IP == "" {
continue
}
ss.checkContainer(c.Name, c.IP)
}
}
func (ss *SecurityScanner) checkContainer(name, ip string) {
lines := readConntrackLines(ip)
if len(lines) == 0 {
return
}
stats := newTrafficStats()
for _, line := range lines {
conn, ok := parseConntrackLine(line, ip)
if !ok || conn.dstIP == "" || conn.dstIP == ip {
continue
}
stats.add(conn)
}
if stats.total == 0 {
return
}
ss.detectPortScans(name, ip, stats)
ss.detectBruteForce(name, ip, stats)
ss.detectSpam(name, ip, stats)
ss.detectMassAbuse(name, ip, stats)
ss.detectReflectionAbuse(name, ip, stats)
ss.detectMining(name, ip, stats)
ss.detectProxyAndTor(name, ip, stats)
ss.detectMalware(name, ip, stats)
}
func newTrafficStats() *trafficStats {
return &trafficStats{
destCounts: make(map[string]int),
destPorts: make(map[string]map[int]int),
portDestCounts: make(map[int]map[string]int),
portTotalCounts: make(map[int]int),
udpDestCounts: make(map[int]map[string]int),
udpTotalCounts: make(map[int]int),
synSentByDst: make(map[string]int),
}
}
func (ts *trafficStats) add(conn connEntry) {
ts.total++
ts.destCounts[conn.dstIP]++
if conn.dstPort > 0 {
if ts.destPorts[conn.dstIP] == nil {
ts.destPorts[conn.dstIP] = make(map[int]int)
}
ts.destPorts[conn.dstIP][conn.dstPort]++
if ts.portDestCounts[conn.dstPort] == nil {
ts.portDestCounts[conn.dstPort] = make(map[string]int)
}
ts.portDestCounts[conn.dstPort][conn.dstIP]++
ts.portTotalCounts[conn.dstPort]++
if conn.proto == "udp" {
if ts.udpDestCounts[conn.dstPort] == nil {
ts.udpDestCounts[conn.dstPort] = make(map[string]int)
}
ts.udpDestCounts[conn.dstPort][conn.dstIP]++
ts.udpTotalCounts[conn.dstPort]++
}
}
if conn.state == "SYN_SENT" {
ts.totalSynSent++
ts.synSentByDst[conn.dstIP]++
}
}
func (ss *SecurityScanner) detectPortScans(name, ip string, stats *trafficStats) {
for dstIP, portCounts := range stats.destPorts {
uniquePorts := len(portCounts)
switch {
case uniquePorts >= 20:
ss.addAlert(name, "port_scan", "high", ip, dstIP, 0,
fmt.Sprintf("端口扫描: 同一目标 %s 出现 %d 个不同目标端口", dstIP, uniquePorts),
"")
case uniquePorts >= 8:
ss.addAlert(name, "port_scan", "medium", ip, dstIP, 0,
fmt.Sprintf("可疑端口探测: 同一目标 %s 出现 %d 个不同目标端口", dstIP, uniquePorts),
"")
}
}
for port, targets := range stats.portDestCounts {
uniqueTargets := len(targets)
if service, ok := bruteForcePorts[port]; ok {
if uniqueTargets >= 30 {
ss.addAlert(name, "brute_force", "critical", ip, "*", port,
fmt.Sprintf("横向爆破: 目标服务 %s(%d) 覆盖 %d 个不同 IP", service, port, uniqueTargets),
"")
} else if uniqueTargets >= 10 {
ss.addAlert(name, "brute_force", "high", ip, "*", port,
fmt.Sprintf("疑似横向爆破: 目标服务 %s(%d) 覆盖 %d 个不同 IP", service, port, uniqueTargets),
"")
}
continue
}
if uniqueTargets >= 40 {
ss.addAlert(name, "horizontal_scan", "high", ip, "*", port,
fmt.Sprintf("横向扫描: 同一端口 %d 覆盖 %d 个不同目标", port, uniqueTargets),
"")
} else if uniqueTargets >= 15 {
ss.addAlert(name, "horizontal_scan", "medium", ip, "*", port,
fmt.Sprintf("可疑横向探测: 同一端口 %d 覆盖 %d 个不同目标", port, uniqueTargets),
"")
}
}
}
func (ss *SecurityScanner) detectBruteForce(name, ip string, stats *trafficStats) {
for dstIP, portCounts := range stats.destPorts {
for port, count := range portCounts {
service, sensitive := bruteForcePorts[port]
if !sensitive {
continue
}
if count >= 20 {
ss.addAlert(name, "brute_force", "critical", ip, dstIP, port,
fmt.Sprintf("暴力破解: %s(%d) 当前连接数 %d", service, port, count),
"")
} else if count >= 10 {
ss.addAlert(name, "brute_force", "high", ip, dstIP, port,
fmt.Sprintf("疑似暴力破解: %s(%d) 当前连接数 %d", service, port, count),
"")
}
}
}
}
func (ss *SecurityScanner) detectSpam(name, ip string, stats *trafficStats) {
total, targets := countPorts(stats.portTotalCounts, stats.portDestCounts, smtpPorts)
if total == 0 {
return
}
if targets >= 10 || total >= 30 {
ss.addAlert(name, "spam", "critical", ip, "*", 25,
fmt.Sprintf("疑似垃圾邮件: SMTP 相关端口当前连接 %d 条,覆盖 %d 个目标", total, targets),
"")
} else if targets >= 2 || total >= 5 {
ss.addAlert(name, "spam", "high", ip, "*", 25,
fmt.Sprintf("可疑邮件发送: SMTP 相关端口当前连接 %d 条,覆盖 %d 个目标", total, targets),
"")
}
}
func (ss *SecurityScanner) detectMassAbuse(name, ip string, stats *trafficStats) {
targets := len(stats.destCounts)
switch {
case targets >= 100:
ss.addAlert(name, "ddos", "critical", ip, "*", 0,
fmt.Sprintf("大规模对外连接: 当前覆盖 %d 个不同目标", targets),
"")
case targets >= 35:
ss.addAlert(name, "ddos", "high", ip, "*", 0,
fmt.Sprintf("大量对外连接: 当前覆盖 %d 个不同目标", targets),
"")
}
switch {
case stats.total >= 500:
ss.addAlert(name, "ddos", "critical", ip, "*", 0,
fmt.Sprintf("异常大量连接: 当前 conntrack 出站记录 %d 条", stats.total),
"")
case stats.total >= 200:
ss.addAlert(name, "ddos", "high", ip, "*", 0,
fmt.Sprintf("高连接数: 当前 conntrack 出站记录 %d 条", stats.total),
"")
}
if stats.totalSynSent >= 100 {
ss.addAlert(name, "ddos", "critical", ip, "*", 0,
fmt.Sprintf("大量半开连接: 当前 SYN_SENT %d 条", stats.totalSynSent),
"")
}
for dstIP, count := range stats.synSentByDst {
if count >= 50 {
ss.addAlert(name, "ddos", "critical", ip, dstIP, 0,
fmt.Sprintf("SYN 洪水: 单一目标半开连接 %d 条", count),
"")
} else if count >= 20 {
ss.addAlert(name, "ddos", "high", ip, dstIP, 0,
fmt.Sprintf("可疑 SYN 洪水: 单一目标半开连接 %d 条", count),
"")
}
}
}
func (ss *SecurityScanner) detectReflectionAbuse(name, ip string, stats *trafficStats) {
for port, service := range reflectionPorts {
total := stats.udpTotalCounts[port]
targets := len(stats.udpDestCounts[port])
if total == 0 {
continue
}
if targets >= 30 || total >= 100 {
ss.addAlert(name, "reflection", "critical", ip, "*", port,
fmt.Sprintf("UDP 反射放大: %s(%d) 当前 UDP 连接 %d 条,覆盖 %d 个目标", service, port, total, targets),
"")
} else if targets >= 10 || total >= 30 {
ss.addAlert(name, "reflection", "high", ip, "*", port,
fmt.Sprintf("疑似 UDP 反射放大: %s(%d) 当前 UDP 连接 %d 条,覆盖 %d 个目标", service, port, total, targets),
"")
}
}
}
func (ss *SecurityScanner) detectMining(name, ip string, stats *trafficStats) {
for port, service := range miningPorts {
total := stats.portTotalCounts[port]
if total == 0 {
continue
}
severity := "high"
if total >= 5 {
severity = "critical"
}
ss.addAlert(name, "mining", severity, ip, "*", port,
fmt.Sprintf("疑似挖矿连接: %s/%d 当前连接 %d 条", service, port, total),
"")
}
}
func (ss *SecurityScanner) detectProxyAndTor(name, ip string, stats *trafficStats) {
for port, service := range proxyPorts {
total := stats.portTotalCounts[port]
targets := len(stats.portDestCounts[port])
if total == 0 {
continue
}
if port == 1194 || port == 51820 {
if targets < 3 && total < 10 {
continue
}
}
severity := "high"
if targets >= 10 || total >= 30 {
severity = "critical"
}
ss.addAlert(name, "proxy", severity, ip, "*", port,
fmt.Sprintf("疑似代理/VPN/Tor 滥用: %s(%d) 当前连接 %d 条,覆盖 %d 个目标", service, port, total, targets),
"")
}
total8080 := stats.portTotalCounts[8080]
targets8080 := len(stats.portDestCounts[8080])
if targets8080 >= 5 || total8080 >= 20 {
ss.addAlert(name, "proxy", "high", ip, "*", 8080,
fmt.Sprintf("疑似开放代理流量: HTTP 代理常用端口 8080 当前连接 %d 条,覆盖 %d 个目标", total8080, targets8080),
"")
}
}
func (ss *SecurityScanner) detectMalware(name, ip string, stats *trafficStats) {
for port, label := range malwarePorts {
total := stats.portTotalCounts[port]
if total == 0 {
continue
}
ss.addAlert(name, "malware", "critical", ip, "*", port,
fmt.Sprintf("疑似恶意软件/C2 连接: %s 端口 %d 当前连接 %d 条", label, port, total),
"")
}
}
func readConntrackLines(ip string) []string {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "conntrack", "-L", "-s", ip)
output, err := cmd.Output()
if err == nil && len(output) > 0 {
return splitNonEmptyLines(string(output))
}
var lines []string
for _, path := range []string{"/proc/net/nf_conntrack", "/proc/net/ip_conntrack"} {
data, readErr := os.ReadFile(path)
if readErr != nil {
continue
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if strings.Contains(line, "src="+ip+" ") {
lines = append(lines, line)
}
}
}
return lines
}
func splitNonEmptyLines(raw string) []string {
lines := make([]string, 0)
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
if line != "" {
lines = append(lines, line)
}
}
return lines
}
func parseConntrackLine(line, containerIP string) (connEntry, bool) {
srcIP := extractField(line, "src=")
if srcIP != containerIP {
return connEntry{}, false
}
dstIP := extractField(line, "dst=")
dstPort, _ := strconv.Atoi(extractField(line, "dport="))
return connEntry{
dstIP: dstIP,
dstPort: dstPort,
proto: extractProtocol(line),
state: extractConnState(line),
line: line,
}, true
}
func extractProtocol(line string) string {
for _, field := range strings.Fields(line) {
switch field {
case "tcp", "udp", "icmp", "icmpv6", "sctp":
return field
}
}
return ""
}
func extractConnState(line string) string {
for _, field := range strings.Fields(line) {
switch field {
case "SYN_SENT", "SYN_RECV", "ESTABLISHED", "TIME_WAIT", "CLOSE", "CLOSE_WAIT", "FIN_WAIT", "LAST_ACK", "UNREPLIED":
return field
}
}
return ""
}
func countPorts(totalCounts map[int]int, destCounts map[int]map[string]int, ports map[int]string) (int, int) {
total := 0
targets := make(map[string]struct{})
for port := range ports {
total += totalCounts[port]
for dstIP := range destCounts[port] {
targets[dstIP] = struct{}{}
}
}
return total, len(targets)
}
func (ss *SecurityScanner) addAlert(name, alertType, severity, srcIP, dstIP string, port int, detail, logLine string) {
ss.mu.Lock()
defer ss.mu.Unlock()
now := time.Now()
cutoff := now.Add(-5 * time.Minute)
for i := range ss.alerts {
a := &ss.alerts[i]
if a.ContainerName != name || a.Type != alertType || a.TargetIP != dstIP || a.TargetPort != port {
continue
}
t, err := time.Parse("2006-01-02 15:04:05", a.Timestamp)
if err != nil || t.Before(cutoff) {
continue
}
a.Count++
a.Detail = detail
a.LogLine = logLine
a.Timestamp = now.Format("2006-01-02 15:04:05")
if severityRank(severity) > severityRank(a.Severity) {
a.Severity = severity
}
return
}
ss.nextID++
alert := SecurityAlert{
ID: fmt.Sprintf("alert-%d", ss.nextID),
ContainerName: name,
Type: alertType,
Severity: severity,
SourceIP: srcIP,
TargetIP: dstIP,
TargetPort: port,
Detail: detail,
LogLine: logLine,
Timestamp: now.Format("2006-01-02 15:04:05"),
Count: 1,
}
ss.alerts = append(ss.alerts, alert)
config.AddAuditLog("security_"+alertType, name, fmt.Sprintf("[%s] %s", severity, detail), "system")
if len(ss.alerts) > 200 {
ss.alerts = ss.alerts[len(ss.alerts)-200:]
}
}
func severityRank(severity string) int {
switch severity {
case "critical":
return 4
case "high":
return 3
case "medium":
return 2
case "low":
return 1
default:
return 0
}
}
// HandleSecurityAlerts returns all security alerts.
func HandleSecurityAlerts(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
ss := ensureScanner()
ss.mu.Lock()
reversed := make([]SecurityAlert, len(ss.alerts))
for i, a := range ss.alerts {
reversed[len(ss.alerts)-1-i] = a
}
ss.mu.Unlock()
if reversed == nil {
reversed = []SecurityAlert{}
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: reversed})
}
// HandleSecurityCheck triggers immediate security check for a container.
func HandleSecurityCheck(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 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
c := config.FindContainerByName(req.ContainerName)
if c == nil || c.IP == "" {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found or not running"})
return
}
ensureScanner().checkContainer(c.Name, c.IP)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Security check completed"})
}
// HandleSecurityLogs returns connection logs for a container.
func HandleSecurityLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
containerName := r.URL.Query().Get("container")
if containerName == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Container name required"})
return
}
c := config.FindContainerByName(containerName)
if c == nil || c.IP == "" {
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: []map[string]interface{}{}})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: getConnectionLogs(c.IP)})
}
func getConnectionLogs(ip string) []map[string]interface{} {
logs := make([]map[string]interface{}, 0)
for _, line := range readConntrackLines(ip) {
srcIP := extractField(line, "src=")
dstIP := extractField(line, "dst=")
srcPort := extractField(line, "sport=")
dstPort := extractField(line, "dport=")
sPort, _ := strconv.Atoi(srcPort)
dPort, _ := strconv.Atoi(dstPort)
logs = append(logs, map[string]interface{}{
"src_ip": srcIP,
"dst_ip": dstIP,
"src_port": sPort,
"dst_port": dPort,
"protocol": extractProtocol(line),
"state": extractConnState(line),
})
if len(logs) >= 100 {
break
}
}
return logs
}
func extractField(line, prefix string) string {
idx := strings.Index(line, prefix)
if idx == -1 {
return ""
}
start := idx + len(prefix)
end := start
for end < len(line) && line[end] != ' ' && line[end] != '\t' {
end++
}
return line[start:end]
}
// HandleContainerSecuritySummary returns security status for dashboard.
func HandleContainerSecuritySummary(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
ss := ensureScanner()
ss.mu.Lock()
critical := 0
high := 0
medium := 0
low := 0
for _, a := range ss.alerts {
switch a.Severity {
case "critical":
critical++
case "high":
high++
case "medium":
medium++
case "low":
low++
}
}
total := len(ss.alerts)
ss.mu.Unlock()
summary := map[string]interface{}{
"total_alerts": total,
"critical": critical,
"high": high,
"medium": medium,
"low": low,
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: summary})
}
+146
View File
@@ -0,0 +1,146 @@
package api
import (
"encoding/json"
"net/http"
"time"
"clicd/internal/config"
"golang.org/x/crypto/bcrypt"
)
type LoginLog struct {
Time string `json:"time"`
Username string `json:"username"`
IP string `json:"ip"`
UserAgent string `json:"user_agent"`
Success bool `json:"success"`
}
var loginLogs = make([]LoginLog, 0)
// RecordLoginLog adds a login attempt to the log (persisted to config)
func RecordLoginLog(username, ip, userAgent string, success bool) {
config.AddLoginLog(username, ip, userAgent, success)
log := LoginLog{
Time: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"),
Username: username,
IP: ip,
UserAgent: userAgent,
Success: success,
}
loginLogs = append(loginLogs, log)
if len(loginLogs) > 200 {
loginLogs = loginLogs[len(loginLogs)-200:]
}
}
// RestoreLoginLogs restores login logs from config
func RestoreLoginLogs() {
for _, l := range config.AppConfig.LoginLogs {
loginLogs = append(loginLogs, LoginLog{
Time: l.Time,
Username: l.Username,
IP: l.IP,
UserAgent: l.UserAgent,
Success: l.Success,
})
}
}
// HandleLoginLogs returns login history
func HandleLoginLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
// Return in reverse (newest first)
reversed := make([]LoginLog, len(loginLogs))
for i, l := range loginLogs {
reversed[len(loginLogs)-1-i] = l
}
if reversed == nil {
reversed = []LoginLog{}
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: reversed})
}
// HandleAdminPasswordChange changes admin password
func HandleAdminPasswordChange(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 {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
if len(req.NewPassword) < 6 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "新密码至少 6 位"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(config.AppConfig.AdminPassHash), []byte(req.OldPassword)); err != nil {
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "当前密码不正确"})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "密码加密失败"})
return
}
config.AppConfig.AdminPassHash = string(hash)
if err := config.SaveConfig(); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "保存配置失败"})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "密码修改成功"})
}
// HandleAdminUsernameChange changes admin username
func HandleAdminUsernameChange(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 {
NewUsername string `json:"new_username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
if len(req.NewUsername) < 3 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "用户名至少 3 位"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(config.AppConfig.AdminPassHash), []byte(req.Password)); err != nil {
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "密码不正确"})
return
}
config.AppConfig.AdminUser = req.NewUsername
if err := config.SaveConfig(); err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "保存配置失败"})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "用户名修改成功"})
}
+308
View File
@@ -0,0 +1,308 @@
package api
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"sync"
"time"
"clicd/internal/config"
"github.com/gorilla/websocket"
"golang.org/x/crypto/ssh"
)
type terminalResizeMessage struct {
Type string `json:"type"`
Cols int `json:"cols"`
Rows int `json:"rows"`
}
type webSSHTicket struct {
ContainerName string
ExpiresAt time.Time
}
var webSSHTickets = struct {
sync.Mutex
items map[string]webSSHTicket
}{items: map[string]webSSHTicket{}}
func HandleWebSSHTicket(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
}
if config.FindContainerByName(req.ContainerName) == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
}
ticket := randomHex(32)
webSSHTickets.Lock()
cleanupExpiredWebSSHTicketsLocked(time.Now())
webSSHTickets.items[ticket] = webSSHTicket{
ContainerName: req.ContainerName,
ExpiresAt: time.Now().Add(60 * time.Second),
}
webSSHTickets.Unlock()
jsonResponse(w, http.StatusOK, APIResponse{
Success: true,
Data: map[string]string{"ticket": ticket},
})
}
// HandleWebSSH proxies an SSH session to the browser over WebSocket.
func HandleWebSSH(w http.ResponseWriter, r *http.Request) {
ticket := r.URL.Query().Get("ticket")
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
}
if !consumeWebSSHTicket(ticket, containerName) {
http.Error(w, "invalid or expired ticket", http.StatusUnauthorized)
return
}
c := config.FindContainerByName(containerName)
if c == nil {
http.Error(w, "container not found", http.StatusNotFound)
return
}
if c.Status != "running" {
http.Error(w, "container is not running", http.StatusBadRequest)
return
}
if c.IP == "" {
if ip, err := lxcManager.GetContainerIP(c.LxcName()); err == nil {
c.IP = ip
config.SaveConfig()
}
}
if c.IP == "" {
if ip, err := lxcManager.EnsureContainerIPv4(c.ID); err == nil && ip != "" {
c.IP = ip
}
}
if c.IP == "" {
http.Error(w, "container ip is not available", http.StatusBadRequest)
return
}
ws, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSSH upgrade failed: %v", err)
return
}
defer ws.Close()
if c.SSHPassword == "" {
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 {
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nSSH auto setup failed: %v\r\n", err))
return
}
if refreshed := config.FindContainer(c.ID); refreshed != nil {
c = refreshed
}
}
if c.SSHPassword == "" {
writeWebSocketText(ws, nil, "\r\nSSH password is empty after auto setup\r\n")
return
}
sshConfig := &ssh.ClientConfig{
User: "root",
Auth: []ssh.AuthMethod{
ssh.Password(c.SSHPassword),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 4 * time.Second,
}
addr := net.JoinHostPort(c.IP, "22")
writeWebSocketText(ws, nil, fmt.Sprintf("Connecting to %s...\r\n", addr))
client, err := ssh.Dial("tcp", addr, sshConfig)
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 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()
session, err := client.NewSession()
if err != nil {
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nFailed to create SSH session: %v\r\n", err))
return
}
defer session.Close()
stdin, err := session.StdinPipe()
if err != nil {
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nFailed to open SSH stdin: %v\r\n", err))
return
}
stdout, err := session.StdoutPipe()
if err != nil {
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nFailed to open SSH stdout: %v\r\n", err))
return
}
stderr, err := session.StderrPipe()
if err != nil {
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nFailed to open SSH stderr: %v\r\n", err))
return
}
if err := session.RequestPty("xterm-256color", 40, 120, ssh.TerminalModes{
ssh.ECHO: 1,
ssh.TTY_OP_ISPEED: 14400,
ssh.TTY_OP_OSPEED: 14400,
}); err != nil {
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nFailed to request pty: %v\r\n", err))
return
}
if err := session.Shell(); err != nil {
writeWebSocketText(ws, nil, fmt.Sprintf("\r\nFailed to start shell: %v\r\n", err))
return
}
writeWebSocketText(ws, nil, "\r\nSSH shell ready. Press Enter if the prompt is not visible.\r\n")
_, _ = stdin.Write([]byte("\n"))
log.Printf("WebSSH connected for container %s -> %s", containerName, addr)
done := make(chan struct{}, 3)
var writeMu sync.Mutex
go streamSSHOutput(ws, &writeMu, stdout, done)
go streamSSHOutput(ws, &writeMu, stderr, done)
go func() {
defer func() { done <- struct{}{} }()
for {
messageType, msg, err := ws.ReadMessage()
if err != nil {
return
}
if messageType == websocket.TextMessage {
var resize terminalResizeMessage
if err := json.Unmarshal(msg, &resize); err == nil && resize.Type == "resize" {
if resize.Rows > 0 && resize.Cols > 0 {
_ = session.WindowChange(resize.Rows, resize.Cols)
}
continue
}
}
if _, err := stdin.Write(msg); err != nil {
return
}
}
}()
<-done
_ = session.Signal(ssh.SIGTERM)
log.Printf("WebSSH disconnected for container %s", containerName)
}
func streamSSHOutput(ws *websocket.Conn, writeMu *sync.Mutex, src io.Reader, done chan<- struct{}) {
defer func() { done <- struct{}{} }()
buf := make([]byte, 8192)
for {
n, err := src.Read(buf)
if n > 0 {
writeMu.Lock()
writeErr := ws.WriteMessage(websocket.BinaryMessage, buf[:n])
writeMu.Unlock()
if writeErr != nil {
return
}
}
if err != nil {
return
}
}
}
func writeWebSocketText(ws *websocket.Conn, writeMu *sync.Mutex, msg string) {
if writeMu != nil {
writeMu.Lock()
defer writeMu.Unlock()
}
_ = ws.WriteMessage(websocket.TextMessage, []byte(msg))
}
func consumeWebSSHTicket(ticket, containerName string) bool {
now := time.Now()
webSSHTickets.Lock()
defer webSSHTickets.Unlock()
cleanupExpiredWebSSHTicketsLocked(now)
item, ok := webSSHTickets.items[ticket]
if !ok {
return false
}
delete(webSSHTickets.items, ticket)
return item.ContainerName == containerName && now.Before(item.ExpiresAt)
}
func cleanupExpiredWebSSHTicketsLocked(now time.Time) {
for ticket, item := range webSSHTickets.items {
if !now.Before(item.ExpiresAt) {
delete(webSSHTickets.items, ticket)
}
}
}
func randomHex(bytesLen int) string {
b := make([]byte, bytesLen)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
+422
View File
@@ -0,0 +1,422 @@
package api
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"clicd/internal/config"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
func generateRandomStr(length int) string {
b := make([]byte, length)
rand.Read(b)
return hex.EncodeToString(b)[:length]
}
// HandleSubUserCreate creates a sub-user for a specific container
func HandleSubUserCreate(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 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
c := containerByIdentifier(req.ContainerName)
if c == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
}
containerName := c.Name
// Check if sub-user already exists for this container
for i := range config.AppConfig.SubUsers {
su := &config.AppConfig.SubUsers[i]
for _, cn := range su.ContainerNames {
if cn == containerName {
if su.AccessCode == "" {
su.AccessCode = generateRandomStr(8)
}
if su.PassHash == "" && su.Password != "" {
if hash, err := bcrypt.GenerateFromPassword([]byte(su.Password), bcrypt.DefaultCost); err == nil {
su.PassHash = string(hash)
}
}
if su.Password == "" {
su.Password = generateRandomStr(16)
if hash, err := bcrypt.GenerateFromPassword([]byte(su.Password), bcrypt.DefaultCost); err == nil {
su.PassHash = string(hash)
}
}
su.Token = newSubUserToken(su.Username, []string{c.UUID}, time.Now().AddDate(1, 0, 0))
config.SaveConfig()
// Return existing
jsonResponse(w, http.StatusOK, APIResponse{
Success: true,
Message: "Sub-user already exists",
Data: *su,
})
return
}
}
}
// Create new sub-user
username := "user-" + generateRandomStr(8)
password := generateRandomStr(16)
hash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
// Generate short access code (8 chars, for URL sharing)
accessCode := generateRandomStr(8)
// Generate JWT for sub-user
tokenStr := newSubUserToken(username, []string{c.UUID}, time.Now().AddDate(1, 0, 0))
subUser := config.SubUser{
ID: "sub-" + generateRandomStr(8),
Username: username,
Password: password,
PassHash: string(hash),
ContainerNames: []string{containerName},
Token: tokenStr,
AccessCode: accessCode,
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
}
config.AppConfig.SubUsers = append(config.AppConfig.SubUsers, subUser)
config.SaveConfig()
config.AddAuditLog("创建子用户", containerName, fmt.Sprintf("用户: %s", username), "admin")
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Sub-user created", Data: subUser})
}
// HandleSubUserLogin handles sub-user login
func HandleSubUserLogin(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 {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
// Find sub-user
for _, su := range config.AppConfig.SubUsers {
if su.Username == req.Username {
if err := bcrypt.CompareHashAndPassword([]byte(su.PassHash), []byte(req.Password)); err == nil {
// Generate fresh token
containerUUIDs := subUserContainerUUIDs(su.ContainerNames)
if len(containerUUIDs) == 0 {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this user"})
return
}
tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour))
jsonResponse(w, http.StatusOK, APIResponse{
Success: true,
Data: map[string]interface{}{
"token": tokenStr,
"username": su.Username,
"container_uuids": containerUUIDs,
},
})
return
}
}
}
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid credentials"})
}
// HandleSubUserAccessCode handles access via short code + password (no token in URL)
func HandleSubUserAccessCode(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 {
Code string `json:"code"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
// Find sub-user by access code
for _, su := range config.AppConfig.SubUsers {
if su.AccessCode == req.Code {
if err := bcrypt.CompareHashAndPassword([]byte(su.PassHash), []byte(req.Password)); err != nil {
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid password"})
return
}
containerUUIDs := subUserContainerUUIDs(su.ContainerNames)
if len(containerUUIDs) == 0 {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this link"})
return
}
tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour))
jsonResponse(w, http.StatusOK, APIResponse{
Success: true,
Data: map[string]interface{}{
"token": tokenStr,
"username": su.Username,
"container_uuids": containerUUIDs,
},
})
return
}
}
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid access code"})
}
func newSubUserToken(username string, containerUUIDs []string, expiresAt time.Time) string {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub_user": username,
"container_uuids": containerUUIDs,
"exp": expiresAt.Unix(),
"iat": time.Now().Unix(),
})
tokenStr, _ := token.SignedString([]byte(config.AppConfig.JWTSecret))
return tokenStr
}
type subUserAccess struct {
names map[string]bool
uuids map[string]bool
}
func subUserAllowedContainers(r *http.Request) (subUserAccess, bool) {
claims, ok := claimsFromRequest(r)
if !ok {
return subUserAccess{}, false
}
if _, isSubUser := claims["sub_user"]; !isSubUser {
return subUserAccess{}, false
}
allowed := subUserAccess{
names: make(map[string]bool),
uuids: make(map[string]bool),
}
if containerNames, ok := claims["container_names"].([]interface{}); ok {
for _, cn := range containerNames {
if name, ok := cn.(string); ok {
allowed.names[name] = true
}
}
}
if containerNames, ok := claims["container_names"].([]string); ok {
for _, name := range containerNames {
allowed.names[name] = true
}
}
if containerUUIDs, ok := claims["container_uuids"].([]interface{}); ok {
for _, item := range containerUUIDs {
if uuid, ok := item.(string); ok {
allowed.uuids[uuid] = true
}
}
}
if containerUUIDs, ok := claims["container_uuids"].([]string); ok {
for _, uuid := range containerUUIDs {
allowed.uuids[uuid] = true
}
}
return allowed, true
}
func containerByIdentifier(identifier string) *config.Container {
return config.FindContainerByIdentifier(identifier)
}
func isContainerAllowedForRequest(r *http.Request, identifier string) bool {
allowed, isSubUser := subUserAllowedContainers(r)
if !isSubUser {
return true
}
c := containerByIdentifier(identifier)
if c == nil {
return false
}
return isContainerAllowed(allowed, c)
}
// HandleAuditLogs returns audit logs
func HandleAuditLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
logs := config.AppConfig.AuditLogs
if logs == nil {
logs = []config.AuditLog{}
}
// Return in reverse order (newest first)
reversed := make([]config.AuditLog, len(logs))
for i, l := range logs {
reversed[len(logs)-1-i] = l
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: reversed})
}
// SubUserMiddleware checks if a request is from a sub-user and restricts container access
func SubUserMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
allowed, isSubUser := subUserAllowedContainers(r)
if !isSubUser {
next(w, r)
return
}
path := r.URL.Path
if path == "/api/tasks" && r.Method == http.MethodGet {
next(w, r)
return
}
if path == "/api/containers" {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Sub-users cannot create containers"})
return
}
next(w, r)
return
}
if len(path) > len("/api/containers/") {
rest := path[len("/api/containers/"):]
parts := splitPath(rest)
if len(parts) > 0 && parts[0] != "" {
c := containerByIdentifier(parts[0])
if c == nil || !isContainerAllowed(allowed, c) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied to this container"})
return
}
action := ""
if len(parts) > 1 {
action = parts[1]
}
if !isSubUserContainerActionAllowed(action, r.Method) {
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Action is not allowed for this link"})
return
}
}
next(w, r)
return
}
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "Access denied"})
return
}
}
func filterContainersForRequest(r *http.Request, containers []config.Container) []config.Container {
allowed, isSubUser := subUserAllowedContainers(r)
if !isSubUser {
return containers
}
filtered := make([]config.Container, 0, len(containers))
for _, c := range containers {
if isContainerAllowed(allowed, &c) {
filtered = append(filtered, c)
}
}
return filtered
}
func filterTasksForRequest(r *http.Request, tasks []*Task) []*Task {
allowed, isSubUser := subUserAllowedContainers(r)
if !isSubUser {
return tasks
}
filtered := make([]*Task, 0, len(tasks))
for _, task := range tasks {
if allowed.names[task.ContainerName] || (task.Config.Name != "" && allowed.names[task.Config.Name]) {
filtered = append(filtered, task)
}
}
return filtered
}
func isContainerAllowed(allowed subUserAccess, c *config.Container) bool {
return allowed.names[c.Name] || (c.UUID != "" && allowed.uuids[c.UUID])
}
func isSubUserContainerActionAllowed(action string, method string) bool {
if action == "" {
return method == http.MethodGet
}
switch {
case action == "usage" || action == "traffic" || action == "random-port":
return method == http.MethodGet
case action == "start" || action == "stop" || action == "restart" || action == "reinstall":
return method == http.MethodPost
case strings.HasPrefix(action, "port-mappings/"):
return method == http.MethodPut
default:
return false
}
}
func subUserContainerUUIDs(containerNames []string) []string {
uuids := make([]string, 0, len(containerNames))
for _, name := range containerNames {
if c := config.FindContainerByName(name); c != nil && c.UUID != "" {
uuids = append(uuids, c.UUID)
}
}
return uuids
}
func splitPath(path string) []string {
parts := make([]string, 0)
for _, p := range splitBy(path, "/") {
if p != "" {
parts = append(parts, p)
}
}
return parts
}
func splitBy(s, sep string) []string {
result := make([]string, 0)
current := ""
for _, c := range s {
if string(c) == sep {
result = append(result, current)
current = ""
} else {
current += string(c)
}
}
result = append(result, current)
return result
}
+189
View File
@@ -0,0 +1,189 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"strconv"
"strings"
)
type SwapInfo struct {
TotalMB int64 `json:"total_mb"`
UsedMB int64 `json:"used_mb"`
FreeMB int64 `json:"free_mb"`
Enabled bool `json:"enabled"`
SwapFile string `json:"swap_file"`
}
// HandleSwapInfo returns current swap status
func HandleSwapInfo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
info := getSwapInfo()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: info})
}
// HandleSwapManage creates/enables/disables swap
func HandleSwapManage(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 {
Action string `json:"action"` // create, enable, disable, resize
SizeMB int `json:"size_mb"` // for create/resize
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
var msg string
switch req.Action {
case "create":
if req.SizeMB <= 0 {
req.SizeMB = 2048
}
err := createSwap(req.SizeMB)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
msg = fmt.Sprintf("已创建 %d MB SWAP", req.SizeMB)
case "enable":
err := enableSwap()
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
msg = "SWAP 已启用"
case "disable":
err := disableSwap()
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: err.Error()})
return
}
msg = "SWAP 已禁用"
case "resize":
if req.SizeMB <= 0 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid size"})
return
}
disableSwap()
createSwap(req.SizeMB)
enableSwap()
msg = fmt.Sprintf("SWAP 已调整为 %d MB", req.SizeMB)
default:
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid action: " + req.Action})
return
}
info := getSwapInfo()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: msg, Data: info})
}
func getSwapInfo() SwapInfo {
info := SwapInfo{SwapFile: "/swapfile"}
// Read /proc/meminfo for swap stats
data, err := os.ReadFile("/proc/meminfo")
if err != nil {
return info
}
lines := strings.Split(string(data), "\n")
for _, line := range lines {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
val, _ := strconv.ParseInt(fields[1], 10, 64)
switch fields[0] {
case "SwapTotal:":
info.TotalMB = val / 1024
case "SwapFree:":
info.FreeMB = val / 1024
}
}
info.UsedMB = info.TotalMB - info.FreeMB
if info.TotalMB > 0 {
info.Enabled = true
}
return info
}
func createSwap(sizeMB int) error {
swapFile := "/swapfile"
// Check if swap file already exists
if _, err := os.Stat(swapFile); err == nil {
// Remove old swap file
exec.Command("swapoff", swapFile).Run()
os.Remove(swapFile)
}
// Create swap file
cmd := exec.Command("dd", "if=/dev/zero", "of="+swapFile, "bs=1M", "count="+strconv.Itoa(sizeMB))
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("创建 swap 文件失败: %v, %s", err, string(output))
}
// Set permissions
os.Chmod(swapFile, 0600)
// Make swap
cmd = exec.Command("mkswap", swapFile)
output, err = cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("mkswap 失败: %v, %s", err, string(output))
}
// Enable swap
return enableSwap()
}
func enableSwap() error {
swapFile := "/swapfile"
if _, err := os.Stat(swapFile); os.IsNotExist(err) {
return fmt.Errorf("swap 文件不存在,请先创建")
}
cmd := exec.Command("swapon", swapFile)
output, err := cmd.CombinedOutput()
if err != nil {
// Check if already enabled
if strings.Contains(string(output), "already") {
return nil
}
return fmt.Errorf("启用 swap 失败: %v, %s", err, string(output))
}
return nil
}
func disableSwap() error {
swapFile := "/swapfile"
cmd := exec.Command("swapoff", swapFile)
output, err := cmd.CombinedOutput()
if err != nil {
if strings.Contains(string(output), "No such") {
return nil
}
return fmt.Errorf("禁用 swap 失败: %v, %s", err, string(output))
}
return nil
}
+650
View File
@@ -0,0 +1,650 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"clicd/internal/config"
"clicd/internal/lxc"
)
type TaskType string
const (
TaskCreate TaskType = "create"
TaskStart TaskType = "start"
TaskStop TaskType = "stop"
TaskRestart TaskType = "restart"
TaskDelete TaskType = "delete"
TaskReinstall TaskType = "reinstall"
)
type Task struct {
ID string `json:"id"`
Type TaskType `json:"type"`
ContainerID int `json:"container_id"`
ContainerName string `json:"container_name"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
CreatedAt string `json:"created_at"`
TemplateID string `json:"template_id,omitempty"`
Config lxc.ContainerConfig `json:"config,omitempty"`
Name string `json:"name,omitempty"`
User string `json:"user,omitempty"` // who created this task
}
type TaskQueue struct {
mu sync.Mutex
createQueue []*Task
opQueue []*Task
tasks map[string]*Task
nextID int
createCond *sync.Cond
opCond *sync.Cond
stop chan struct{}
}
var globalQueue *TaskQueue
func init() {
globalQueue = &TaskQueue{
tasks: make(map[string]*Task),
stop: make(chan struct{}),
}
globalQueue.createCond = sync.NewCond(&globalQueue.mu)
globalQueue.opCond = sync.NewCond(&globalQueue.mu)
go globalQueue.createWorker()
go globalQueue.opWorker()
}
func (q *TaskQueue) enqueueTask(task *Task) {
q.tasks[task.ID] = task
if task.Type == TaskCreate {
q.createQueue = append(q.createQueue, task)
q.createCond.Signal()
} else {
q.opQueue = append(q.opQueue, task)
q.opCond.Signal()
}
}
func (q *TaskQueue) Enqueue(containerID int, containerName string, taskType TaskType, templateID string, cfg *lxc.ContainerConfig) []string {
q.mu.Lock()
defer q.mu.Unlock()
id := q.nextID
q.nextID++
task := &Task{
ID: fmt.Sprintf("task-%d", id),
Type: taskType,
ContainerID: containerID,
ContainerName: containerName,
Status: "pending",
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
TemplateID: templateID,
}
if cfg != nil {
task.Config = *cfg
}
q.enqueueTask(task)
q.persistTasks()
return []string{task.ID}
}
func (q *TaskQueue) EnqueueBatch(taskType TaskType, ids []int, templateID string) []string {
return q.EnqueueBatchWithUser(taskType, ids, templateID, "admin")
}
func (q *TaskQueue) EnqueueBatchWithUser(taskType TaskType, ids []int, templateID string, user string) []string {
q.mu.Lock()
defer q.mu.Unlock()
var result []string
for _, id := range ids {
c := config.FindContainer(id)
name := ""
if c != nil {
name = c.Name
}
result = append(result, q.enqueueSingleWithUser(id, name, taskType, templateID, user))
}
q.persistTasks()
return result
}
func (q *TaskQueue) EnqueueBatchCreate(configs []lxc.ContainerConfig) []string {
q.mu.Lock()
defer q.mu.Unlock()
return q.enqueueBatchCreateList(configs)
}
func (q *TaskQueue) ActiveCreateNames() map[string]bool {
q.mu.Lock()
defer q.mu.Unlock()
names := make(map[string]bool)
for _, task := range q.tasks {
if task.Type != TaskCreate || (task.Status != "pending" && task.Status != "running") {
continue
}
name := task.Config.Name
if name == "" {
name = task.ContainerName
}
if name != "" {
names[name] = true
}
}
return names
}
func (q *TaskQueue) enqueueBatchCreateList(configs []lxc.ContainerConfig) []string {
var result []string
for _, cfg := range configs {
cfgCopy := cfg
id := q.nextID
q.nextID++
task := &Task{
ID: fmt.Sprintf("task-%d", id),
Type: TaskCreate,
ContainerID: 0,
ContainerName: cfgCopy.Name,
Status: "pending",
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
Config: cfgCopy,
}
q.enqueueTask(task)
result = append(result, task.ID)
}
q.persistTasks()
return result
}
func (q *TaskQueue) enqueueSingle(containerID int, containerName string, taskType TaskType, templateID string) string {
return q.enqueueSingleWithUser(containerID, containerName, taskType, templateID, "admin")
}
func (q *TaskQueue) enqueueSingleWithUser(containerID int, containerName string, taskType TaskType, templateID string, user string) string {
id := q.nextID
q.nextID++
task := &Task{
ID: fmt.Sprintf("task-%d", id),
Type: taskType,
ContainerID: containerID,
ContainerName: containerName,
Status: "pending",
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
TemplateID: templateID,
User: user,
}
q.enqueueTask(task)
return task.ID
}
// createWorker handles TaskCreate: lxc-create, resource setup, start, and SSH init.
// If a restored task already has a same-name container in config, it resumes
// initialization instead of creating another ct-{id}.
func (q *TaskQueue) createWorker() {
for {
q.mu.Lock()
for len(q.createQueue) == 0 {
q.createCond.Wait()
}
task := q.createQueue[0]
q.createQueue = q.createQueue[1:]
task.Status = "running"
q.mu.Unlock()
createdByTask := false
if task.Config.Name == "" {
task.Config.Name = task.ContainerName
}
if task.Config.Name == "" {
task.Status = "failed"
task.Error = "container name is required"
config.AddAuditLog(string(task.Type), task.ContainerName, "failed: "+task.Error, "admin")
q.mu.Lock()
q.persistTasks()
q.mu.Unlock()
continue
}
c := config.FindContainerByName(task.Config.Name)
if c == nil {
// 1) Download image + apply limits (lxc-create)
err := lxcManager.CreateContainer(task.Config)
if err != nil {
task.Status = "failed"
task.Error = err.Error()
config.AddAuditLog(string(task.Type), task.Config.Name, "失败: "+err.Error(), "admin")
q.mu.Lock()
q.persistTasks()
q.mu.Unlock()
continue
}
createdByTask = true
// 2) Find created container by name
c = config.FindContainerByName(task.Config.Name)
if c == nil {
task.Status = "failed"
task.Error = "created but not found in config"
config.AddAuditLog(string(task.Type), task.Config.Name, "失败: "+task.Error, "admin")
q.mu.Lock()
q.persistTasks()
q.mu.Unlock()
continue
}
}
task.ContainerID = c.ID
task.ContainerName = c.Name
// 3) Start + initialize SSH/network in the same worker.
// If init fails, destroy the container so no dead entry remains.
startErr := lxcManager.StartContainer(c.ID)
if startErr != nil {
if createdByTask {
lxcManager.DestroyContainer(c.ID)
}
task.Status = "failed"
task.Error = startErr.Error()
config.AddAuditLog(string(task.Type), task.ContainerName, "初始化失败: "+startErr.Error(), "admin")
} else {
task.Status = "done"
config.AddAuditLog(string(task.Type), task.ContainerName, "成功", "admin")
}
q.mu.Lock()
q.persistTasks()
q.mu.Unlock()
}
}
// opWorker handles all non-create tasks (start, stop, restart, delete, reinstall)
// including the follow-up initialization after a create succeeds.
func (q *TaskQueue) opWorker() {
for {
q.mu.Lock()
for len(q.opQueue) == 0 {
q.opCond.Wait()
}
task := q.opQueue[0]
q.opQueue = q.opQueue[1:]
task.Status = "running"
q.mu.Unlock()
var err error
err = resolveTaskContainer(task)
// Block operations on expired or traffic-exceeded containers (except stop/delete)
if err == nil && (task.Type == TaskStart || task.Type == TaskRestart || task.Type == TaskReinstall) {
c := config.FindContainer(task.ContainerID)
if c != nil {
if lxc.IsExpired(*c) {
err = fmt.Errorf("容器已到期,不允许此操作")
} else if lxc.IsTrafficExceeded(*c) {
err = fmt.Errorf("容器流量已超限,不允许此操作")
}
}
}
if err == nil {
switch task.Type {
case TaskStart:
err = lxcManager.StartContainer(task.ContainerID)
case TaskStop:
err = lxcManager.StopContainer(task.ContainerID)
case TaskRestart:
err = lxcManager.RestartContainer(task.ContainerID)
case TaskDelete:
err = lxcManager.DestroyContainer(task.ContainerID)
if err == nil {
time.Sleep(1 * time.Second)
if config.FindContainer(task.ContainerID) != nil {
err = fmt.Errorf("container still exists after delete: %d", task.ContainerID)
}
}
case TaskReinstall:
err = lxcManager.ReinstallContainer(task.ContainerID, task.TemplateID)
}
}
q.mu.Lock()
auditUser := task.User
if auditUser == "" {
auditUser = "admin"
}
if err != nil {
task.Status = "failed"
task.Error = err.Error()
config.AddAuditLog(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser)
} else {
task.Status = "done"
config.AddAuditLog(string(task.Type), task.ContainerName, "成功", auditUser)
switch task.Type {
case TaskStart:
config.UpdateContainerStatus(task.ContainerID, "running")
case TaskStop:
config.UpdateContainerStatus(task.ContainerID, "stopped")
case TaskRestart:
config.UpdateContainerStatus(task.ContainerID, "running")
}
}
q.persistTasks()
q.mu.Unlock()
}
}
func resolveTaskContainer(task *Task) error {
if task.Type == TaskCreate {
return nil
}
if task.ContainerID > 0 {
if c := config.FindContainer(task.ContainerID); c != nil {
if task.ContainerName == "" {
task.ContainerName = c.Name
}
return nil
}
}
if task.ContainerName != "" {
if c := config.FindContainerByName(task.ContainerName); c != nil {
task.ContainerID = c.ID
task.ContainerName = c.Name
return nil
}
return fmt.Errorf("container not found: %s", task.ContainerName)
}
return fmt.Errorf("container not found: %d", task.ContainerID)
}
func (q *TaskQueue) persistTasks() {
saved := make([]config.SavedTask, 0)
for _, t := range q.tasks {
// Only persist pending and running tasks to avoid
// re-queuing already completed/failed tasks after restart.
if t.Status != "pending" && t.Status != "running" {
continue
}
cfgJSON, _ := json.Marshal(t.Config)
saved = append(saved, config.SavedTask{
ID: t.ID,
Type: string(t.Type),
ContainerID: t.ContainerID,
ContainerName: t.ContainerName,
Status: t.Status,
Error: t.Error,
CreatedAt: t.CreatedAt,
TemplateID: t.TemplateID,
Config: string(cfgJSON),
User: t.User,
})
}
config.SaveTasks(saved)
}
func (q *TaskQueue) GetTasks() []*Task {
q.mu.Lock()
defer q.mu.Unlock()
result := make([]*Task, 0, len(q.tasks))
// Collect all task IDs, sort by creation time (extracted from ID number)
for _, t := range q.tasks {
result = append(result, t)
}
// Stable sort by ID number (task-N where N is sequential)
for i := 0; i < len(result); i++ {
for j := i + 1; j < len(result); j++ {
if parseIDNum(result[i].ID) > parseIDNum(result[j].ID) {
result[i], result[j] = result[j], result[i]
}
}
}
return result
}
// HandleSingleTaskAction creates a task for a single container action
func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, action string) {
c := config.FindContainer(id)
name := ""
if c != nil {
name = c.Name
}
// Determine user from JWT claims
user := "admin"
if claims, ok := claimsFromRequest(r); ok {
if subUser, _ := claims["sub_user"].(string); subUser != "" {
user = "user:" + subUser
}
}
var taskType TaskType
var templateID string
switch action {
case "start":
taskType = TaskStart
case "stop":
taskType = TaskStop
case "restart":
taskType = TaskRestart
case "delete":
taskType = TaskDelete
case "reinstall":
var req struct {
TemplateID string `json:"template_id"`
}
json.NewDecoder(r.Body).Decode(&req)
templateID = req.TemplateID
if templateID == "" {
c := config.FindContainer(id)
if c != nil {
templateID = c.Template
}
}
taskType = TaskReinstall
default:
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"})
return
}
ids := globalQueue.EnqueueBatchWithUser(taskType, []int{id}, templateID, user)
jsonResponse(w, http.StatusAccepted, APIResponse{
Success: true,
Message: "Task queued",
Data: map[string]interface{}{"task_id": ids[0], "container_name": name, "status": "pending"},
})
}
// HandleBatchCreate handles batch container creation
func HandleBatchCreate(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 {
Containers []lxc.ContainerConfig `json:"containers"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
if len(req.Containers) == 0 {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "No containers requested"})
return
}
activeCreateNames := globalQueue.ActiveCreateNames()
requestNames := make(map[string]bool)
for i := range req.Containers {
name := strings.TrimSpace(req.Containers[i].Name)
req.Containers[i].Name = name
if !config.IsValidContainerNameSyntax(name) {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid container name: " + name})
return
}
if requestNames[name] {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Duplicate container name in request: " + name})
return
}
if config.FindContainerByName(name) != nil {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Container name already exists: " + name})
return
}
if activeCreateNames[name] {
jsonResponse(w, http.StatusConflict, APIResponse{Success: false, Message: "Container creation already queued: " + name})
return
}
if req.Containers[i].VCPU <= 0 {
req.Containers[i].VCPU = 1
}
if req.Containers[i].RAMMB < 128 {
req.Containers[i].RAMMB = 512
}
if req.Containers[i].DiskGB < 1 {
req.Containers[i].DiskGB = 5
}
if err := validateContainerResourceRequest(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()})
return
}
requestNames[name] = true
}
ids := globalQueue.EnqueueBatchCreate(req.Containers)
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
}
// HandleBatchAction handles batch container actions
func HandleBatchAction(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 {
Action string `json:"action"`
Containers []int `json:"containers"`
TemplateID string `json:"template_id,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
return
}
var taskType TaskType
switch req.Action {
case "start":
taskType = TaskStart
case "stop":
taskType = TaskStop
case "restart":
taskType = TaskRestart
case "delete":
taskType = TaskDelete
default:
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Unknown action"})
return
}
ids := globalQueue.EnqueueBatch(taskType, req.Containers, req.TemplateID)
jsonResponse(w, http.StatusAccepted, APIResponse{Success: true, Data: ids})
}
// HandleTaskDelete deletes a specific task by ID
func HandleTaskDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
// URL: /api/tasks/{id}
taskID := strings.TrimPrefix(r.URL.Path, "/api/tasks/")
if taskID == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Task ID required"})
return
}
globalQueue.mu.Lock()
delete(globalQueue.tasks, taskID)
// Also remove from both queues if pending
newCreate := make([]*Task, 0, len(globalQueue.createQueue))
for _, t := range globalQueue.createQueue {
if t.ID != taskID {
newCreate = append(newCreate, t)
}
}
globalQueue.createQueue = newCreate
newOp := make([]*Task, 0, len(globalQueue.opQueue))
for _, t := range globalQueue.opQueue {
if t.ID != taskID {
newOp = append(newOp, t)
}
}
globalQueue.opQueue = newOp
globalQueue.persistTasks()
globalQueue.mu.Unlock()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "Task deleted"})
}
// HandleTasks returns the current task queue
func HandleTasks(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
tasks := globalQueue.GetTasks()
tasks = filterTasksForRequest(r, tasks)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: tasks})
}
// RestoreTasks restores task queue from config
func RestoreTasks() {
for _, st := range config.AppConfig.Tasks {
var cfg lxc.ContainerConfig
if st.Config != "" {
json.Unmarshal([]byte(st.Config), &cfg)
}
containerName := st.ContainerName
if containerName == "" {
containerName = cfg.Name
}
if cfg.Name == "" {
cfg.Name = containerName
}
containerID := st.ContainerID
if containerID <= 0 && containerName != "" {
if c := config.FindContainerByName(containerName); c != nil {
containerID = c.ID
}
}
globalQueue.tasks[st.ID] = &Task{
ID: st.ID,
Type: TaskType(st.Type),
ContainerID: containerID,
ContainerName: containerName,
Status: st.Status,
Error: st.Error,
CreatedAt: st.CreatedAt,
TemplateID: st.TemplateID,
Config: cfg,
User: st.User,
}
if st.Status == "pending" || st.Status == "running" {
// Reset running tasks back to pending so they get retried
globalQueue.tasks[st.ID].Status = "pending"
globalQueue.enqueueTask(globalQueue.tasks[st.ID])
}
if num := parseIDNum(st.ID); num >= globalQueue.nextID {
globalQueue.nextID = num + 1
}
}
// Clear persisted tasks from disk (they're now in memory)
config.SaveTasks([]config.SavedTask{})
}
func parseIDNum(id string) int {
var num int
for _, c := range id {
if c >= '0' && c <= '9' {
num = num*10 + int(c-'0')
}
}
return num
}
+35
View File
@@ -0,0 +1,35 @@
package api
import (
"net"
"net/http"
"net/url"
"strings"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return true
}
originURL, err := url.Parse(origin)
if err != nil {
return false
}
originHost := strings.ToLower(stripPort(originURL.Host))
requestHost := strings.ToLower(stripPort(r.Host))
return originHost != "" && originHost == requestHost
},
}
func stripPort(host string) string {
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
return parsedHost
}
return strings.Trim(host, "[]")
}
+426
View File
@@ -0,0 +1,426 @@
package cli
import (
"bufio"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"clicd/internal/config"
"clicd/internal/lxc"
)
var manager = lxc.NewManager()
// Run starts the CLI interface.
func Run() {
reader := bufio.NewReader(os.Stdin)
for {
clearScreen()
printMenu()
fmt.Print("\nSelect action [1-9,0/q]: ")
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
switch strings.ToLower(input) {
case "1":
clearScreen()
cliListContainers()
waitEnter(reader)
case "2":
clearScreen()
cliCreateContainer(reader)
waitEnter(reader)
case "3":
clearScreen()
cliStartContainer(reader)
waitEnter(reader)
case "4":
clearScreen()
cliStopContainer(reader)
waitEnter(reader)
case "5":
clearScreen()
cliRestartContainer(reader)
waitEnter(reader)
case "6":
clearScreen()
cliDeleteContainer(reader)
waitEnter(reader)
case "7":
clearScreen()
cliReinstallContainer(reader)
waitEnter(reader)
case "8":
clearScreen()
cliResetPassword(reader)
waitEnter(reader)
case "9":
clearScreen()
cliToggleWebPanel()
waitEnter(reader)
case "0":
clearScreen()
cliShowInfo()
waitEnter(reader)
case "q", "exit", "quit":
fmt.Println("Bye")
return
default:
fmt.Println("Invalid selection")
}
}
}
func printMenu() {
webStatus := "start"
if isWebPanelRunning() {
webStatus = "stop"
}
fmt.Println()
fmt.Println(" ==========================================")
fmt.Println(" CLICD - LXC Container Manager")
fmt.Println(" ==========================================")
fmt.Println()
fmt.Printf(" Web panel: %s (port %d)\n", func() string {
if isWebPanelRunning() {
return "running"
}
return "stopped"
}(), config.AppConfig.Port)
fmt.Println()
fmt.Println(" 1. List containers")
fmt.Println(" 2. Create container")
fmt.Println(" 3. Start container")
fmt.Println(" 4. Stop container")
fmt.Println(" 5. Restart container")
fmt.Println(" 6. Delete container")
fmt.Println(" 7. Reinstall container")
fmt.Println(" 8. Reset web admin password")
fmt.Printf(" 9. %s web panel\n", webStatus)
fmt.Println(" 0. System info")
fmt.Println(" q. Quit")
}
func cliListContainers() {
containers, err := manager.ListContainers()
if err != nil {
fmt.Printf("Failed to list containers: %v\n", err)
return
}
if len(containers) == 0 {
fmt.Println("\nNo containers")
return
}
fmt.Println()
fmt.Printf("%-18s %-10s %-18s %-6s %-10s %-10s %-16s\n", "Name", "Status", "Template", "vCPU", "RAM(MB)", "Disk(GB)", "SSH")
fmt.Println(strings.Repeat("-", 94))
for _, c := range containers {
ssh := "-"
if c.SSHPort > 0 {
ssh = fmt.Sprintf("%d->22", c.SSHPort)
}
fmt.Printf("%-18s %-10s %-18s %-6.2f %-10d %-10d %-16s\n",
c.Name, c.Status, c.Template, c.VCPU, c.RAMMB, c.DiskGB, ssh)
}
}
func cliCreateContainer(reader *bufio.Reader) {
fmt.Println("\n--- Create container ---")
name := promptString(reader, "Container name", "")
if name == "" {
fmt.Println("Container name is required")
return
}
templates := lxc.GetTemplates()
fmt.Println("\nAvailable templates:")
for i, template := range templates {
fmt.Printf(" %d. %s\n", i+1, template.Name)
}
tmplIdx := promptInt(reader, fmt.Sprintf("Template [1-%d]", len(templates)), 1)
if tmplIdx < 1 || tmplIdx > len(templates) {
fmt.Println("Invalid template selection")
return
}
cfg := lxc.ContainerConfig{
Name: name,
TemplateID: templates[tmplIdx-1].ID,
VCPU: promptFloat(reader, "vCPU", 1),
RAMMB: promptInt(reader, "Memory (MB)", 512),
DiskGB: promptInt(reader, "Disk (GB)", 10),
NetworkBWMbps: promptInt(reader, "Network bandwidth (Mbps)", 100),
MonthlyTrafficGB: promptInt(reader, "Monthly traffic (GB)", 1000),
IOSpeedMBps: promptInt(reader, "IO speed (MB/s)", 500),
ExtraPorts: promptPortList(reader, "Extra NAT ports, comma separated"),
}
fmt.Printf("\nCreating container %s ...\n", name)
if err := manager.CreateContainer(cfg); err != nil {
fmt.Printf("Create failed: %v\n", err)
return
}
container := config.FindContainerByName(name)
fmt.Printf("Container %s created successfully\n", name)
if container != nil {
fmt.Printf("SSH: root / %s, port %d -> 22\n", container.SSHPassword, container.SSHPort)
}
}
func cliStartContainer(reader *bufio.Reader) {
id, name := selectContainer(reader, "start")
if id == 0 {
return
}
if err := manager.StartContainer(id); err != nil {
fmt.Printf("Start failed: %v\n", err)
return
}
fmt.Printf("Container %s started\n", name)
}
func cliStopContainer(reader *bufio.Reader) {
id, name := selectContainer(reader, "stop")
if id == 0 {
return
}
if err := manager.StopContainer(id); err != nil {
fmt.Printf("Stop failed: %v\n", err)
return
}
fmt.Printf("Container %s stopped\n", name)
}
func cliRestartContainer(reader *bufio.Reader) {
id, name := selectContainer(reader, "restart")
if id == 0 {
return
}
if err := manager.RestartContainer(id); err != nil {
fmt.Printf("Restart failed: %v\n", err)
return
}
fmt.Printf("Container %s restarted\n", name)
}
func cliDeleteContainer(reader *bufio.Reader) {
id, name := selectContainer(reader, "delete")
if id == 0 {
return
}
confirm := promptString(reader, fmt.Sprintf("Delete container %s? Type yes", name), "no")
if strings.ToLower(confirm) != "yes" {
fmt.Println("Canceled")
return
}
if err := manager.DestroyContainer(id); err != nil {
fmt.Printf("Delete failed: %v\n", err)
return
}
fmt.Printf("Container %s deleted\n", name)
}
func cliReinstallContainer(reader *bufio.Reader) {
id, name := selectContainer(reader, "reinstall")
if id == 0 {
return
}
templates := lxc.GetTemplates()
fmt.Println("\nAvailable templates:")
for i, template := range templates {
fmt.Printf(" %d. %s\n", i+1, template.Name)
}
tmplIdx := promptInt(reader, fmt.Sprintf("Template [1-%d]", len(templates)), 1)
if tmplIdx < 1 || tmplIdx > len(templates) {
fmt.Println("Invalid template selection")
return
}
confirm := promptString(reader, fmt.Sprintf("Reinstall container %s? Type yes", name), "no")
if strings.ToLower(confirm) != "yes" {
fmt.Println("Canceled")
return
}
if err := manager.ReinstallContainer(id, templates[tmplIdx-1].ID); err != nil {
fmt.Printf("Reinstall failed: %v\n", err)
return
}
fmt.Printf("Container %s reinstalled\n", name)
}
func cliResetPassword(reader *bufio.Reader) {
newPass := promptString(reader, "New admin password (at least 6 chars)", "")
if len(newPass) < 6 {
fmt.Println("Password must be at least 6 chars")
return
}
confirm := promptString(reader, "Confirm password", "")
if newPass != confirm {
fmt.Println("Passwords do not match")
return
}
if err := config.ResetAdminPassword(newPass); err != nil {
fmt.Printf("Reset failed: %v\n", err)
return
}
fmt.Println("Admin password reset. Restart the web service for it to take effect.")
}
func cliToggleWebPanel() {
if isWebPanelRunning() {
cmd := exec.Command("systemctl", "stop", "clicd")
if err := cmd.Run(); err != nil {
fmt.Printf("Failed to stop web panel: %v\n", err)
return
}
fmt.Println("Web panel stopped. LXC containers are not affected.")
return
}
cmd := exec.Command("systemctl", "start", "clicd")
if err := cmd.Run(); err != nil {
fmt.Printf("Failed to start web panel: %v\n", err)
return
}
fmt.Println("Web panel started")
}
func isWebPanelRunning() bool {
cmd := exec.Command("systemctl", "is-active", "clicd")
output, err := cmd.Output()
if err != nil {
return false
}
return strings.TrimSpace(string(output)) == "active"
}
func cliShowInfo() {
containers, err := manager.ListContainers()
if err != nil {
fmt.Printf("Failed to read container status: %v\n", err)
}
total := len(containers)
running := 0
for _, container := range containers {
if container.Status == "running" {
running++
}
}
fmt.Println("\n--- System info ---")
fmt.Printf("Web port: %d\n", config.AppConfig.Port)
fmt.Printf("Admin user: %s\n", config.AppConfig.AdminUser)
fmt.Printf("Containers: %d\n", total)
fmt.Printf("Running: %d\n", running)
fmt.Printf("Stopped: %d\n", total-running)
if hostname, err := os.Hostname(); err == nil {
fmt.Printf("Hostname: %s\n", hostname)
}
cmd := exec.Command("lxc-info", "--version")
output, err := cmd.Output()
if err == nil {
fmt.Printf("LXC version: %s", string(output))
}
}
func selectContainer(reader *bufio.Reader, action string) (int, string) {
containers, err := manager.ListContainers()
if err != nil {
fmt.Printf("Failed to list containers: %v\n", err)
return 0, ""
}
if len(containers) == 0 {
fmt.Println("No containers available")
return 0, ""
}
fmt.Printf("\n--- Select container to %s ---\n", action)
for i, container := range containers {
fmt.Printf(" %d. [%d] %s [%s]\n", i+1, container.ID, container.Name, container.Status)
}
idx := promptInt(reader, "Container", 0)
if idx < 1 || idx > len(containers) {
fmt.Println("Invalid selection")
return 0, ""
}
c := containers[idx-1]
return c.ID, c.Name
}
func promptString(reader *bufio.Reader, label string, fallback string) string {
if fallback == "" {
fmt.Printf("%s: ", label)
} else {
fmt.Printf("%s [%s]: ", label, fallback)
}
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input)
if input == "" {
return fallback
}
return input
}
func promptInt(reader *bufio.Reader, label string, fallback int) int {
input := promptString(reader, label, strconv.Itoa(fallback))
value, err := strconv.Atoi(input)
if err != nil || value < 0 {
return fallback
}
return value
}
func promptFloat(reader *bufio.Reader, label string, fallback float64) float64 {
input := promptString(reader, label, strconv.FormatFloat(fallback, 'f', -1, 64))
value, err := strconv.ParseFloat(input, 64)
if err != nil || value < 0 {
return fallback
}
return value
}
func clearScreen() {
fmt.Print("\033[H\033[2J")
}
func waitEnter(reader *bufio.Reader) {
fmt.Print("\nPress Enter to return to menu...")
reader.ReadString('\n')
}
func promptPortList(reader *bufio.Reader, label string) []int {
input := promptString(reader, label, "")
if input == "" {
return nil
}
var ports []int
for _, part := range strings.Split(input, ",") {
value, err := strconv.Atoi(strings.TrimSpace(part))
if err != nil || value <= 0 || value > 65535 {
fmt.Printf("Ignoring invalid port: %s\n", strings.TrimSpace(part))
continue
}
ports = append(ports, value)
}
return ports
}
+586
View File
@@ -0,0 +1,586 @@
package config
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
// PortMapping represents a port mapping rule
type PortMapping struct {
ContainerPort int `json:"container_port"`
HostPort int `json:"host_port"`
Protocol string `json:"protocol"`
Description string `json:"description"`
}
// SavedTask for persisting task queue across restarts
type SavedTask struct {
ID string `json:"id"`
Type string `json:"type"`
ContainerID int `json:"container_id"`
ContainerName string `json:"container_name"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
CreatedAt string `json:"created_at"`
TemplateID string `json:"template_id,omitempty"`
Config string `json:"config,omitempty"`
User string `json:"user,omitempty"`
}
// SavedLoginLog for persisting login logs
type SavedLoginLog struct {
Time string `json:"time"`
Username string `json:"username"`
IP string `json:"ip"`
UserAgent string `json:"user_agent"`
Success bool `json:"success"`
}
// AuditLog represents an operation log entry
type AuditLog struct {
Time string `json:"time"`
Action string `json:"action"`
Target string `json:"target"`
Detail string `json:"detail"`
User string `json:"user"`
}
// OversellConfig controls host-level overselling behavior
type OversellConfig struct {
CPUOvercommit int `json:"cpu_overcommit"` // multiplier, e.g. 4 means 4x oversell
RAMOvercommit int `json:"ram_overcommit"` // multiplier
DiskOvercommit int `json:"disk_overcommit"` // multiplier
KSMEnabled bool `json:"ksm_enabled"` // kernel same-page merging
Swappiness int `json:"swappiness"` // 0-100, lower = less swap
}
// Container represents an LXC container configuration
type Container struct {
ID int `json:"id"`
UUID string `json:"uuid"`
Name string `json:"name"`
Template string `json:"template"`
VCPU float64 `json:"vcpu"`
RAMMB int `json:"ram_mb"`
DiskGB int `json:"disk_gb"`
NetworkBWMbps int `json:"network_bw_mbps"`
MonthlyTrafficGB int `json:"monthly_traffic_gb"`
TrafficMode string `json:"traffic_mode"` // "total" or "in_out"
TrafficInGB int `json:"traffic_in_gb"` // 0 = unlimited
TrafficOutGB int `json:"traffic_out_gb"` // 0 = unlimited
TrafficUsedRX int64 `json:"traffic_used_rx"`
TrafficUsedTX int64 `json:"traffic_used_tx"`
TrafficResetDate string `json:"traffic_reset_date"`
IOSpeedMBps int `json:"io_speed_mbps"`
Status string `json:"status"`
IP string `json:"ip"`
IPv6 string `json:"ipv6"`
IPv6PrefixLen int `json:"ipv6_prefix_len"`
IPv6Interface string `json:"ipv6_interface"`
VNCPort int `json:"vnc_port"`
SSHPort int `json:"ssh_port"`
SSHPassword string `json:"ssh_password"`
PortMappings []PortMapping `json:"port_mappings"`
PortMappingLimit int `json:"port_mapping_limit"`
CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at"`
}
// LxcName returns the internal LXC container name (ct-{id})
func (c *Container) LxcName() string {
return fmt.Sprintf("ct-%d", c.ID)
}
// SubUser represents a sub-user with access to specific containers
type ApiKeyConfig struct {
ID string `json:"id"`
Name string `json:"name"`
KeyHash string `json:"key_hash"`
Prefix string `json:"prefix"`
IPWhitelist string `json:"ip_whitelist"`
CreatedAt string `json:"created_at"`
LastUsed string `json:"last_used"`
}
// DeleteApiKey removes an API key by ID
func DeleteApiKey(id string) {
filtered := make([]ApiKeyConfig, 0, len(AppConfig.ApiKeys))
for _, k := range AppConfig.ApiKeys {
if k.ID != id {
filtered = append(filtered, k)
}
}
AppConfig.ApiKeys = filtered
SaveConfig()
}
type SubUser struct {
ID string `json:"id"`
Username string `json:"username"`
Password string `json:"password"` // plaintext for display
PassHash string `json:"pass_hash"`
ContainerNames []string `json:"container_names"`
Token string `json:"token"`
AccessCode string `json:"access_code"`
CreatedAt string `json:"created_at"`
}
// ClicdConfig is the main configuration structure
type ClicdConfig struct {
AdminUser string `json:"admin_user"`
AdminPassHash string `json:"admin_pass_hash"`
JWTSecret string `json:"jwt_secret"`
Port int `json:"port"`
DataDir string `json:"data_dir"`
Containers []Container `json:"containers"`
NextContainerID int `json:"next_container_id"`
NextVNCPort int `json:"next_vnc_port"`
NextSSHPort int `json:"next_ssh_port"`
SetupComplete bool `json:"setup_complete"`
Oversell OversellConfig `json:"oversell"`
SubUsers []SubUser `json:"sub_users"`
ApiKeys []ApiKeyConfig `json:"api_keys"`
AuditLogs []AuditLog `json:"audit_logs"`
Tasks []SavedTask `json:"tasks"`
LoginLogs []SavedLoginLog `json:"login_logs"`
EnabledImages []string `json:"enabled_images"`
}
var configPath string
var AppConfig *ClicdConfig
func getConfigPath() string {
if configPath != "" {
return configPath
}
home, err := os.UserHomeDir()
if err != nil {
home = "/root"
}
return filepath.Join(home, ".clicd", "config.json")
}
func SetConfigPath(path string) {
configPath = path
}
func getDataDir() string {
home, err := os.UserHomeDir()
if err != nil {
home = "/root"
}
return filepath.Join(home, ".clicd")
}
func generateRandomString(length int) string {
b := make([]byte, length)
rand.Read(b)
return hex.EncodeToString(b)[:length]
}
func generateUUIDString() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return generateRandomString(32)
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
// NewContainerUUID returns a UUID that is unique within the current config.
func NewContainerUUID() string {
for {
uuid := generateUUIDString()
if FindContainerByUUID(uuid) == nil {
return uuid
}
}
}
// InitConfig initializes or loads the configuration
func InitConfig() (*ClicdConfig, error) {
cfgPath := getConfigPath()
dataDir := getDataDir()
if err := os.MkdirAll(filepath.Dir(cfgPath), 0700); err != nil {
return nil, fmt.Errorf("failed to create config directory: %v", err)
}
if err := os.MkdirAll(dataDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create data directory: %v", err)
}
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
// First run: generate new config
adminUser := "admin"
adminPass := generateRandomString(16)
jwtSecret := generateRandomString(32)
hash, err := bcrypt.GenerateFromPassword([]byte(adminPass), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("failed to hash password: %v", err)
}
AppConfig = &ClicdConfig{
AdminUser: adminUser,
AdminPassHash: string(hash),
JWTSecret: jwtSecret,
Port: 8999,
DataDir: dataDir,
Containers: []Container{},
NextContainerID: 1,
NextVNCPort: 5900,
NextSSHPort: 22000,
SetupComplete: false,
SubUsers: []SubUser{},
AuditLogs: []AuditLog{},
Tasks: []SavedTask{},
LoginLogs: []SavedLoginLog{},
Oversell: OversellConfig{
CPUOvercommit: 4,
RAMOvercommit: 1,
DiskOvercommit: 2,
KSMEnabled: true,
Swappiness: 10,
},
}
if err := SaveConfig(); err != nil {
return nil, err
}
fmt.Println("\n========================================")
fmt.Println(" CLICD - LXC Container Manager")
fmt.Println("========================================")
fmt.Printf(" Username: %s\n", adminUser)
fmt.Printf(" Password: %s\n", adminPass)
fmt.Println("========================================")
fmt.Println(" Please save these credentials!")
fmt.Println(" Web Interface: http://0.0.0.0:8999")
fmt.Println("========================================")
fmt.Println()
return AppConfig, nil
}
// Load existing config
data, err := os.ReadFile(cfgPath)
if err != nil {
return nil, fmt.Errorf("failed to read config: %v", err)
}
AppConfig = &ClicdConfig{}
if err := json.Unmarshal(data, AppConfig); err != nil {
return nil, fmt.Errorf("failed to parse config: %v", err)
}
if AppConfig.Port == 0 {
AppConfig.Port = 8999
}
if AppConfig.NextVNCPort == 0 {
AppConfig.NextVNCPort = 5900
}
if AppConfig.NextSSHPort == 0 {
AppConfig.NextSSHPort = 22000
}
if AppConfig.NextContainerID == 0 {
AppConfig.NextContainerID = 1
}
if AppConfig.DataDir == "" {
AppConfig.DataDir = dataDir
}
if AppConfig.Containers == nil {
AppConfig.Containers = make([]Container, 0)
}
changed := ensureContainerUUIDs()
if ensureContainerPortMappingLimits() {
changed = true
}
if removeLegacyVNCMappings() {
changed = true
}
if changed {
if err := SaveConfig(); err != nil {
return nil, err
}
}
return AppConfig, nil
}
func ensureContainerUUIDs() bool {
changed := false
used := make(map[string]bool)
for i := range AppConfig.Containers {
uuid := AppConfig.Containers[i].UUID
if uuid == "" || used[uuid] {
for {
uuid = generateUUIDString()
if !used[uuid] {
break
}
}
AppConfig.Containers[i].UUID = uuid
changed = true
}
used[uuid] = true
}
return changed
}
func ensureContainerPortMappingLimits() bool {
changed := false
for i := range AppConfig.Containers {
if AppConfig.Containers[i].PortMappingLimit <= 0 {
limit := len(AppConfig.Containers[i].PortMappings)
if limit < 2 {
limit = 2
}
AppConfig.Containers[i].PortMappingLimit = limit
changed = true
}
}
return changed
}
func removeLegacyVNCMappings() bool {
changed := false
for i := range AppConfig.Containers {
mappings := AppConfig.Containers[i].PortMappings
if len(mappings) == 0 {
continue
}
filtered := mappings[:0]
for _, pm := range mappings {
isLegacyVNC := strings.EqualFold(pm.Description, "VNC") || pm.ContainerPort == 5901
if isLegacyVNC {
changed = true
continue
}
filtered = append(filtered, pm)
}
AppConfig.Containers[i].PortMappings = filtered
}
return changed
}
// SaveConfig saves configuration to disk
func SaveConfig() error {
data, err := json.MarshalIndent(AppConfig, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config: %v", err)
}
return os.WriteFile(getConfigPath(), data, 0600)
}
// AddContainer adds a container to the config
func AddContainer(c Container) {
if c.UUID == "" {
c.UUID = NewContainerUUID()
}
AppConfig.Containers = append(AppConfig.Containers, c)
SaveConfig()
}
// AllocateContainerID allocates a new container ID
func AllocateContainerID() int {
id := AppConfig.NextContainerID
AppConfig.NextContainerID++
SaveConfig()
return id
}
// RemoveContainer removes a container from config by ID
func RemoveContainer(id int) bool {
for i, c := range AppConfig.Containers {
if c.ID == id {
removeSubUserContainerAccess(c.Name)
AppConfig.Containers = append(AppConfig.Containers[:i], AppConfig.Containers[i+1:]...)
SaveConfig()
return true
}
}
return false
}
func removeSubUserContainerAccess(containerName string) {
if containerName == "" || len(AppConfig.SubUsers) == 0 {
return
}
filteredUsers := make([]SubUser, 0, len(AppConfig.SubUsers))
for _, su := range AppConfig.SubUsers {
filteredNames := make([]string, 0, len(su.ContainerNames))
for _, name := range su.ContainerNames {
if name != containerName {
filteredNames = append(filteredNames, name)
}
}
if len(filteredNames) == 0 {
continue
}
su.ContainerNames = filteredNames
filteredUsers = append(filteredUsers, su)
}
AppConfig.SubUsers = filteredUsers
}
// FindContainer finds a container by ID
func FindContainer(id int) *Container {
for i, c := range AppConfig.Containers {
if c.ID == id {
return &AppConfig.Containers[i]
}
}
return nil
}
// FindContainerByUUID finds a container by UUID.
func FindContainerByUUID(uuid string) *Container {
for i, c := range AppConfig.Containers {
if c.UUID == uuid {
return &AppConfig.Containers[i]
}
}
return nil
}
// FindContainerByName finds a container by name
func FindContainerByName(name string) *Container {
for i, c := range AppConfig.Containers {
if c.Name == name {
return &AppConfig.Containers[i]
}
}
return nil
}
// FindContainerByIdentifier finds a container by ID, UUID, or name.
func FindContainerByIdentifier(identifier string) *Container {
if id, err := strconv.Atoi(identifier); err == nil {
if c := FindContainer(id); c != nil {
return c
}
}
if c := FindContainerByUUID(identifier); c != nil {
return c
}
return FindContainerByName(identifier)
}
// UpdateContainerStatus updates container status by ID
func UpdateContainerStatus(id int, status string) {
c := FindContainer(id)
if c != nil {
c.Status = status
SaveConfig()
}
}
// UpdateVNC refreshes all container statuses
func UpdateVNC(containers []Container) {
AppConfig.Containers = containers
SaveConfig()
}
// AllocateSSHPort allocates a new SSH port
func AllocateSSHPort() int {
port := AppConfig.NextSSHPort
AppConfig.NextSSHPort++
SaveConfig()
return port
}
// IsValidContainerName checks if container name is valid (no duplicate check needed, ID is primary key)
func IsValidContainerName(name string) bool {
return IsValidContainerNameSyntax(name)
}
// IsValidContainerNameSyntax checks only the container name format.
func IsValidContainerNameSyntax(name string) bool {
if len(name) == 0 || len(name) > 63 {
return false
}
// Only allow alphanumeric, hyphens, underscores
for _, c := range name {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
return false
}
}
return true
}
// AddAuditLog adds an audit log entry
func AddAuditLog(action, target, detail, user string) {
log := AuditLog{
Time: time.Now().Format("2006-01-02 15:04:05"),
Action: action,
Target: target,
Detail: detail,
User: user,
}
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
func SaveTasks(tasks []SavedTask) {
AppConfig.Tasks = tasks
SaveConfig()
}
// AddLoginLog persists a login log entry
func AddLoginLog(username, ip, userAgent string, success bool) {
log := SavedLoginLog{
Time: time.Now().Format("2006-01-02 15:04:05 MST"),
Username: username,
IP: ip,
UserAgent: userAgent,
Success: success,
}
AppConfig.LoginLogs = append(AppConfig.LoginLogs, log)
if len(AppConfig.LoginLogs) > 200 {
AppConfig.LoginLogs = AppConfig.LoginLogs[len(AppConfig.LoginLogs)-200:]
}
SaveConfig()
}
// ResetAdminPassword resets the admin password from CLI
func ResetAdminPassword(newPassword string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
AppConfig.AdminPassHash = string(hash)
return SaveConfig()
}
// CleanStaleContainers removes containers from config if their LXC directory doesn't exist
func CleanStaleContainers() {
valid := make([]Container, 0)
changed := false
for _, c := range AppConfig.Containers {
lxcDir := "/var/lib/lxc/" + c.LxcName()
if _, err := os.Stat(lxcDir); os.IsNotExist(err) {
fmt.Printf("Cleaning stale container config: %s (LXC dir not found)\n", c.LxcName())
changed = true
continue
}
valid = append(valid, c)
}
if changed {
AppConfig.Containers = valid
SaveConfig()
}
}
+137
View File
@@ -0,0 +1,137 @@
package lxc
import (
"fmt"
"time"
"clicd/internal/config"
)
// IsExpired checks if a container has passed its expiration date
func IsExpired(c config.Container) bool {
return isContainerExpired(c, time.Now())
}
// StopExpiredContainers stops running containers whose expiration date has passed.
func (m *Manager) StopExpiredContainers(now time.Time) {
for _, container := range config.AppConfig.Containers {
if !isContainerExpired(container, now) {
continue
}
status, err := m.GetContainerStatus(container.LxcName())
if err != nil {
status = container.Status
}
if status != "running" {
continue
}
fmt.Printf("Container %s (ID=%d) expired at %s, stopping...\n", container.Name, container.ID, container.ExpiresAt)
if err := m.StopContainer(container.ID); err != nil {
fmt.Printf("Warning: failed to stop expired container %s: %v\n", container.Name, err)
}
}
}
// StartExpiryScanner runs a background loop that tracks traffic & stops expired/over-traffic containers every 30 seconds
func (m *Manager) StartExpiryScanner() {
go func() {
for {
time.Sleep(30 * time.Second)
now := time.Now()
m.AccumulateTraffic() // track network traffic deltas
m.StopExpiredContainers(now)
m.StopTrafficExceededContainers(now)
}
}()
}
// StopTrafficExceededContainers stops running containers that have exceeded their monthly traffic limit
func (m *Manager) StopTrafficExceededContainers(now time.Time) {
currentMonth := now.Format("2006-01")
saved := false
for i := range config.AppConfig.Containers {
c := &config.AppConfig.Containers[i]
if c.Status != "running" {
continue
}
// Reset traffic if new month
if c.TrafficResetDate != currentMonth {
c.TrafficUsedRX = 0
c.TrafficUsedTX = 0
c.TrafficResetDate = currentMonth
saved = true
continue
}
// Check traffic limits
if isTrafficExceeded(*c) {
fmt.Printf("Container %s (ID=%d) exceeded traffic limit, stopping...\n", c.Name, c.ID)
if err := m.StopContainer(c.ID); err != nil {
fmt.Printf("Warning: failed to stop traffic-exceeded container %s: %v\n", c.Name, err)
}
}
}
if saved {
config.SaveConfig()
}
}
func isTrafficExceeded(c config.Container) bool {
if c.TrafficMode == "in_out" {
inLimit := int64(c.TrafficInGB) * 1073741824
outLimit := int64(c.TrafficOutGB) * 1073741824
if inLimit > 0 && c.TrafficUsedRX >= inLimit {
return true
}
if outLimit > 0 && c.TrafficUsedTX >= outLimit {
return true
}
return false
}
totalLimit := int64(c.MonthlyTrafficGB) * 1073741824
return totalLimit > 0 && (c.TrafficUsedRX+c.TrafficUsedTX) >= totalLimit
}
// ResetTraffic resets traffic counters for a container
func (m *Manager) ResetTraffic(id int) error {
c := config.FindContainer(id)
if c == nil {
return fmt.Errorf("container not found: %d", id)
}
c.TrafficUsedRX = 0
c.TrafficUsedTX = 0
c.TrafficResetDate = time.Now().Format("2006-01")
config.SaveConfig()
return nil
}
// IsTrafficExceeded checks if a container has exceeded its traffic limit
func IsTrafficExceeded(c config.Container) bool {
return isTrafficExceeded(c)
}
func isContainerExpired(container config.Container, now time.Time) bool {
expiresAt, ok := ParseExpiration(container.ExpiresAt)
return ok && !now.Before(expiresAt)
}
// ParseExpiration parses an expiration string. A YYYY-MM-DD value expires at the
// end of that local day, while RFC3339 values are treated as exact timestamps.
func ParseExpiration(value string) (time.Time, bool) {
if value == "" {
return time.Time{}, false
}
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
return parsed, true
}
if parsed, err := time.ParseInLocation("2006-01-02", value, time.Local); err == nil {
return parsed.Add(24 * time.Hour), true
}
return time.Time{}, false
}
+553
View File
@@ -0,0 +1,553 @@
package lxc
import (
"encoding/binary"
"fmt"
"math/big"
"net/netip"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"clicd/internal/config"
)
const ipv6GatewayLinkLocal = "fe80::1"
type IPv6PrefixInfo struct {
Interface string `json:"interface"`
Address string `json:"address"`
Prefix string `json:"prefix"`
PrefixLen int `json:"prefix_len"`
Gateway string `json:"gateway"`
IsTunnel bool `json:"is_tunnel"`
Source string `json:"source"`
}
type PublicIPInfo struct {
Address string `json:"address"`
Interface string `json:"interface"`
Prefix string `json:"prefix"`
IsTunnel bool `json:"is_tunnel"`
Source string `json:"source"`
}
type IPv6Status struct {
Available bool `json:"available"`
Reachable bool `json:"reachable"`
Reason string `json:"reason"`
Prefixes []IPv6PrefixInfo `json:"prefixes"`
}
func (m *Manager) DetectIPv6Status() IPv6Status {
status := IPv6Status{}
prefixes := DetectPublicIPv6Prefixes()
status.Prefixes = prefixes
if len(prefixes) == 0 {
status.Reason = "no usable public IPv6 prefix found; /128 single-address IPv6 is not assignable"
return status
}
status.Reachable = ipv6ConnectivityOK()
if !status.Reachable {
status.Reason = "host has an IPv6 prefix, but outbound IPv6 connectivity test failed"
return status
}
status.Available = true
status.Reason = "usable public IPv6 prefix detected"
return status
}
func DetectPublicIPv6Prefixes() []IPv6PrefixInfo {
return detectPublicIPv6Prefixes(detectIPv6DefaultRoutes())
}
func DetectPublicIPv4() PublicIPInfo {
candidates := DetectPublicIPv4Candidates()
if len(candidates) == 0 {
return PublicIPInfo{}
}
return candidates[0]
}
func DetectPublicIPv4Candidates() []PublicIPInfo {
out, err := exec.Command("ip", "-4", "-o", "addr", "show", "scope", "global").Output()
if err != nil {
return nil
}
defaultRoutes := detectIPv4DefaultRoutes()
defaultIfaces := map[string]bool{}
for _, route := range defaultRoutes {
defaultIfaces[route.Interface] = true
}
type candidate struct {
info PublicIPInfo
score int
}
var candidates []candidate
seen := map[string]bool{}
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) < 4 || fields[2] != "inet" {
continue
}
iface := normalizeIface(fields[1])
if isContainerLikeInterface(iface) {
continue
}
prefix, err := netip.ParsePrefix(fields[3])
if err != nil || !prefix.Addr().Is4() || !isPublicIPv4(prefix.Addr()) {
continue
}
key := iface + "|" + prefix.Addr().String()
if seen[key] {
continue
}
seen[key] = true
score := publicInterfaceScore(iface, defaultIfaces)
candidates = append(candidates, candidate{
info: PublicIPInfo{
Address: prefix.Addr().String(),
Interface: iface,
Prefix: prefix.Masked().String(),
IsTunnel: isTunnelLikeInterface(iface),
Source: "local",
},
score: score,
})
}
sort.SliceStable(candidates, func(i, j int) bool {
return candidates[i].score > candidates[j].score
})
result := make([]PublicIPInfo, 0, len(candidates))
for _, c := range candidates {
result = append(result, c.info)
}
return result
}
func detectPublicIPv6Prefixes(defaultRoutes []routeInfo) []IPv6PrefixInfo {
out, err := exec.Command("ip", "-6", "-o", "addr", "show", "scope", "global").Output()
if err != nil {
return nil
}
defaultIfaces := map[string]bool{}
gateways := map[string]string{}
for _, route := range defaultRoutes {
defaultIfaces[route.Interface] = true
if route.Gateway != "" && gateways[route.Interface] == "" {
gateways[route.Interface] = route.Gateway
}
}
type candidate struct {
info IPv6PrefixInfo
score int
}
var candidates []candidate
seen := map[string]bool{}
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) < 4 || fields[2] != "inet6" {
continue
}
iface := normalizeIface(fields[1])
if isContainerLikeInterface(iface) {
continue
}
prefix, err := netip.ParsePrefix(fields[3])
if err != nil || !prefix.Addr().Is6() {
continue
}
addr := prefix.Addr()
if !isPublicIPv6(addr) {
continue
}
// Require at least 8 host bits. /128 is a single address, not a usable segment.
if prefix.Bits() > 120 {
continue
}
masked := prefix.Masked()
key := iface + "|" + masked.String()
if seen[key] {
continue
}
seen[key] = true
score := publicInterfaceScore(iface, defaultIfaces)
if masked.Bits() <= 64 {
score += 20
}
info := IPv6PrefixInfo{
Interface: iface,
Address: addr.String(),
Prefix: masked.String(),
PrefixLen: masked.Bits(),
Gateway: gateways[iface],
IsTunnel: isTunnelLikeInterface(iface),
Source: "local",
}
candidates = append(candidates, candidate{info: info, score: score})
}
sort.SliceStable(candidates, func(i, j int) bool {
return candidates[i].score > candidates[j].score
})
result := make([]IPv6PrefixInfo, 0, len(candidates))
for _, c := range candidates {
result = append(result, c.info)
}
return result
}
type routeInfo struct {
Interface string
Gateway string
Metric int
}
func detectIPv4DefaultRoutes() []routeInfo {
out, err := exec.Command("ip", "-4", "route", "show", "default").Output()
if err != nil {
return nil
}
return parseDefaultRoutes(string(out))
}
func detectIPv6DefaultRoutes() []routeInfo {
out, err := exec.Command("ip", "-6", "route", "show", "default").Output()
if err != nil {
return nil
}
return parseDefaultRoutes(string(out))
}
func parseDefaultRoutes(output string) []routeInfo {
var routes []routeInfo
for _, line := range strings.Split(output, "\n") {
fields := strings.Fields(line)
if len(fields) == 0 || fields[0] != "default" {
continue
}
route := routeInfo{Metric: 1024}
for i := 1; i < len(fields)-1; i++ {
switch fields[i] {
case "dev":
route.Interface = normalizeIface(fields[i+1])
case "via":
route.Gateway = fields[i+1]
case "metric":
metric, err := strconv.Atoi(fields[i+1])
if err == nil {
route.Metric = metric
}
}
}
if route.Interface != "" {
routes = append(routes, route)
}
}
sort.SliceStable(routes, func(i, j int) bool {
return routes[i].Metric < routes[j].Metric
})
return routes
}
func ipv6ConnectivityOK() bool {
targets := [][]string{
{"ping", "-6", "-c", "1", "-W", "2", "2606:4700:4700::1111"},
{"ping", "-6", "-c", "1", "-W", "2", "2001:4860:4860::8888"},
{"ping6", "-c", "1", "-W", "2", "2606:4700:4700::1111"},
}
for _, args := range targets {
if exec.Command(args[0], args[1:]...).Run() == nil {
return true
}
}
return false
}
func isContainerLikeInterface(iface string) bool {
prefixes := []string{
"lo", "lxc", "docker", "br-", "veth", "virbr", "cni", "flannel", "cali",
"kube", "dummy", "ifb", "zt", "zerotier",
}
for _, prefix := range prefixes {
if iface == prefix || strings.HasPrefix(iface, prefix) {
return true
}
}
return false
}
func normalizeIface(iface string) string {
iface = strings.TrimSuffix(iface, ":")
if at := strings.Index(iface, "@"); at >= 0 {
iface = iface[:at]
}
return iface
}
func publicInterfaceScore(iface string, defaultIfaces map[string]bool) int {
score := 0
if defaultIfaces[iface] {
score += 100
}
if isTunnelLikeInterface(iface) {
score -= 120
} else {
score += 80
}
if isLikelyPhysicalInterface(iface) {
score += 40
}
if operState(iface) == "up" {
score += 10
}
return score
}
func isLikelyPhysicalInterface(iface string) bool {
prefixes := []string{"eth", "ens", "eno", "enp", "em", "bond", "team"}
for _, prefix := range prefixes {
if strings.HasPrefix(iface, prefix) {
return true
}
}
return false
}
func isTunnelLikeInterface(iface string) bool {
lower := strings.ToLower(iface)
prefixes := []string{
"wg", "wgcf", "warp", "cloudflare", "tun", "tap", "tailscale", "ts",
"vpn", "ppp", "ipsec", "gre", "gretap", "sit", "he-", "nebula", "zt",
}
for _, prefix := range prefixes {
if lower == prefix || strings.HasPrefix(lower, prefix) {
return true
}
}
return strings.Contains(lower, "warp") || strings.Contains(lower, "cloudflare")
}
func operState(iface string) string {
data, err := os.ReadFile("/sys/class/net/" + iface + "/operstate")
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
func isPublicIPv4(addr netip.Addr) bool {
if !addr.IsGlobalUnicast() || addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() {
return false
}
raw := addr.As4()
if raw[0] == 100 && raw[1] >= 64 && raw[1] <= 127 {
return false
}
if raw[0] == 192 && raw[1] == 0 && raw[2] == 0 {
return false
}
return true
}
func isPublicIPv6(addr netip.Addr) bool {
if !addr.IsGlobalUnicast() || addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() {
return false
}
return !strings.HasPrefix(addr.String(), "2001:db8:")
}
func (m *Manager) allocateIPv6ForContainer(id int) (string, int, string, error) {
status := m.DetectIPv6Status()
if !status.Available {
return "", 0, "", fmt.Errorf("public IPv6 allocation is unavailable: %s", status.Reason)
}
prefixInfo := status.Prefixes[0]
prefix, err := netip.ParsePrefix(prefixInfo.Prefix)
if err != nil {
return "", 0, "", err
}
used := map[string]bool{}
hostAddrs := map[string]bool{}
for _, p := range status.Prefixes {
hostAddrs[p.Address] = true
}
for _, c := range config.AppConfig.Containers {
if c.IPv6 != "" {
used[c.IPv6] = true
}
}
for offset := uint64(0x1000 + id); offset < 0x100000; offset++ {
addr, err := ipv6Add(prefix.Masked().Addr(), offset)
if err != nil || !prefix.Contains(addr) {
break
}
candidate := addr.String()
if !used[candidate] && !hostAddrs[candidate] {
return candidate, prefix.Bits(), prefixInfo.Interface, nil
}
}
return "", 0, "", fmt.Errorf("no free IPv6 address in %s", prefix.String())
}
func ipv6Add(base netip.Addr, offset uint64) (netip.Addr, error) {
raw := base.As16()
value := big.NewInt(0).SetBytes(raw[:])
add := make([]byte, 8)
binary.BigEndian.PutUint64(add, offset)
value.Add(value, big.NewInt(0).SetBytes(add))
bytes := value.Bytes()
if len(bytes) > 16 {
return netip.Addr{}, fmt.Errorf("IPv6 address overflow")
}
padded := make([]byte, 16)
copy(padded[16-len(bytes):], bytes)
var out [16]byte
copy(out[:], padded)
return netip.AddrFrom16(out), nil
}
func (m *Manager) AssignIPv6(id int) (*config.Container, error) {
c := config.FindContainer(id)
if c == nil {
return nil, fmt.Errorf("container not found: %d", id)
}
if c.IPv6 == "" {
addr, prefixLen, iface, err := m.allocateIPv6ForContainer(id)
if err != nil {
return nil, err
}
c.IPv6 = addr
c.IPv6PrefixLen = prefixLen
c.IPv6Interface = iface
config.SaveConfig()
}
if err := m.applyIPv6Config(c.LxcName(), c.IPv6); err != nil {
return nil, err
}
if err := m.ApplyIPv6(id); err != nil {
return nil, err
}
return c, nil
}
func (m *Manager) applyIPv6Config(lxcName, ipv6 string) error {
configFile := filepath.Join(m.LxcPath, lxcName, "config")
data, err := os.ReadFile(configFile)
if err != nil {
return fmt.Errorf("failed to read container config: %v", err)
}
lines := strings.Split(string(data), "\n")
next := make([]string, 0, len(lines)+4)
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.Contains(trimmed, "# clicd managed: public IPv6") ||
strings.HasPrefix(trimmed, "lxc.net.0.ipv6.address") ||
strings.HasPrefix(trimmed, "lxc.net.0.ipv6.gateway") {
continue
}
next = append(next, line)
}
if ipv6 != "" {
next = append(next, "", "# clicd managed: public IPv6 routed /128")
next = append(next, fmt.Sprintf("lxc.net.0.ipv6.address = %s/128", ipv6))
next = append(next, "lxc.net.0.ipv6.gateway = auto")
}
return os.WriteFile(configFile, []byte(strings.Join(next, "\n")), 0644)
}
func (m *Manager) ApplyIPv6(id int) error {
c := config.FindContainer(id)
if c == nil {
return fmt.Errorf("container not found: %d", id)
}
if c.IPv6 == "" {
return nil
}
if c.IPv6Interface == "" {
status := m.DetectIPv6Status()
if len(status.Prefixes) == 0 {
return fmt.Errorf("failed to detect IPv6 uplink for %s", c.IPv6)
}
c.IPv6Interface = status.Prefixes[0].Interface
c.IPv6PrefixLen = status.Prefixes[0].PrefixLen
config.SaveConfig()
}
if err := ensureHostIPv6Routing(c.IPv6, c.IPv6Interface); err != nil {
return err
}
status, _ := m.GetContainerStatus(c.LxcName())
if status != "running" {
return nil
}
cmd := exec.Command("lxc-attach", "-n", c.LxcName(), "--", "sh", "-c",
fmt.Sprintf("ip -6 addr replace %s/128 dev eth0 && ip -6 route replace default via %s dev eth0",
shellQuote(c.IPv6), shellQuote(ipv6GatewayLinkLocal)))
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to apply IPv6 inside container: %v, output: %s", err, string(output))
}
return nil
}
func ensureHostIPv6Routing(ipv6, uplink string) error {
if uplink == "" {
return fmt.Errorf("missing IPv6 uplink interface")
}
runQuiet("sysctl", "-w", "net.ipv6.conf.all.forwarding=1")
runQuiet("sysctl", "-w", "net.ipv6.conf."+uplink+".accept_ra=2")
runQuiet("sysctl", "-w", "net.ipv6.conf."+uplink+".proxy_ndp=1")
runQuiet("ip", "link", "set", "lxcbr0", "up")
runQuiet("ip", "-6", "addr", "add", ipv6GatewayLinkLocal+"/64", "dev", "lxcbr0")
if out, err := exec.Command("ip", "-6", "route", "replace", ipv6+"/128", "dev", "lxcbr0").CombinedOutput(); err != nil {
return fmt.Errorf("failed to add IPv6 host route: %v, output: %s", err, string(out))
}
if out, err := exec.Command("ip", "-6", "neigh", "replace", "proxy", ipv6, "dev", uplink).CombinedOutput(); err != nil {
return fmt.Errorf("failed to add IPv6 proxy NDP: %v, output: %s", err, string(out))
}
ensureIPv6ForwardRules(ipv6)
return nil
}
func ensureIPv6ForwardRules(ipv6 string) {
rules := [][]string{
{"FORWARD", "-i", "lxcbr0", "-s", ipv6 + "/128", "-j", "ACCEPT"},
{"FORWARD", "-o", "lxcbr0", "-d", ipv6 + "/128", "-j", "ACCEPT"},
}
for _, rule := range rules {
check := append([]string{"-C"}, rule...)
add := append([]string{"-A"}, rule...)
if exec.Command("ip6tables", check...).Run() != nil {
exec.Command("ip6tables", add...).Run()
}
}
}
func runQuiet(name string, args ...string) {
_ = exec.Command(name, args...).Run()
}
func (m *Manager) AssignedIPv6Count() int {
count := 0
for _, c := range config.AppConfig.Containers {
if strings.TrimSpace(c.IPv6) != "" {
count++
}
}
return count
}
func IPv6PrefixCapacity(prefixLen int) string {
if prefixLen <= 0 || prefixLen > 128 {
return "0"
}
hostBits := 128 - prefixLen
if hostBits > 32 {
return "large"
}
return strconv.FormatUint(uint64(1)<<uint(hostBits), 10)
}
File diff suppressed because it is too large Load Diff
+196
View File
@@ -0,0 +1,196 @@
package lxc
import (
"fmt"
"os/exec"
"strconv"
"clicd/internal/config"
)
// ApplyPortMappings applies iptables DNAT rules for a container's port mappings
func (m *Manager) ApplyPortMappings(id int) error {
c := config.FindContainer(id)
if c == nil {
return fmt.Errorf("container not found: %d", id)
}
if c.IP == "" {
return fmt.Errorf("container has no IP")
}
tag := clicdTag(id)
EnsureForwardRules()
m.CleanPortMappings(id)
for _, pm := range c.PortMappings {
cmd := exec.Command("iptables",
"-t", "nat",
"-I", "PREROUTING", "1",
"-p", pm.Protocol,
"--dport", fmt.Sprintf("%d", pm.HostPort),
"-j", "DNAT",
"--to-destination", fmt.Sprintf("%s:%d", c.IP, pm.ContainerPort),
"-m", "comment", "--comment", fmt.Sprintf("clicd-%s-%d", tag, pm.HostPort),
)
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Printf("Warning: failed to apply port mapping %d->%s:%d: %v, output: %s\n",
pm.HostPort, c.IP, pm.ContainerPort, err, string(output))
continue
}
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 {
exec.Command("iptables", "-t", "nat", "-I", "POSTROUTING", "1", "-s", "10.0.3.0/24", "-o", "eth+", "-j", "MASQUERADE").Run()
}
return nil
}
func clicdTag(id int) string { return "c" + strconv.Itoa(id) }
// EnsureForwardRules makes sure iptables FORWARD chain allows LXC bridge traffic
func EnsureForwardRules() {
rules := [][]string{
{"-A", "FORWARD", "-i", "lxcbr0", "-j", "ACCEPT"},
{"-A", "FORWARD", "-o", "lxcbr0", "-j", "ACCEPT"},
{"-A", "FORWARD", "-i", "lxcbr0", "-o", "lxcbr0", "-j", "ACCEPT"},
}
for _, args := range rules {
checkArgs := append([]string{"-C", "FORWARD"}, args[2:]...)
if exec.Command("iptables", checkArgs...).Run() != nil {
exec.Command("iptables", args...).Run()
}
}
}
// CleanPortMappings removes all iptables rules for a container
func (m *Manager) CleanPortMappings(id int) error {
tag := clicdTag(id)
cmd := exec.Command("sh", "-c",
fmt.Sprintf("iptables -t nat -L PREROUTING -n --line-numbers 2>/dev/null | grep 'clicd-%s' | awk '{print $1}' | sort -rn | while read num; do iptables -t nat -D PREROUTING $num; done", tag))
cmd.Run()
return nil
}
// SetupDefaultPortMappings creates default port mappings
func SetupDefaultPortMappings(sshPort int) []config.PortMapping {
return []config.PortMapping{
{ContainerPort: 22, HostPort: sshPort, Protocol: "tcp", Description: "SSH"},
}
}
// AddPortMapping adds a NAT rule to a container
func (m *Manager) AddPortMapping(id int, pm config.PortMapping) ([]config.PortMapping, error) {
c := config.FindContainer(id)
if c == nil {
return nil, fmt.Errorf("container not found: %d", id)
}
if c.PortMappingLimit > 0 && len(c.PortMappings) >= c.PortMappingLimit {
return nil, fmt.Errorf("port mapping quota exceeded: %d/%d", len(c.PortMappings), c.PortMappingLimit)
}
normalized, err := normalizePortMapping(c, -1, pm)
if err != nil {
return nil, err
}
c.PortMappings = append(c.PortMappings, normalized)
if err := persistAndReloadMappings(m, c); err != nil {
return nil, err
}
return c.PortMappings, nil
}
// UpdatePortMapping updates an existing NAT rule
func (m *Manager) UpdatePortMapping(id int, index int, pm config.PortMapping) ([]config.PortMapping, error) {
c := config.FindContainer(id)
if c == nil {
return nil, fmt.Errorf("container not found: %d", id)
}
if index < 0 || index >= len(c.PortMappings) {
return nil, fmt.Errorf("invalid port mapping index: %d", index)
}
normalized, err := normalizePortMapping(c, index, pm)
if err != nil {
return nil, err
}
c.PortMappings[index] = normalized
if err := persistAndReloadMappings(m, c); err != nil {
return nil, err
}
return c.PortMappings, nil
}
// DeletePortMapping removes a NAT rule
func (m *Manager) DeletePortMapping(id int, index int) ([]config.PortMapping, error) {
c := config.FindContainer(id)
if c == nil {
return nil, fmt.Errorf("container not found: %d", id)
}
if index < 0 || index >= len(c.PortMappings) {
return nil, fmt.Errorf("invalid port mapping index: %d", index)
}
if c.PortMappings[index].Description == "SSH" {
return nil, fmt.Errorf("SSH default mapping cannot be deleted")
}
c.PortMappings = append(c.PortMappings[:index], c.PortMappings[index+1:]...)
if err := persistAndReloadMappings(m, c); err != nil {
return nil, err
}
return c.PortMappings, nil
}
func persistAndReloadMappings(m *Manager, c *config.Container) error {
config.SaveConfig()
if c.Status == "running" && c.IP != "" {
return m.ApplyPortMappings(c.ID)
}
return nil
}
func normalizePortMapping(c *config.Container, skipIndex int, pm config.PortMapping) (config.PortMapping, error) {
if pm.ContainerPort < 1 || pm.ContainerPort > 65535 {
return pm, fmt.Errorf("container port must be 1-65535")
}
if pm.Protocol == "" {
pm.Protocol = "tcp"
}
if pm.Description == "" {
pm.Description = fmt.Sprintf("Port-%d", pm.ContainerPort)
}
if pm.HostPort <= 0 {
pm.HostPort = pm.ContainerPort
}
for i, existing := range c.PortMappings {
if i == skipIndex {
continue
}
if existing.HostPort == pm.HostPort && existing.Protocol == pm.Protocol {
return pm, fmt.Errorf("host port %d/%s already mapped", pm.HostPort, pm.Protocol)
}
}
return pm, nil
}
func allocateDefaultEqualPorts(c *config.Container, count int) []int {
if count <= 0 {
return nil
}
used := map[int]bool{}
for _, pm := range c.PortMappings {
used[pm.HostPort] = true
used[pm.ContainerPort] = true
}
ports := make([]int, 0, count)
next := 20000
for len(ports) < count {
if !used[next] {
ports = append(ports, next)
}
next++
if next > 65535 || len(ports) >= count {
break
}
}
return ports
}
+74
View File
@@ -0,0 +1,74 @@
package lxc
// Template represents an LXC image template
type Template struct {
ID string `json:"id"`
Name string `json:"name"`
Distro string `json:"distro"`
Release string `json:"release"`
Arch string `json:"arch"`
Variant string `json:"variant"`
Description string `json:"description"`
}
// GetTemplates returns available LXC image templates (only verified working ones)
func GetTemplates() []Template {
return []Template{
{
ID: "ubuntu-noble", Name: "Ubuntu 24.04",
Distro: "ubuntu", Release: "noble", Arch: "amd64",
Description: "Ubuntu 24.04 LTS",
},
{
ID: "ubuntu-jammy", Name: "Ubuntu 22.04",
Distro: "ubuntu", Release: "jammy", Arch: "amd64",
Description: "Ubuntu 22.04 LTS",
},
{
ID: "debian-bookworm", Name: "Debian 12",
Distro: "debian", Release: "bookworm", Arch: "amd64",
Description: "Debian 12 (Bookworm)",
},
{
ID: "debian-bullseye", Name: "Debian 11",
Distro: "debian", Release: "bullseye", Arch: "amd64",
Description: "Debian 11 (Bullseye)",
},
{
ID: "alpine-3.21", Name: "Alpine 3.21",
Distro: "alpine", Release: "3.21", Arch: "amd64",
Description: "Alpine Linux 3.21",
},
{
ID: "centos-9-stream", Name: "CentOS 9 Stream",
Distro: "centos", Release: "9-Stream", Arch: "amd64",
Description: "CentOS 9 Stream",
},
{
ID: "archlinux-current", Name: "Arch Linux",
Distro: "archlinux", Release: "current", Arch: "amd64", Variant: "cloud",
Description: "Arch Linux (Rolling)",
},
{
ID: "fedora-44", Name: "Fedora 44",
Distro: "fedora", Release: "44", Arch: "amd64", Variant: "cloud",
Description: "Fedora 44",
},
{
ID: "rockylinux-10", Name: "Rocky Linux 10",
Distro: "rockylinux", Release: "10", Arch: "amd64", Variant: "cloud",
Description: "Rocky Linux 10",
},
}
}
// FindTemplate finds a template by ID
func FindTemplate(id string) *Template {
templates := GetTemplates()
for _, t := range templates {
if t.ID == id {
return &t
}
}
return nil
}
+19
View File
@@ -0,0 +1,19 @@
package server
import (
"embed"
"io/fs"
"net/http"
)
//go:embed web/**
var embeddedWeb embed.FS
// GetEmbeddedFS returns the embedded frontend file system
func GetEmbeddedFS() http.FileSystem {
sub, err := fs.Sub(embeddedWeb, "web")
if err != nil {
return http.Dir("web")
}
return http.FS(sub)
}
+138
View File
@@ -0,0 +1,138 @@
package server
import (
"fmt"
"log"
"net/http"
"strings"
"time"
"clicd/internal/api"
"clicd/internal/config"
"clicd/internal/lxc"
)
// webFS holds embedded frontend files
var webFS http.FileSystem
// corsMiddleware adds CORS headers
func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Allow-Credentials", "true")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next(w, r)
}
}
// setupRoutes configures API and static routes
func setupRoutes(mux *http.ServeMux) {
// API routes
mux.HandleFunc("/api/login", corsMiddleware(api.HandleLogin))
mux.HandleFunc("/api/check-auth", corsMiddleware(api.AuthMiddleware(api.HandleCheckAuth)))
mux.HandleFunc("/api/change-password", corsMiddleware(api.AdminMiddleware(api.HandleAdminPasswordChange)))
mux.HandleFunc("/api/change-username", corsMiddleware(api.AdminMiddleware(api.HandleAdminUsernameChange)))
mux.HandleFunc("/api/login-logs", corsMiddleware(api.AdminMiddleware(api.HandleLoginLogs)))
mux.HandleFunc("/api/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers))))
mux.HandleFunc("/api/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
mux.HandleFunc("/api/templates", corsMiddleware(api.AuthMiddleware(api.HandleTemplates)))
mux.HandleFunc("/api/images", corsMiddleware(api.AdminMiddleware(api.HandleImages)))
mux.HandleFunc("/api/images/download", corsMiddleware(api.AdminMiddleware(api.HandleImageDownload)))
mux.HandleFunc("/api/images/delete", corsMiddleware(api.AdminMiddleware(api.HandleImageDelete)))
mux.HandleFunc("/api/images/toggle", corsMiddleware(api.AdminMiddleware(api.HandleImageToggle)))
mux.HandleFunc("/api/images/enabled", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleEnabledImages))))
mux.HandleFunc("/api/dashboard", corsMiddleware(api.AdminMiddleware(api.HandleDashboard)))
mux.HandleFunc("/api/host-info", corsMiddleware(api.AdminMiddleware(api.HandleHostInfo)))
mux.HandleFunc("/api/ipv6/status", corsMiddleware(api.AdminMiddleware(api.HandleIPv6Status)))
mux.HandleFunc("/api/oversell", corsMiddleware(api.AdminMiddleware(api.HandleOversell)))
mux.HandleFunc("/api/oversell/status", corsMiddleware(api.AdminMiddleware(api.HandleOversellStatus)))
mux.HandleFunc("/api/oversell/reclaim", corsMiddleware(api.AdminMiddleware(api.HandleOversellReclaim)))
mux.HandleFunc("/api/tasks", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleTasks))))
mux.HandleFunc("/api/tasks/", corsMiddleware(api.AuthMiddleware(api.AdminMiddleware(api.HandleTaskDelete))))
mux.HandleFunc("/api/batch-create", corsMiddleware(api.AdminMiddleware(api.HandleBatchCreate)))
mux.HandleFunc("/api/batch-action", corsMiddleware(api.AdminMiddleware(api.HandleBatchAction)))
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/access", corsMiddleware(api.HandleSubUserAccessCode))
mux.HandleFunc("/api/audit-logs", corsMiddleware(api.AdminMiddleware(api.HandleAuditLogs)))
mux.HandleFunc("/api/security/alerts", corsMiddleware(api.AdminMiddleware(api.HandleSecurityAlerts)))
mux.HandleFunc("/api/security/check", corsMiddleware(api.AdminMiddleware(api.HandleSecurityCheck)))
mux.HandleFunc("/api/security/logs", corsMiddleware(api.AdminMiddleware(api.HandleSecurityLogs)))
mux.HandleFunc("/api/security/summary", corsMiddleware(api.AdminMiddleware(api.HandleContainerSecuritySummary)))
mux.HandleFunc("/api/ssh-ticket", corsMiddleware(api.AuthMiddleware(api.HandleWebSSHTicket)))
mux.HandleFunc("/api/ssh", api.HandleWebSSH) // WebSocket
// API Key management
mux.HandleFunc("/api/api-keys", corsMiddleware(api.AdminMiddleware(api.HandleApiKeys)))
mux.HandleFunc("/api/api-keys/", corsMiddleware(api.AdminMiddleware(api.HandleApiKeyDelete)))
// Static files
if webFS != nil {
fs := http.FileServer(webFS)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// API routes already handled above
if strings.HasPrefix(r.URL.Path, "/api/") {
http.NotFound(w, r)
return
}
// Try to serve file
path := r.URL.Path
f, err := webFS.Open(path)
if err != nil {
// SPA fallback: serve index.html
indexFile, err := webFS.Open("index.html")
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
defer indexFile.Close()
stat, _ := indexFile.Stat()
http.ServeContent(w, r, "index.html", stat.ModTime(), indexFile)
return
}
defer f.Close()
fs.ServeHTTP(w, r)
})
}
}
// Run starts the HTTP server
func Run() error {
// Use embedded frontend files
webFS = GetEmbeddedFS()
startExpiryMonitor()
mux := http.NewServeMux()
setupRoutes(mux)
addr := fmt.Sprintf("0.0.0.0:%d", config.AppConfig.Port)
log.Printf("CLICD Web Server starting on http://0.0.0.0:%d", config.AppConfig.Port)
log.Printf("Admin user: %s", config.AppConfig.AdminUser)
server := &http.Server{
Addr: addr,
Handler: mux,
}
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
View File
@@ -0,0 +1 @@
+102
View File
@@ -0,0 +1,102 @@
package main
import (
"fmt"
"os"
"os/exec"
"strings"
"clicd/internal/api"
"clicd/internal/cli"
"clicd/internal/config"
"clicd/internal/lxc"
"clicd/internal/server"
"golang.org/x/term"
)
func main() {
isTerminal := term.IsTerminal(int(os.Stdin.Fd()))
isServerMode := false
isCliMode := false
noWebAutostart := false
for _, arg := range os.Args[1:] {
if arg == "server" || arg == "-s" || arg == "--server" {
isServerMode = true
}
if arg == "cli" || arg == "-c" || arg == "--cli" {
isCliMode = true
}
if arg == "--no-web" || arg == "--cli-only" {
noWebAutostart = true
isCliMode = true
}
}
// Initialize config
cfg, err := config.InitConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to initialize config: %v\n", err)
os.Exit(1)
}
_ = cfg
if isServerMode || (!isTerminal && !isCliMode) {
// Restore persisted state
api.RestoreTasks()
api.RestoreLoginLogs()
// Start security scanner
api.InitScanner()
// Ensure iptables FORWARD rules allow LXC traffic
lxc.EnsureForwardRules()
// Start expiry scanner (stops expired containers every 30s)
manager := lxc.NewManager()
manager.StartExpiryScanner()
// Start usage monitor (computes CPU/network/disk rates every 5s)
manager.StartUsageMonitor()
// Clean up stale container configs (LXC dir was deleted but config remains)
config.CleanStaleContainers()
// Pre-warm SSH for containers already running after host boot or service restart.
manager.StartSSHWarmupScanner()
// Run in server mode (frontend embedded in binary)
if err := server.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Server error: %v\n", err)
os.Exit(1)
}
} else {
// CLI mode normally keeps the web panel available. Use --no-web to avoid
// starting the systemd web service on locked-down hosts.
if !noWebAutostart && !isWebPanelSystemdRunning() {
startWebPanelSystemd()
}
// Run CLI interface
cli.Run()
}
}
func isWebPanelSystemdRunning() bool {
cmd := exec.Command("systemctl", "is-active", "clicd")
output, err := cmd.Output()
if err != nil {
return false
}
return strings.TrimSpace(string(output)) == "active"
}
func startWebPanelSystemd() {
cmd := exec.Command("systemctl", "start", "clicd")
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "警告: 自动启动 Web 面板失败: %v\n", err)
} else {
fmt.Println("Web 面板已自动启动")
}
}
+76
View File
@@ -0,0 +1,76 @@
#!/bin/bash
set -e
# CLICD Build Script
# Builds frontend and backend into a single deployable package
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BUILD_DIR="$SCRIPT_DIR/build"
FRONTEND_DIR="$SCRIPT_DIR/frontend"
BACKEND_DIR="$SCRIPT_DIR/backend"
WEB_DIR="$SCRIPT_DIR/web"
EMBED_WEB_DIR="$BACKEND_DIR/internal/server/web"
echo "====================================="
echo " CLICD Build Script"
echo "====================================="
# Clean previous build
rm -rf "$BUILD_DIR"
rm -rf "$WEB_DIR"
rm -rf "$EMBED_WEB_DIR"
mkdir -p "$BUILD_DIR"
mkdir -p "$WEB_DIR"
mkdir -p "$EMBED_WEB_DIR"
touch "$EMBED_WEB_DIR/.gitkeep"
# Step 1: Build frontend
echo ""
echo "[1/3] Building frontend..."
cd "$FRONTEND_DIR"
if [ ! -d "node_modules" ]; then
echo "Installing frontend dependencies..."
npm install
fi
npm run build
# Copy frontend build to web directory (for Go embed)
cp -r dist/* "$WEB_DIR/"
# Keep the Go embed directory in sync with the frontend build.
cp -r dist/* "$EMBED_WEB_DIR/"
touch "$EMBED_WEB_DIR/.gitkeep"
echo "Frontend built successfully"
# Step 2: Build Go backend
echo ""
echo "[2/3] Building Go backend..."
cd "$BACKEND_DIR"
go mod tidy
go mod download
# Build for Linux amd64
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w" -o "$BUILD_DIR/clicd" .
echo "Go backend built successfully"
# Step 3: Package
echo ""
echo "[3/3] Packaging..."
cp -r "$WEB_DIR" "$BUILD_DIR/web"
cp "$SCRIPT_DIR/install.sh" "$BUILD_DIR/install.sh" 2>/dev/null || true
chmod +x "$BUILD_DIR/clicd"
echo ""
echo "====================================="
echo " Build Complete!"
echo "====================================="
echo " Output: $BUILD_DIR/clicd"
echo " Web: $BUILD_DIR/web/"
echo ""
echo " To deploy:"
echo " 1. Copy build/ directory to server"
echo " 2. Run: ./clicd server"
echo "====================================="
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CLICD - LXC Container Manager</title>
</head>
<body class="bg-white text-black">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3023
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "clicd-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"axios": "^1.7.7",
"lucide-react": "^0.454.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.15",
"typescript": "^5.6.3",
"vite": "^5.4.11"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+1
View File
@@ -0,0 +1 @@
<svg t="1780499553554" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4260" width="200" height="200"><path d="M852.9 147.8c4.9 0 9.1 4.2 9.1 9.1v167.8c0 4.9-4.2 9.1-9.1 9.1H171.1c-4.9 0-9.1-4.2-9.1-9.1V156.9c0-4.9 4.2-9.1 9.1-9.1h681.8m0-50H171.1c-32.5 0-59.1 26.6-59.1 59.1v167.8c0 32.5 26.6 59.1 59.1 59.1h681.8c32.5 0 59.1-26.6 59.1-59.1V156.9c0-32.5-26.6-59.1-59.1-59.1z" p-id="4261" fill="#707070"></path><path d="M290.5 214h-60v60h60v-60zM393.5 214h-60v60h60v-60zM806 214H591v60h215v-60zM852.9 417.8c4.9 0 9.1 4.2 9.1 9.1v167.8c0 4.9-4.2 9.1-9.1 9.1H171.1c-4.9 0-9.1-4.2-9.1-9.1V426.9c0-4.9 4.2-9.1 9.1-9.1h681.8m0-50H171.1c-32.5 0-59.1 26.6-59.1 59.1v167.8c0 32.5 26.6 59.1 59.1 59.1h681.8c32.5 0 59.1-26.6 59.1-59.1V426.9c0-32.5-26.6-59.1-59.1-59.1z" p-id="4262" fill="#707070"></path><path d="M290.5 484h-60v60h60v-60zM393.5 484h-60v60h60v-60zM806 484H591v60h215v-60zM852.9 687.8c4.9 0 9.1 4.2 9.1 9.1v167.8c0 4.9-4.2 9.1-9.1 9.1H171.1c-4.9 0-9.1-4.2-9.1-9.1V696.9c0-4.9 4.2-9.1 9.1-9.1h681.8m0-50H171.1c-32.5 0-59.1 26.6-59.1 59.1v167.8c0 32.5 26.6 59.1 59.1 59.1h681.8c32.5 0 59.1-26.6 59.1-59.1V696.9c0-32.5-26.6-59.1-59.1-59.1z" p-id="4263" fill="#707070"></path><path d="M290.5 754h-60v60h60v-60zM393.5 754h-60v60h60v-60zM806 754H591v60h215v-60z" p-id="4264" fill="#707070"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+69
View File
@@ -0,0 +1,69 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuth } from './contexts/AuthContext'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import Containers from './pages/Containers'
import ContainerDetail from './pages/ContainerDetail'
import Oversell from './pages/Oversell'
import Security from './pages/Security'
import AuditLogs from './pages/AuditLogs'
import ApiIntegration from './pages/ApiIntegration'
import Settings from './pages/Settings'
import ImageManagement from './pages/ImageManagement'
import Layout from './components/Layout'
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth()
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-white">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black"></div>
</div>
)
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
return <>{children}</>
}
function HomeRoute() {
const { isSubUser, containerIdentifiers } = useAuth()
if (isSubUser) {
const firstContainer = containerIdentifiers[0]
return <Navigate to={firstContainer ? `/container/${encodeURIComponent(firstContainer)}` : '/containers'} replace />
}
return <Dashboard />
}
function App() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
<Route index element={<HomeRoute />} />
<Route path="containers" element={<Containers />} />
<Route path="images" element={<ImageManagement />} />
<Route path="container/:id" element={<ContainerDetail />} />
<Route path="oversell" element={<Oversell />} />
<Route path="security" element={<Security />} />
<Route path="audit-logs" element={<AuditLogs />} />
<Route path="api-integration" element={<ApiIntegration />} />
<Route path="settings" element={<Settings />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}
export default App
+14
View File
@@ -0,0 +1,14 @@
type AppIconProps = {
className?: string
}
export default function AppIcon({ className = 'w-6 h-6' }: AppIconProps) {
return (
<svg className={className} viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M852.9 147.8c4.9 0 9.1 4.2 9.1 9.1v167.8c0 4.9-4.2 9.1-9.1 9.1H171.1c-4.9 0-9.1-4.2-9.1-9.1V156.9c0-4.9 4.2-9.1 9.1-9.1h681.8m0-50H171.1c-32.5 0-59.1 26.6-59.1 59.1v167.8c0 32.5 26.6 59.1 59.1 59.1h681.8c32.5 0 59.1-26.6 59.1-59.1V156.9c0-32.5-26.6-59.1-59.1-59.1z" fill="#707070" />
<path d="M290.5 214h-60v60h60v-60zM393.5 214h-60v60h60v-60zM806 214H591v60h215v-60zM852.9 417.8c4.9 0 9.1 4.2 9.1 9.1v167.8c0 4.9-4.2 9.1-9.1 9.1H171.1c-4.9 0-9.1-4.2-9.1-9.1V426.9c0-4.9 4.2-9.1 9.1-9.1h681.8m0-50H171.1c-32.5 0-59.1 26.6-59.1 59.1v167.8c0 32.5 26.6 59.1 59.1 59.1h681.8c32.5 0 59.1-26.6 59.1-59.1V426.9c0-32.5-26.6-59.1-59.1-59.1z" fill="#707070" />
<path d="M290.5 484h-60v60h60v-60zM393.5 484h-60v60h60v-60zM806 484H591v60h215v-60zM852.9 687.8c4.9 0 9.1 4.2 9.1 9.1v167.8c0 4.9-4.2 9.1-9.1 9.1H171.1c-4.9 0-9.1-4.2-9.1-9.1V696.9c0-4.9 4.2-9.1 9.1-9.1h681.8m0-50H171.1c-32.5 0-59.1 26.6-59.1 59.1v167.8c0 32.5 26.6 59.1 59.1 59.1h681.8c32.5 0 59.1-26.6 59.1-59.1V696.9c0-32.5-26.6-59.1-59.1-59.1z" fill="#707070" />
<path d="M290.5 754h-60v60h60v-60zM393.5 754h-60v60h60v-60zM806 754H591v60h215v-60z" fill="#707070" />
</svg>
)
}
+142
View File
@@ -0,0 +1,142 @@
import { useNavigate } from 'react-router-dom'
import {
Server,
Cpu,
HardDrive,
MemoryStick,
Globe,
Play,
Square,
RotateCcw,
Trash2,
} from 'lucide-react'
import { Container, startContainer, stopContainer, restartContainer, deleteContainer } from '../services/api'
interface ContainerCardProps {
container: Container
onRefresh: () => void
}
export default function ContainerCard({ container, onRefresh }: ContainerCardProps) {
const navigate = useNavigate()
const containerIdentifier = container.uuid || container.id
const handleAction = async (action: string) => {
try {
switch (action) {
case 'start':
await startContainer(containerIdentifier)
break
case 'stop':
await stopContainer(containerIdentifier)
break
case 'restart':
await restartContainer(containerIdentifier)
break
case 'delete':
if (window.confirm(`确定要删除容器 ${container.name} 吗?此操作不可撤销。`)) {
await deleteContainer(containerIdentifier)
} else {
return
}
break
}
onRefresh()
} catch (err) {
console.error('Action failed:', err)
alert('操作失败')
}
}
const statusColor = container.status === 'running' ? 'bg-green-500' : 'bg-red-500'
const statusText = container.status === 'running' ? '运行中' : '已停止'
return (
<div className="bg-white border border-gray-200 rounded-lg p-5 hover:shadow-md transition-shadow">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center">
<Server className="w-5 h-5 text-gray-700" />
</div>
<div>
<button
onClick={() => navigate(`/container/${encodeURIComponent(String(containerIdentifier))}`)}
className="font-semibold text-black hover:underline text-left"
>
{container.name}
</button>
<div className="flex items-center gap-1.5 mt-0.5">
<span className={`w-1.5 h-1.5 rounded-full ${statusColor}`}></span>
<span className="text-xs text-gray-500">{statusText}</span>
</div>
</div>
</div>
</div>
{/* Specs */}
<div className="grid grid-cols-2 gap-3 mb-4">
<div className="flex items-center gap-2 text-sm text-gray-600">
<Cpu className="w-3.5 h-3.5" />
<span>{container.vcpu} vCPU</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<MemoryStick className="w-3.5 h-3.5" />
<span>{container.ram_mb} MB</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<HardDrive className="w-3.5 h-3.5" />
<span>{container.disk_gb} GB</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<Globe className="w-3.5 h-3.5" />
<span>{container.network_bw_mbps} Mbps</span>
</div>
</div>
{container.ip && (
<div className="text-xs text-gray-400 mb-3">
IP: {container.ip}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-1.5 pt-3 border-t border-gray-100">
{container.status !== 'running' ? (
<button
onClick={() => handleAction('start')}
className="flex items-center gap-1 px-3 py-1.5 bg-green-600 text-white rounded text-xs hover:bg-green-700 transition-colors"
>
<Play className="w-3 h-3" />
</button>
) : (
<>
<button
onClick={() => handleAction('stop')}
className="flex items-center gap-1 px-3 py-1.5 bg-yellow-500 text-white rounded text-xs hover:bg-yellow-600 transition-colors"
>
<Square className="w-3 h-3" />
</button>
<button
onClick={() => handleAction('restart')}
className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 text-white rounded text-xs hover:bg-blue-700 transition-colors"
>
<RotateCcw className="w-3 h-3" />
</button>
</>
)}
<div className="flex-1" />
<button
onClick={() => handleAction('delete')}
className="flex items-center gap-1 px-3 py-1.5 text-red-600 hover:bg-red-50 rounded text-xs transition-colors"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
)
}
@@ -0,0 +1,342 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { CalendarClock, X } from 'lucide-react'
import { batchCreate, getIPv6Status, getEnabledImages, getHostInfo, CreateContainerRequest, HostInfo, IPv6Status, Template } from '../services/api'
import { useDialog } from './Dialog'
interface CreateContainerModalProps {
isOpen: boolean
onClose: () => void
onSuccess: (containers: CreateContainerRequest[]) => void | Promise<void>
}
const defaultForm: CreateContainerRequest = {
name: '',
template_id: '',
vcpu: 1,
cpu_percent: 100,
ram_mb: 512,
disk_gb: 10,
network_bw_mbps: 0,
monthly_traffic_gb: 0,
traffic_mode: 'total',
traffic_in_gb: 0,
traffic_out_gb: 0,
io_speed_mbps: 0,
extra_ports: [],
port_mapping_count: 2,
assign_ipv6: false,
expires_at: '',
}
export default function CreateContainerModal({ isOpen, onClose, onSuccess }: CreateContainerModalProps) {
const dialog = useDialog()
const [templates, setTemplates] = useState<Template[]>([])
const [loading, setLoading] = useState(false)
const [batchCount, setBatchCount] = useState(1)
const [form, setForm] = useState<CreateContainerRequest>(defaultForm)
const [hostInfo, setHostInfo] = useState<HostInfo | null>(null)
const [ipv6Status, setIPv6Status] = useState<IPv6Status | null>(null)
useEffect(() => {
if (!isOpen) return
getEnabledImages()
.then((res) => {
const data = res.data.data || []
setTemplates(data)
if (data.length > 0) {
setForm((prev) => ({ ...prev, template_id: prev.template_id || data[0].id }))
}
})
.catch(console.error)
getIPv6Status()
.then((res) => {
const status = res.data.data || null
setIPv6Status(status)
if (!status?.available) {
setForm((prev) => ({ ...prev, assign_ipv6: false }))
}
})
.catch(() => {
setIPv6Status({ available: false, reachable: false, reason: 'IPv6 status check failed', prefixes: [] })
setForm((prev) => ({ ...prev, assign_ipv6: false }))
})
getHostInfo()
.then((res) => setHostInfo(res.data.data || null))
.catch(() => setHostInfo(null))
}, [isOpen])
const ipv6Available = !!ipv6Status?.available
const ipv6Prefix = ipv6Status?.prefixes?.[0]?.prefix || ''
const maxVCPU = hostInfo?.cpu.cores || 64
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 autoPorts = useMemo(() => {
const count = Math.max(2, form.port_mapping_count)
return Array.from({ length: count - 1 }, (_, index) => 22002 + index)
}, [form.port_mapping_count])
// SSH port preview (will be allocated sequentially, starting around 22000+)
const sshPortPreview = 22000
const handleSubmit = async () => {
if (!form.name || !form.template_id) {
dialog.alert('提示', '请填写容器名称并选择系统模板')
return
}
const boundedForm = clampCreateForm(form, maxVCPU, maxRAMMB, maxDiskGB)
// Build batch of containers
const containers: CreateContainerRequest[] = []
for (let i = 0; i < batchCount; i++) {
const name = batchCount > 1 ? `${boundedForm.name}-${i + 1}` : boundedForm.name
containers.push({ ...boundedForm, name, port_mapping_count: Math.max(2, boundedForm.port_mapping_count || 2), extra_ports: [] })
}
setLoading(true)
try {
await batchCreate(containers)
await onSuccess(containers)
onClose()
setBatchCount(1)
setForm({ ...defaultForm, template_id: templates[0]?.id || '' })
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
dialog.alert('创建失败', error.response?.data?.message || '请稍后重试')
} finally {
setLoading(false)
}
}
if (!isOpen) return null
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg border border-gray-200 shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-black"></h2>
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded text-gray-500" title="关闭">
<X className="w-5 h-5" />
</button>
</div>
<div className="px-6 py-4 space-y-4">
<div className="grid grid-cols-2 gap-4">
<Field label="容器名称">
<input
type="text"
value={form.name}
onChange={(event) => setForm({ ...form, name: event.target.value })}
className={inputClass}
placeholder="my-container"
required
/>
</Field>
<Field label="批量创建数量">
<NumberInput value={batchCount} min={1} max={50} onChange={(value) => setBatchCount(Math.max(1, value || 1))} />
</Field>
</div>
{batchCount > 1 && <p className="text-xs text-gray-400"> {batchCount} {form.name}-1 {form.name}-{batchCount}</p>}
<Field label="系统模板">
{templates.length === 0 ? (
<div className="text-sm text-amber-600 bg-amber-50 border border-amber-200 rounded-md px-3 py-2">
</div>
) : (
<select
value={form.template_id}
onChange={(event) => setForm({ ...form, template_id: event.target.value })}
className={inputClass}
>
{templates.map((template) => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
)}
</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'}`}>
<input
type="checkbox"
checked={!!form.assign_ipv6}
disabled={!ipv6Available}
onChange={(event) => setForm({ ...form, assign_ipv6: event.target.checked })}
className="mt-1"
/>
<span className="min-w-0">
<span className="block font-medium text-gray-800">Public IPv6</span>
<span className="block text-xs text-gray-500 truncate">
{ipv6Available ? `Use ${ipv6Prefix}` : (ipv6Status?.reason || 'Checking IPv6 prefix...')}
</span>
</span>
</label>
<div className="grid grid-cols-2 gap-4">
<Field label="vCPU">
<NumberInput value={form.vcpu} min={0.25} max={maxVCPU} step={0.25} onChange={(value) => setForm({ ...form, vcpu: clampVCPU(value, maxVCPU) })} />
</Field>
<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) })} />
</Field>
</div>
<div className="grid grid-cols-3 gap-3">
<Field label="磁盘 (GB)">
<NumberInput value={form.disk_gb} min={1} max={maxDiskGB} onChange={(value) => setForm({ ...form, disk_gb: clampInt(value, 1, maxDiskGB, 10) })} />
</Field>
<Field label="带宽 (Mbps)">
<NumberInput value={form.network_bw_mbps} min={0} onChange={(value) => setForm({ ...form, network_bw_mbps: value })} />
</Field>
<Field label="IO 速度 (MB/s)">
<NumberInput value={form.io_speed_mbps} min={0} onChange={(value) => setForm({ ...form, io_speed_mbps: value })} />
</Field>
</div>
{/* Traffic control */}
<div>
<div className="flex items-center gap-3 mb-2">
<label className="text-sm font-medium text-gray-700"></label>
<select
value={form.traffic_mode}
onChange={(e) => setForm({ ...form, traffic_mode: e.target.value })}
className="h-8 px-2 border border-gray-300 rounded text-xs text-gray-600 bg-white"
>
<option value="total"></option>
<option value="in_out">/</option>
</select>
</div>
{form.traffic_mode === 'total' ? (
<div className="flex items-center gap-2">
<NumberInput value={form.monthly_traffic_gb} min={0} onChange={(value) => setForm({ ...form, monthly_traffic_gb: value })} />
<span className="text-xs text-gray-400">GB (0=)</span>
</div>
) : (
<div className="grid grid-cols-2 gap-3">
<Field label="入站 (GB)">
<NumberInput value={form.traffic_in_gb} min={0} onChange={(value) => setForm({ ...form, traffic_in_gb: value || 0 })} />
</Field>
<Field label="出站 (GB)">
<NumberInput value={form.traffic_out_gb} min={0} onChange={(value) => setForm({ ...form, traffic_out_gb: value || 0 })} />
</Field>
</div>
)}
</div>
<Field label="NAT 端口映射数量">
<NumberInput
value={form.port_mapping_count}
min={2}
max={64}
onChange={(value) => setForm({ ...form, port_mapping_count: Math.max(2, value || 2) })}
/>
<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">
SSH: {sshPortPreview} -&gt; 22
</span>
{autoPorts.map((port) => (
<span key={port} className="inline-flex px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-mono">
{port} -&gt; {port}
</span>
))}
</div>
</Field>
<Field label="到期时间">
<div className="relative">
<CalendarClock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="date"
value={form.expires_at}
onChange={(event) => setForm({ ...form, expires_at: event.target.value })}
min={new Date().toISOString().slice(0, 10)}
className={`${inputClass} pl-10`}
/>
</div>
<p className="text-xs text-gray-400 mt-1.5"></p>
</Field>
</div>
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-md transition-colors">
</button>
<button
onClick={handleSubmit}
disabled={loading}
className="px-4 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '创建中...' : '创建容器'}
</button>
</div>
</div>
</div>
)
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">{label}</label>
{children}
</div>
)
}
function NumberInput({
value,
min,
max,
step,
onChange,
}: {
value: number
min?: number
max?: number
step?: number
onChange: (value: number) => void
}) {
return (
<input
type="number"
value={value}
min={min}
max={max}
step={step}
onChange={(event) => {
const raw = event.target.value
const value = step && !Number.isInteger(step) ? parseFloat(raw) : parseInt(raw, 10)
onChange(value)
}}
className={inputClass}
/>
)
}
function clampCreateForm(form: CreateContainerRequest, maxVCPU: number, maxRAMMB?: number, maxDiskGB?: number): CreateContainerRequest {
return {
...form,
vcpu: clampVCPU(form.vcpu, maxVCPU),
ram_mb: clampInt(form.ram_mb, 128, maxRAMMB, 512),
disk_gb: clampInt(form.disk_gb, 1, maxDiskGB, 10),
}
}
function clampVCPU(value: number, max: number) {
const rounded = Math.round((Number.isFinite(value) ? value : 1) * 4) / 4
return Number(Math.min(Math.max(rounded, 0.25), max).toFixed(2))
}
function clampInt(value: number, min: number, max?: number, fallback = min) {
const next = Math.round(Number.isFinite(value) ? value : fallback)
return Math.min(Math.max(next, min), max ?? next)
}
const inputClass =
'w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white focus:outline-none focus:ring-2 focus:ring-black focus:border-black'
+94
View File
@@ -0,0 +1,94 @@
import { useState, useCallback, createContext, useContext, ReactNode } from 'react'
import { AlertTriangle, CheckCircle, X } from 'lucide-react'
type DialogType = 'confirm' | 'alert'
interface DialogState {
open: boolean
type: DialogType
title: string
message: string
resolve?: (value: boolean) => void
}
interface DialogContextType {
confirm: (title: string, message: string) => Promise<boolean>
alert: (title: string, message: string) => Promise<void>
}
const DialogContext = createContext<DialogContextType | undefined>(undefined)
export function DialogProvider({ children }: { children: ReactNode }) {
const [dialog, setDialog] = useState<DialogState>({ open: false, type: 'alert', title: '', message: '' })
const confirm = useCallback((title: string, message: string) => {
return new Promise<boolean>((resolve) => {
setDialog({ open: true, type: 'confirm', title, message, resolve })
})
}, [])
const alert = useCallback((title: string, message: string) => {
return new Promise<void>((resolve) => {
setDialog({ open: true, type: 'alert', title, message, resolve: () => resolve() })
})
}, [])
const close = (result: boolean) => {
dialog.resolve?.(result)
setDialog({ open: false, type: 'alert', title: '', message: '' })
}
return (
<DialogContext.Provider value={{ confirm, alert }}>
{children}
{dialog.open && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4">
<div className="bg-white rounded-lg shadow-xl border border-gray-200 w-full max-w-sm overflow-hidden">
<div className="flex items-center gap-3 px-5 py-4 border-b border-gray-100">
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
dialog.type === 'confirm' ? 'bg-amber-50 text-amber-600' : 'bg-gray-100 text-gray-600'
}`}>
{dialog.type === 'confirm' ? <AlertTriangle className="w-4 h-4" /> : <CheckCircle className="w-4 h-4" />}
</div>
<h3 className="text-sm font-semibold text-black flex-1">{dialog.title}</h3>
{dialog.type === 'alert' && (
<button onClick={() => close(true)} className="p-1 text-gray-400 hover:text-black rounded">
<X className="w-4 h-4" />
</button>
)}
</div>
<div className="px-5 py-4">
<p className="text-sm text-gray-600">{dialog.message}</p>
</div>
<div className="flex justify-end gap-2 px-5 py-3 bg-gray-50 border-t border-gray-100">
{dialog.type === 'confirm' && (
<button
onClick={() => close(false)}
className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-200 rounded-md transition-colors"
>
</button>
)}
<button
onClick={() => close(true)}
className={`px-4 py-2 text-sm rounded-md transition-colors ${
dialog.type === 'confirm'
? 'bg-black text-white hover:bg-gray-800'
: 'bg-black text-white hover:bg-gray-800'
}`}
>
{dialog.type === 'confirm' ? '确认' : '确定'}
</button>
</div>
</div>
</div>
)}
</DialogContext.Provider>
)
}
export function useDialog() {
const ctx = useContext(DialogContext)
if (!ctx) throw new Error('useDialog must be used within DialogProvider')
return ctx
}
+18
View File
@@ -0,0 +1,18 @@
import { Outlet } from 'react-router-dom'
import Sidebar from './Sidebar'
import { useState } from 'react'
export default function Layout() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
return (
<div className="min-h-screen bg-gray-50 flex">
<Sidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed(!sidebarCollapsed)} />
<main className={`flex-1 transition-all duration-300 ${sidebarCollapsed ? 'ml-16' : 'ml-60'}`}>
<div className="p-6">
<Outlet />
</div>
</main>
</div>
)
}
@@ -0,0 +1,224 @@
import { ReactNode } from 'react'
import { RefreshCw } from 'lucide-react'
export type StatsRangeKey = '30m' | '1h' | '1d' | '1w'
export type ChartPoint = {
ts: number
value: number
}
export type ResourceChartConfig = {
title: string
icon: ReactNode
points: ChartPoint[]
current: number
detail?: string
max?: number
unitLabel?: string
formatValue: (value: number) => string
}
const rangeLabels: Record<StatsRangeKey, string> = {
'30m': '30分钟',
'1h': '1小时',
'1d': '1天',
'1w': '1周',
}
export const statsRanges: Record<StatsRangeKey, number> = {
'30m': 30 * 60 * 1000,
'1h': 60 * 60 * 1000,
'1d': 24 * 60 * 60 * 1000,
'1w': 7 * 24 * 60 * 60 * 1000,
}
export default function ResourceStatsPanel({
range,
onRangeChange,
onRefresh,
charts,
}: {
range: StatsRangeKey
onRangeChange: (range: StatsRangeKey) => void
onRefresh: () => void
charts: ResourceChartConfig[]
}) {
return (
<section className="border border-gray-200 rounded-lg bg-white overflow-hidden">
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b border-gray-200 bg-white">
<h2 className="text-sm font-semibold text-gray-950"></h2>
<div className="flex items-center gap-1.5">
<div className="inline-flex rounded border border-gray-200 bg-gray-50 p-0.5">
{(Object.keys(rangeLabels) as StatsRangeKey[]).map((item) => (
<button
key={item}
onClick={() => onRangeChange(item)}
className={`h-7 px-3 rounded text-xs font-medium transition-colors ${
range === item ? 'bg-gray-800 text-white shadow-sm' : 'text-gray-500 hover:text-gray-900'
}`}
>
{rangeLabels[item]}
</button>
))}
</div>
<button
onClick={onRefresh}
className="h-8 w-8 inline-flex items-center justify-center rounded border border-gray-200 text-gray-500 hover:bg-gray-50 hover:text-gray-900"
title="刷新"
>
<RefreshCw className="w-4 h-4" />
</button>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2">
{charts.map((chart, index) => (
<DetailedChart key={chart.title} chart={chart} className={chartBorderClass(index)} />
))}
</div>
</section>
)
}
function DetailedChart({ chart, className }: { chart: ResourceChartConfig; className: string }) {
const values = chart.points.map((point) => point.value)
const avg = values.length > 0 ? values.reduce((sum, value) => sum + value, 0) / values.length : 0
const peak = values.length > 0 ? Math.max(...values) : 0
return (
<div className={`p-4 ${className}`}>
<div className="flex items-start justify-between gap-3 mb-2">
<div>
<div className="flex items-center gap-1.5 text-sm font-semibold text-gray-950">
<span className="text-gray-500">{chart.icon}</span>
<span>{chart.title}</span>
</div>
{chart.detail && <p className="mt-0.5 text-[11px] text-gray-400">{chart.detail}</p>}
</div>
<div className="grid grid-cols-3 gap-3 text-right">
<Stat label="当前" value={chart.formatValue(chart.current)} />
<Stat label="平均" value={chart.formatValue(avg)} />
<Stat label="峰值" value={chart.formatValue(peak)} />
</div>
</div>
<LineAreaChart
points={chart.points}
max={chart.max}
formatValue={chart.formatValue}
unitLabel={chart.unitLabel}
/>
</div>
)
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<div className="text-[10px] text-gray-400">{label}</div>
<div className="text-xs font-semibold text-gray-900 tabular-nums whitespace-nowrap">{value}</div>
</div>
)
}
function LineAreaChart({
points,
max,
formatValue,
unitLabel,
}: {
points: ChartPoint[]
max?: number
formatValue: (value: number) => string
unitLabel?: string
}) {
const width = 520
const height = 150
const left = 50
const right = 10
const top = 8
const bottom = 28
const innerWidth = width - left - right
const innerHeight = height - top - bottom
const values = points.length > 0 ? points : [{ ts: Date.now(), value: 0 }]
const maxValue = Math.max(max || 0, ...values.map((point) => point.value), 1)
const minTs = values[0]?.ts || Date.now()
const maxTs = values[values.length - 1]?.ts || minTs + 1
const span = Math.max(maxTs - minTs, 1)
const coords = values.map((point, index) => {
const x = left + ((point.ts - minTs) / span) * innerWidth
const y = top + innerHeight - (point.value / maxValue) * innerHeight
return `${Number.isFinite(x) ? x : left},${Number.isFinite(y) ? y : top + innerHeight}`
})
const fallbackX = left
const fallbackY = top + innerHeight
const line = coords.length > 1 ? coords.join(' ') : `${fallbackX},${fallbackY} ${left + innerWidth},${fallbackY}`
const area = `${left},${top + innerHeight} ${line} ${left + innerWidth},${top + innerHeight}`
const yTicks = [1, 0.5, 0]
const xTicks = [0, 0.5, 1]
return (
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-[140px]" preserveAspectRatio="none">
<defs>
<linearGradient id="resource-chart-fill" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#555" stopOpacity="0.25" />
<stop offset="100%" stopColor="#555" stopOpacity="0.02" />
</linearGradient>
</defs>
{yTicks.map((tick) => {
const y = top + (1 - tick) * innerHeight
return (
<g key={tick}>
<line x1={left} y1={y} x2={left + innerWidth} y2={y} stroke="#e5e7eb" strokeDasharray="3 3" />
<text x={left - 8} y={y + 3} textAnchor="end" fontSize="10" fill="#888">
{formatValue(maxValue * tick)}
</text>
</g>
)
})}
{xTicks.map((tick) => {
const x = left + tick * innerWidth
const ts = minTs + tick * span
return (
<g key={tick}>
<line x1={x} y1={top} x2={x} y2={top + innerHeight} stroke="#edf0f2" strokeDasharray="3 3" />
<text x={x} y={height - 5} textAnchor={tick === 0 ? 'start' : tick === 1 ? 'end' : 'middle'} fontSize="10" fill="#888">
{formatTime(ts)}
</text>
</g>
)
})}
{unitLabel && (
<text x={left - 45} y={top + 10} fontSize="10" fill="#888">
{unitLabel}
</text>
)}
<line x1={left} y1={top} x2={left} y2={top + innerHeight} stroke="#888" />
<line x1={left} y1={top + innerHeight} x2={left + innerWidth} y2={top + innerHeight} stroke="#888" />
<polygon points={area} fill="url(#resource-chart-fill)" />
<polyline points={line} fill="none" stroke="#444" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
function chartBorderClass(index: number) {
const right = index % 2 === 0 ? 'xl:border-r' : ''
const top = index > 1 ? 'border-t' : ''
return `${right} ${top} border-gray-200`
}
function formatTime(ts: number) {
return new Date(ts).toLocaleString('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
}
+132
View File
@@ -0,0 +1,132 @@
import type { ReactNode } from 'react'
interface RingStatProps {
value: number
max?: number
label: string
subLabel?: ReactNode
size?: number
strokeWidth?: number
}
export function RingStat({ value, max = 100, label, subLabel, size = 120, strokeWidth = 8 }: RingStatProps) {
const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI
const percentage = Math.min(Math.max(value / max * 100, 0), 100)
const strokeDashoffset = circumference - (percentage / 100) * circumference
return (
<div className="flex flex-col items-center">
<div className="relative" style={{ width: size, height: size }}>
<svg width={size} height={size} className="transform -rotate-90">
{/* Background ring */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="#f3f4f6"
strokeWidth={strokeWidth}
/>
{/* Progress ring */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="#000000"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
style={{ transition: 'stroke-dashoffset 0.5s ease' }}
/>
</svg>
{/* Center value */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-2xl font-bold text-black">{value.toFixed(percentage < 1 ? 2 : 1)}%</span>
</div>
</div>
<div className="mt-2 text-center">
<div className="text-sm font-medium text-gray-800">{label}</div>
{subLabel && <div className="text-xs text-gray-400 mt-0.5">{subLabel}</div>}
</div>
</div>
)
}
interface RingStatsProps {
cpuPercent: number
cpuCores: number
cpuUsed: number
ramPercent: number
ramUsed: number
ramTotal: number
swapPercent?: number
swapUsed?: number
swapTotal?: number
loadPercent: number
loadStatus: string
diskPercent: number
diskUsed: number
diskTotal: number
}
export default function RingStats({
cpuPercent,
cpuCores,
cpuUsed,
ramPercent,
ramUsed,
ramTotal,
swapPercent = 0,
swapUsed = 0,
swapTotal = 0,
loadPercent,
loadStatus,
diskPercent,
diskUsed,
diskTotal,
}: RingStatsProps) {
const formatGB = (mb: number) => {
if (mb >= 1024) return `${(mb / 1024).toFixed(2)} GB`
return `${mb} MB`
}
const hasSwap = swapTotal > 0
return (
<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 ${hasSwap ? 'grid-cols-5' : 'grid-cols-4'} gap-3`}>
<RingStat
value={cpuPercent}
label="CPU"
subLabel={`(${cpuUsed.toFixed(1)} / ${cpuCores} 核)`}
/>
<RingStat
value={ramPercent}
label="内存"
subLabel={`${formatGB(ramUsed)} / ${formatGB(ramTotal)}`}
/>
{hasSwap && (
<RingStat
value={swapPercent}
label="SWAP"
subLabel={`${formatGB(swapUsed)} / ${formatGB(swapTotal)}`}
/>
)}
<RingStat
value={loadPercent}
label="负载"
subLabel={loadStatus}
/>
<RingStat
value={diskPercent}
label="/"
subLabel={`${formatGB(diskUsed)} / ${formatGB(diskTotal)}`}
/>
</div>
</div>
)
}
+189
View File
@@ -0,0 +1,189 @@
import { useLocation, useNavigate } from 'react-router-dom'
import {
ChevronLeft,
ChevronRight,
Code2,
LayoutDashboard,
LogOut,
Package,
ScrollText,
Server,
Settings2,
ShieldAlert,
UserCog,
} from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import AppIcon from './AppIcon'
interface SidebarProps {
collapsed: boolean
onToggle: () => void
}
export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
const navigate = useNavigate()
const location = useLocation()
const { logout, isSubUser } = useAuth()
const isContainerPage =
location.pathname.startsWith('/containers') ||
location.pathname.startsWith('/container')
const isImagesPage = location.pathname.startsWith('/images')
const isOversellPage = location.pathname.startsWith('/oversell')
const isAuditLogsPage = location.pathname.startsWith('/audit-logs')
const isApiIntegrationPage = location.pathname.startsWith('/api-integration')
const isSecurityPage = location.pathname.startsWith('/security')
const isSettingsPage = location.pathname.startsWith('/settings')
return (
<aside
className={`fixed left-0 top-0 h-full bg-white border-r border-gray-200 flex flex-col transition-all duration-300 z-30 ${
collapsed ? 'w-16' : 'w-60'
}`}
>
<div className="flex items-center justify-between h-14 px-4 border-b border-gray-200">
{!collapsed && (
<div className="flex items-center gap-2">
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center">
<AppIcon className="w-5 h-5" />
</div>
<span className="font-bold text-black text-sm">CLICD</span>
</div>
)}
{collapsed && (
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center mx-auto">
<AppIcon className="w-5 h-5" />
</div>
)}
<button
onClick={onToggle}
className="p-1 rounded hover:bg-gray-100 text-gray-500"
title="切换侧边栏"
>
{collapsed ? (
<ChevronRight className="w-4 h-4" />
) : (
<ChevronLeft className="w-4 h-4" />
)}
</button>
</div>
<nav className="flex-1 py-4 px-2 space-y-1">
{!isSubUser && (
<button
onClick={() => navigate('/')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
location.pathname === '/'
? 'bg-black text-white'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<LayoutDashboard className="w-4 h-4" />
{!collapsed && <span></span>}
</button>
)}
<button
onClick={() => navigate('/containers')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isContainerPage
? 'bg-black text-white'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<Server className="w-4 h-4" />
{!collapsed && <span></span>}
</button>
{!isSubUser && (
<button
onClick={() => navigate('/images')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isImagesPage
? 'bg-black text-white'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<Package className="w-4 h-4" />
{!collapsed && <span></span>}
</button>
)}
{!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'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<Settings2 className="w-4 h-4" />
{!collapsed && <span>宿</span>}
</button>
<button
onClick={() => navigate('/security')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isSecurityPage
? 'bg-black text-white'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<ShieldAlert className="w-4 h-4" />
{!collapsed && <span></span>}
</button>
<button
onClick={() => navigate('/audit-logs')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isAuditLogsPage
? 'bg-black text-white'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<ScrollText className="w-4 h-4" />
{!collapsed && <span></span>}
</button>
<button
onClick={() => navigate('/api-integration')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isApiIntegrationPage
? 'bg-black text-white'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<Code2 className="w-4 h-4" />
{!collapsed && <span>API </span>}
</button>
<button
onClick={() => navigate('/settings')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isSettingsPage
? 'bg-black text-white'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<UserCog className="w-4 h-4" />
{!collapsed && <span></span>}
</button>
</>
)}
</nav>
<div className="border-t border-gray-200 p-2">
<button
onClick={logout}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm text-gray-600 hover:bg-gray-100 transition-colors"
>
<LogOut className="w-4 h-4" />
{!collapsed && <span>退</span>}
</button>
</div>
</aside>
)
}
+220
View File
@@ -0,0 +1,220 @@
import { useEffect, useRef, useState } from 'react'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
import { RefreshCw, TerminalSquare, X } from 'lucide-react'
import { createWebSSHTicket } from '../services/api'
interface WebSSHViewerProps {
containerName: string
onClose: () => void
}
export default function WebSSHViewer({ containerName, onClose }: WebSSHViewerProps) {
const terminalRef = useRef<HTMLDivElement>(null)
const wsRef = useRef<WebSocket | null>(null)
const termRef = useRef<Terminal | null>(null)
const fitRef = useRef<FitAddon | null>(null)
const resizeObserverRef = useRef<ResizeObserver | null>(null)
const [status, setStatus] = useState<'connecting' | 'preparing' | 'connected' | 'disconnected' | 'error'>('connecting')
const [errorMsg, setErrorMsg] = useState('')
const buildWebSSHUrl = (ticket: string) => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const params = new URLSearchParams({
container: containerName,
ticket,
})
return `${protocol}//${window.location.host}/api/ssh?${params.toString()}`
}
const sendResize = () => {
const ws = wsRef.current
const term = termRef.current
if (!ws || !term || ws.readyState !== WebSocket.OPEN) return
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
}
const cleanup = () => {
resizeObserverRef.current?.disconnect()
resizeObserverRef.current = null
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
if (termRef.current) {
termRef.current.dispose()
termRef.current = null
fitRef.current = null
}
}
const connect = async () => {
if (!terminalRef.current) return
cleanup()
setStatus('connecting')
setErrorMsg('')
const term = new Terminal({
cursorBlink: true,
convertEol: true,
fontFamily: 'Consolas, Menlo, Monaco, monospace',
fontSize: 13,
theme: {
background: '#050505',
foreground: '#f3f4f6',
cursor: '#ffffff',
selectionBackground: '#374151',
},
})
const fitAddon = new FitAddon()
term.loadAddon(fitAddon)
term.open(terminalRef.current)
termRef.current = term
fitRef.current = fitAddon
const fitTerminal = () => {
try {
fitAddon.fit()
sendResize()
} catch {
// The modal may report zero size during the first paint. Retry below.
}
}
requestAnimationFrame(() => {
fitTerminal()
window.setTimeout(fitTerminal, 80)
window.setTimeout(fitTerminal, 250)
})
let ticket = ''
try {
const response = await createWebSSHTicket(containerName)
ticket = response.data.data?.ticket || ''
} catch {
setStatus('error')
setErrorMsg('WebSSH ticket 创建失败,请重新登录后再试')
return
}
if (!ticket) {
setStatus('error')
setErrorMsg('WebSSH ticket 为空,请重新登录后再试')
return
}
const ws = new WebSocket(buildWebSSHUrl(ticket))
ws.binaryType = 'arraybuffer'
wsRef.current = ws
term.writeln(`Connecting to ${containerName} as root...`)
ws.onopen = () => {
setStatus('preparing')
term.writeln('\r\nWebSocket connected. Preparing SSH shell...')
sendResize()
term.focus()
}
ws.onmessage = async (event) => {
setStatus('connected')
if (event.data instanceof ArrayBuffer) {
term.write(new Uint8Array(event.data))
return
}
if (event.data instanceof Blob) {
const buffer = await event.data.arrayBuffer()
term.write(new Uint8Array(buffer))
return
}
term.write(String(event.data))
}
ws.onerror = () => {
setStatus('error')
setErrorMsg('WebSSH 连接失败,请确认容器已运行且 SSH 服务可用')
}
ws.onclose = () => {
if (status !== 'error') {
setStatus((current) => current === 'connected' ? 'disconnected' : current)
}
}
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(new TextEncoder().encode(data))
}
})
const observer = new ResizeObserver(() => {
fitTerminal()
})
observer.observe(terminalRef.current)
resizeObserverRef.current = observer
}
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">
<TerminalSquare className="w-4 h-4 text-gray-600" />
<span className="text-sm font-medium text-black">WebSSH - {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 === 'preparing' && (
<span className="text-xs px-1.5 py-0.5 rounded bg-yellow-100 text-yellow-700">SSH preparing...</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={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 bg-black min-h-[500px]">
<div ref={terminalRef} className="absolute inset-0 p-2" />
{status === 'error' && (
<div className="absolute inset-x-0 bottom-0 border-t border-red-900 bg-red-950 px-4 py-2 text-sm text-red-100">
{errorMsg}
</div>
)}
</div>
</div>
)
}
+151
View File
@@ -0,0 +1,151 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import api, { login as apiLogin, checkAuth, LoginResponse } from '../services/api'
interface AuthContextType {
isAuthenticated: boolean
isLoading: boolean
username: string | null
isSubUser: boolean
containerIdentifiers: string[]
login: (username: string, password: string) => Promise<void>
accessCodeLogin: (code: string, password: string) => Promise<void>
logout: () => void
token: string | null
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [username, setUsername] = useState<string | null>(null)
const [isSubUser, setIsSubUser] = useState(false)
const [containerIdentifiers, setContainerIdentifiers] = useState<string[]>([])
const [token, setToken] = useState<string | null>(null)
const navigate = useNavigate()
const saveAuth = (t: string, u: string, sub: boolean, ids: string[]) => {
localStorage.setItem('clicd_token', t)
localStorage.setItem('clicd_username', u)
setToken(t)
setUsername(u)
setIsSubUser(sub)
setContainerIdentifiers(ids)
setIsAuthenticated(true)
}
useEffect(() => {
const savedToken = localStorage.getItem('clicd_token')
const savedUsername = localStorage.getItem('clicd_username')
if (savedToken) {
const payload = decodeTokenPayload(savedToken)
const nextUsername = payload?.username || payload?.sub_user || savedUsername || null
const nextContainerIdentifiers = Array.isArray(payload?.container_uuids) && payload.container_uuids.length > 0
? payload.container_uuids
: Array.isArray(payload?.container_names) ? payload.container_names : []
setToken(savedToken)
setUsername(nextUsername)
setIsSubUser(!!payload?.sub_user)
setContainerIdentifiers(nextContainerIdentifiers)
checkAuth()
.then(() => {
setIsAuthenticated(true)
})
.catch(() => {
localStorage.removeItem('clicd_token')
localStorage.removeItem('clicd_username')
setToken(null)
setUsername(null)
setIsSubUser(false)
setContainerIdentifiers([])
})
.finally(() => setIsLoading(false))
} else {
setIsLoading(false)
}
}, [navigate])
const login = async (user: string, password: string) => {
try {
const response = await apiLogin(user, password)
const data = response.data.data as LoginResponse
saveAuth(data.token, data.username, false, [])
navigate('/')
} catch (adminError) {
try {
const res = await api.post('/sub-user/login', { username: user, password })
const data = res.data.data as { token: string; username: string; container_uuids: string[] }
saveAuth(data.token, data.username, true, data.container_uuids || [])
const first = data.container_uuids?.[0]
navigate(first ? `/container/${encodeURIComponent(first)}` : '/containers')
} catch {
throw adminError
}
}
}
const accessCodeLogin = async (code: string, password: string) => {
const res = await api.post('/sub-user/access', { code, password })
const data = res.data.data as { token: string; username: string; container_uuids: string[] }
saveAuth(data.token, data.username, true, data.container_uuids || [])
const first = data.container_uuids?.[0]
navigate(first ? `/container/${encodeURIComponent(first)}` : '/containers')
}
const logout = () => {
localStorage.removeItem('clicd_token')
localStorage.removeItem('clicd_username')
setToken(null)
setUsername(null)
setIsSubUser(false)
setContainerIdentifiers([])
setIsAuthenticated(false)
navigate('/login')
}
return (
<AuthContext.Provider value={{ isAuthenticated, isLoading, username, isSubUser, containerIdentifiers, login, accessCodeLogin, logout, token }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}
type TokenPayload = {
username?: string
sub_user?: string
container_names?: string[]
container_uuids?: string[]
}
function decodeTokenPayload(token: string): TokenPayload | null {
try {
const payload = token.split('.')[1]
if (!payload) return null
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '=')
const json = decodeURIComponent(
atob(padded)
.split('')
.map((char) => `%${(`00${char.charCodeAt(0).toString(16)}`).slice(-2)}`)
.join('')
)
return JSON.parse(json) as TokenPayload
} catch {
return null
}
}
function subUserTargetPath(containerIdentifiers: string[]) {
const firstContainer = containerIdentifiers[0]
return firstContainer ? `/container/${encodeURIComponent(firstContainer)}` : '/containers'
}
+32
View File
@@ -0,0 +1,32 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #ffffff;
color: #000000;
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
+19
View File
@@ -0,0 +1,19 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import { AuthProvider } from './contexts/AuthContext'
import { DialogProvider } from './components/Dialog'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<DialogProvider>
<App />
</DialogProvider>
</AuthProvider>
</BrowserRouter>
</React.StrictMode>,
)
+306
View File
@@ -0,0 +1,306 @@
import { useState, useEffect, useCallback } from 'react'
import { Key, Plus, Trash2, Copy, RefreshCw, Code, X } from 'lucide-react'
import api, { APIResponse } from '../services/api'
interface ApiKeyItem {
id: string
name: string
key?: string
prefix: string
ip_whitelist: string
created_at: string
last_used: string
}
const BASE_URL = window.location.origin
export default function ApiIntegration() {
const [keys, setKeys] = useState<ApiKeyItem[]>([])
const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false)
const [newName, setNewName] = useState('')
const [newIPs, setNewIPs] = useState('')
const [creating, setCreating] = useState(false)
const [newKey, setNewKey] = useState('')
const [showDocs, setShowDocs] = useState(true)
const [copiedKey, setCopiedKey] = useState(false)
const fetchKeys = useCallback(async () => {
try {
const res = await api.get<APIResponse<ApiKeyItem[]>>('/api-keys')
setKeys(res.data.data || [])
} catch { /* ignore */ }
finally { setLoading(false) }
}, [])
useEffect(() => { fetchKeys() }, [fetchKeys])
const createKey = async () => {
if (!newName.trim()) return
setCreating(true)
try {
const res = await api.post<APIResponse<ApiKeyItem>>('/api-keys', {
name: newName.trim(),
ip_whitelist: newIPs.trim(),
})
if (res.data.data?.key) {
setNewKey(res.data.data.key)
setKeys(prev => [res.data.data!, ...prev])
}
setNewName('')
setNewIPs('')
setShowCreate(false)
} catch { /* ignore */ }
finally { setCreating(false) }
}
const deleteKey = async (id: string) => {
if (!window.confirm('确定要删除此 API Key 吗?')) return
try {
await api.delete(`/api-keys/${id}`)
setKeys(prev => prev.filter(k => k.id !== id))
} catch { /* ignore */ }
}
const copyKey = () => {
try {
navigator.clipboard.writeText(newKey)
} catch {
const ta = document.createElement('textarea')
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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-black">API </h1>
<p className="text-sm text-gray-500 mt-1"> API Key </p>
</div>
{/* API Keys */}
<div className="bg-white border border-gray-200 rounded-lg p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-black flex items-center gap-2">
<Key className="w-4 h-4" />API Keys
</h2>
<div className="flex items-center gap-2">
<button onClick={fetchKeys} className="p-1.5 text-gray-400 hover:text-black rounded" title="刷新"><RefreshCw className="w-3.5 h-3.5" /></button>
<button onClick={() => setShowCreate(true)} className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black text-white rounded-md text-xs hover:bg-gray-800">
<Plus className="w-3.5 h-3.5" /> Key
</button>
</div>
</div>
{newKey && (
<div className="mb-4 p-4 bg-amber-50 border border-amber-200 rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-semibold text-amber-800"> API Key </span>
<button onClick={() => setNewKey('')} className="text-amber-600 hover:text-amber-800 text-xs"></button>
</div>
<p className="text-xs text-amber-700 mb-2"> Key </p>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 bg-white border border-amber-300 rounded text-xs font-mono text-gray-800 break-all">{newKey}</code>
<button onClick={copyKey} className="px-3 py-2 bg-amber-600 text-white rounded-md text-xs hover:bg-amber-700 whitespace-nowrap">
{copiedKey ? '已复制' : '复制'}
</button>
</div>
</div>
)}
{loading ? (
<div className="py-8 text-center text-sm text-gray-400">...</div>
) : keys.length === 0 ? (
<div className="py-8 text-center text-sm text-gray-400"> API Key"创建 Key"</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 text-left text-xs font-medium text-gray-500">
<th className="px-3 py-2"></th>
<th className="px-3 py-2">Key </th>
<th className="px-3 py-2">IP </th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2">使</th>
<th className="px-3 py-2 text-right"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{keys.map(k => (
<tr key={k.id} className="hover:bg-gray-50">
<td className="px-3 py-2.5 font-medium text-gray-800">{k.name}</td>
<td className="px-3 py-2.5 font-mono text-xs text-gray-500">{k.prefix}</td>
<td className="px-3 py-2.5 text-xs text-gray-500">{k.ip_whitelist || '不限制'}</td>
<td className="px-3 py-2.5 text-xs text-gray-500">{k.created_at}</td>
<td className="px-3 py-2.5 text-xs text-gray-500">{k.last_used || '未使用'}</td>
<td className="px-3 py-2.5 text-right">
<button onClick={() => deleteKey(k.id)} className="p-1 text-gray-400 hover:text-red-600 rounded" title="删除">
<Trash2 className="w-3.5 h-3.5" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Create Key Modal */}
{showCreate && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/30" onClick={() => setShowCreate(false)} />
<div className="relative bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-semibold text-black"> API Key</h3>
<button onClick={() => setShowCreate(false)} className="p-1 text-gray-400 hover:text-black rounded"><X className="w-4 h-4" /></button>
</div>
<div className="space-y-4">
<div>
<label className="block text-xs text-gray-500 mb-1"></label>
<input
value={newName}
onChange={e => setNewName(e.target.value)}
placeholder="例如:自动化脚本、CI/CD"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
onKeyDown={e => e.key === 'Enter' && createKey()}
autoFocus
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">IP </label>
<textarea
value={newIPs}
onChange={e => setNewIPs(e.target.value)}
placeholder={`1.2.3.4\n10.0.0.0/24`}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm font-mono resize-none"
/>
<p className="text-[10px] text-gray-400 mt-1"> IP CIDR IP</p>
</div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={() => setShowCreate(false)} className="px-4 py-2 text-sm text-gray-600 border border-gray-200 rounded-md hover:bg-gray-50"></button>
<button onClick={createKey} disabled={creating || !newName.trim()} className="px-4 py-2 text-sm bg-black text-white rounded-md hover:bg-gray-800 disabled:opacity-50">
{creating ? '创建中...' : '创建'}
</button>
</div>
</div>
</div>
</div>
)}
{/* API Documentation */}
<div className="bg-white border border-gray-200 rounded-lg p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-black flex items-center gap-2">
<Code className="w-4 h-4" />API
</h2>
<button onClick={() => setShowDocs(!showDocs)} className="text-xs text-gray-500 hover:text-black">
{showDocs ? '收起' : '展开'}
</button>
</div>
{showDocs && (
<div className="space-y-6 text-sm">
<section>
<h3 className="font-semibold text-black mb-2"></h3>
<p className="text-gray-600 mb-3"> API 使 <strong>POST</strong> API Key</p>
<div className="bg-gray-900 text-gray-100 rounded-lg p-4 font-mono text-xs space-y-2">
<div><span className="text-blue-400">curl</span> -X POST -H <span className="text-green-400">"X-API-Key: clicd_sk_xxxx"</span> {BASE_URL}/api/containers/list</div>
<div className="text-gray-500"># Bearer </div>
<div><span className="text-blue-400">curl</span> -X POST -H <span className="text-green-400">"Authorization: Bearer clicd_sk_xxxx"</span> {BASE_URL}/api/containers/list</div>
</div>
</section>
<section>
<h3 className="font-semibold text-black mb-2"></h3>
<Endpoint method="POST" path="/api/containers/list" desc="获取容器列表" />
<Endpoint method="POST" path="/api/containers/detail" desc="获取容器详情" body='{"id": 1}' />
<Endpoint method="POST" path="/api/containers/create" desc="创建容器" body={`{\n "name": "my-container",\n "template_id": "ubuntu-noble",\n "vcpu": 2,\n "ram_mb": 1024,\n "disk_gb": 20,\n "network_bw_mbps": 100,\n "monthly_traffic_gb": 1000,\n "io_speed_mbps": 500\n}`} />
<Endpoint method="POST" path="/api/containers/start" desc="启动容器" body='{"id": 1}' />
<Endpoint method="POST" path="/api/containers/stop" desc="停止容器" body='{"id": 1}' />
<Endpoint method="POST" path="/api/containers/restart" desc="重启容器" body='{"id": 1}' />
<Endpoint method="POST" path="/api/containers/delete" desc="删除容器" body='{"id": 1}' />
<Endpoint method="POST" path="/api/containers/reinstall" desc="重装系统" body='{"id": 1, "template_id": "debian-bookworm"}' />
<Endpoint method="POST" path="/api/containers/usage" desc="获取资源用量" body='{"id": 1}' />
<Endpoint method="POST" path="/api/containers/traffic" desc="获取流量统计" body='{"id": 1}' />
<Endpoint method="POST" path="/api/containers/traffic-reset" desc="重置流量" body='{"id": 1}' />
<Endpoint method="POST" path="/api/containers/traffic-limit" desc="修改流量限制" body='{"id": 1, "traffic_mode": "total", "monthly_traffic_gb": 1000}' />
<Endpoint method="POST" path="/api/containers/resource-limit" desc="修改资源限制" body='{"id": 1, "vcpu": 2, "ram_mb": 2048, "io_speed_mbps": 500, "network_bw_mbps": 100}' />
<Endpoint method="POST" path="/api/containers/expiry" desc="修改到期时间" body='{"id": 1, "expires_at": "2026-12-31 23:59:59"}' />
<Endpoint method="POST" path="/api/containers/reset-password" desc="重置 SSH 密码" body='{"id": 1}' />
</section>
<section>
<h3 className="font-semibold text-black mb-2"></h3>
<Endpoint method="POST" path="/api/containers/port-mappings/add" desc="添加映射" body='{"id": 1, "container_port": 8080, "host_port": 8080, "protocol": "tcp", "description": "Web"}' />
<Endpoint method="POST" path="/api/containers/port-mappings/update" desc="更新映射" body='{"id": 1, "index": 0, "container_port": 8080, "host_port": 9090, "protocol": "tcp", "description": "API"}' />
<Endpoint method="POST" path="/api/containers/port-mappings/delete" desc="删除映射" body='{"id": 1, "index": 0}' />
<Endpoint method="POST" path="/api/containers/random-port" desc="获取随机空闲端口" body='{"id": 1}' />
</section>
<section>
<h3 className="font-semibold text-black mb-2"> & </h3>
<Endpoint method="POST" path="/api/dashboard" desc="容器统计概览" />
<Endpoint method="POST" path="/api/host-info" desc="宿主机资源信息" />
<Endpoint method="POST" path="/api/templates" desc="可用系统模板列表" />
<Endpoint method="POST" path="/api/tasks" desc="任务队列" />
<Endpoint method="POST" path="/api/tasks/delete" desc="删除任务" body='{"id": "task-1"}' />
</section>
<section>
<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-action" desc="批量操作" body='{"action": "start", "containers": [1, 2, 3]}' />
</section>
<section>
<h3 className="font-semibold text-black mb-2"> & </h3>
<Endpoint method="POST" path="/api/sub-user/create" desc="创建管理链接" body='{"container_name": "my-container"}' />
<Endpoint method="POST" path="/api/audit-logs" desc="操作日志" />
<Endpoint method="POST" path="/api/login-logs" desc="登录日志" />
<Endpoint method="POST" path="/api/security/alerts" desc="安全告警" />
</section>
<section>
<h3 className="font-semibold text-black mb-2"></h3>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 font-mono text-xs text-gray-700">
{`{
"success": true,
"message": "操作成功",
"data": { ... }
}`}
</div>
</section>
</div>
)}
</div>
</div>
)
}
function Endpoint({ method, path, desc, body }: { method: string; path: string; desc: string; body?: string }) {
return (
<div className="flex items-start gap-3 py-2 border-b border-gray-50">
<span className="shrink-0 px-1.5 py-0.5 rounded border text-[10px] font-mono font-bold bg-blue-50 text-blue-700 border-blue-200">{method}</span>
<code className="shrink-0 text-xs text-gray-800 font-mono">{path}</code>
<span className="text-xs text-gray-500 min-w-0">{desc}</span>
{body && (
<details className="text-xs">
<summary className="text-gray-400 cursor-pointer hover:text-gray-600">Body</summary>
<pre className="mt-1 p-2 bg-gray-50 rounded text-xs text-gray-600 overflow-x-auto">{body}</pre>
</details>
)}
</div>
)
}
+120
View File
@@ -0,0 +1,120 @@
import { useCallback, useEffect, useState } from 'react'
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, RefreshCw } from 'lucide-react'
import { AuditLog, getAuditLogs } from '../services/api'
import { actionLabel } from '../utils/labels'
const PAGE_SIZE = 10
export default function AuditLogs() {
const [logs, setLogs] = useState<AuditLog[]>([])
const [loading, setLoading] = useState(true)
const [page, setPage] = useState(1)
const fetchData = useCallback(async () => {
try {
const res = await getAuditLogs()
setLogs(res.data.data || [])
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
const timer = window.setInterval(fetchData, 10000)
return () => window.clearInterval(timer)
}, [fetchData])
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>
)
}
const totalPages = Math.max(1, Math.ceil(logs.length / PAGE_SIZE))
const pageLogs = logs.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
return (
<div className="space-y-4">
<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"> {logs.length} </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="bg-white border border-gray-200 rounded-lg overflow-hidden">
{logs.length === 0 ? (
<div className="p-8 text-center text-sm text-gray-500"></div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 bg-gray-50 text-left text-xs font-medium text-gray-500">
<th className="px-4 py-2.5 whitespace-nowrap"></th>
<th className="px-4 py-2.5 whitespace-nowrap"></th>
<th className="px-4 py-2.5 whitespace-nowrap"></th>
<th className="px-4 py-2.5 whitespace-nowrap"></th>
<th className="px-4 py-2.5"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{pageLogs.map((log, index) => (
<tr key={`${log.time}-${index}`} className="hover:bg-gray-50">
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 whitespace-nowrap">{log.time}</td>
<td className="px-4 py-2.5 whitespace-nowrap">
{log.user === 'admin' ? (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] font-medium bg-black text-white"></span>
) : log.user?.startsWith('user:') ? (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] font-medium bg-gray-100 text-gray-700"></span>
) : (
<span className="text-xs text-gray-500">{log.user || '-'}</span>
)}
</td>
<td className="px-4 py-2.5 text-gray-800 whitespace-nowrap">{actionLabel(log.action)}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-700 whitespace-nowrap">{log.target || '-'}</td>
<td className="px-4 py-2.5 text-gray-600 min-w-[280px]">{log.detail || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
{logs.length > PAGE_SIZE && (
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-100 bg-gray-50">
<span className="text-xs text-gray-400"> {page}/{totalPages} </span>
<div className="flex items-center gap-1">
<button onClick={() => setPage(1)} disabled={page === 1} className="p-1 text-gray-400 hover:text-black disabled:opacity-20" title="首页"><ChevronsLeft className="w-4 h-4" /></button>
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} className="p-1 text-gray-400 hover:text-black disabled:opacity-20" title="上一页"><ChevronLeft className="w-4 h-4" /></button>
{getPageNumbers(page, totalPages).map(n => (
<button key={n} onClick={() => setPage(n)} className={`w-7 h-7 text-xs rounded ${n === page ? 'bg-black text-white' : 'border border-gray-200 hover:bg-gray-100'}`}>{n}</button>
))}
<button onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page >= totalPages} className="p-1 text-gray-400 hover:text-black disabled:opacity-20" title="下一页"><ChevronRight className="w-4 h-4" /></button>
<button onClick={() => setPage(totalPages)} disabled={page >= totalPages} className="p-1 text-gray-400 hover:text-black disabled:opacity-20" title="末页"><ChevronsRight className="w-4 h-4" /></button>
</div>
</div>
)}
</>
)}
</div>
</div>
)
}
function getPageNumbers(current: number, total: number): number[] {
if (total <= 5) return Array.from({ length: total }, (_, i) => i + 1)
let start = Math.max(1, current - 2)
if (start + 4 > total) start = total - 4
return Array.from({ length: 5 }, (_, i) => start + i)
}
File diff suppressed because it is too large Load Diff
+736
View File
@@ -0,0 +1,736 @@
import { useCallback, useEffect, useState, type ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import {
ArrowDown,
ArrowUp,
Cpu,
Eye,
HardDrive,
MemoryStick,
Network,
Play,
Plus,
RefreshCw,
RotateCcw,
Server,
Square,
Trash2,
ListTodo,
X,
} from 'lucide-react'
import CreateContainerModal from '../components/CreateContainerModal'
import { useAuth } from '../contexts/AuthContext'
import {
Container,
CreateContainerRequest,
ContainerUsage,
getContainerUsage,
getContainers,
batchAction,
Task,
getTasks,
deleteTask,
} from '../services/api'
import { actionLabel, taskStatusClass, taskStatusLabel } from '../utils/labels'
export default function Containers() {
const navigate = useNavigate()
const { isSubUser } = useAuth()
const [containers, setContainers] = useState<Container[]>([])
const [usageByName, setUsageByName] = useState<Record<string, ContainerUsage>>({})
const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false)
const [selected, setSelected] = useState<Set<number>>(new Set())
const [batchLoading, setBatchLoading] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [showTasks, setShowTasks] = useState(false)
const [tasks, setTasks] = useState<Task[]>([])
const [queuedCreates, setQueuedCreates] = useState<Record<string, CreateContainerRequest>>({})
const refreshUsage = useCallback(async (items: Container[]) => {
const targets = items.filter((container) => container.status === 'running')
if (targets.length === 0) {
setUsageByName({})
return
}
const results = await Promise.allSettled(
targets.map(async (container) => {
const res = await getContainerUsage(container.uuid || container.id)
return [container.name, res.data.data] as const
})
)
setUsageByName((current) => {
const next: Record<string, ContainerUsage> = {}
const activeNames = new Set(items.map((container) => container.name))
for (const [name, usage] of Object.entries(current)) {
if (activeNames.has(name)) next[name] = usage
}
for (const result of results) {
if (result.status === 'fulfilled' && result.value[1]) {
next[result.value[0]] = result.value[1]
}
}
return next
})
}, [])
const fetchData = useCallback(async () => {
try {
const res = await getContainers()
const nextContainers = res.data.data || []
setContainers(nextContainers)
await refreshUsage(nextContainers)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}, [refreshUsage])
useEffect(() => {
fetchData()
const interval = window.setInterval(fetchData, 5000)
return () => window.clearInterval(interval)
}, [fetchData])
const toggleSelect = (id: number) => {
setSelected(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
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.
// 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.
const taskStatusMap: Record<number, Task> = {}
const taskNameMap: Record<string, Task> = {}
for (const t of tasks) {
if (t.status === 'pending' || t.status === 'running') {
if (t.container_id != null && t.container_id > 0) {
taskStatusMap[t.container_id] = t
}
if (t.container_name) {
taskNameMap[t.container_name] = t
}
}
}
const handleBatchAction = async (action: string) => {
if (selected.size === 0) return
setBatchLoading(true)
try {
await batchAction(action, [...selected])
setSelected(new Set())
await fetchTasks()
} catch (err) {
console.error(err)
} finally {
setBatchLoading(false)
}
}
const fetchTasks = useCallback(async () => {
try {
const res = await getTasks()
const nextTasks = res.data.data || []
setTasks(nextTasks)
setQueuedCreates((current) => syncQueuedCreates(current, nextTasks, containers))
} catch { /* ignore */ }
}, [containers])
useEffect(() => { fetchTasks(); const t = setInterval(fetchTasks, 2000); return () => clearInterval(t) }, [fetchTasks])
const handleRefreshList = useCallback(async () => {
setRefreshing(true)
try {
await Promise.all([fetchData(), fetchTasks()])
} finally {
setRefreshing(false)
}
}, [fetchData, fetchTasks])
const actionLabels: Record<string, string> = {
create: '正在初始化', start: '开机中', stop: '关机中', restart: '重启中', delete: '删除中', reinstall: '重装中',
}
const displayContainers = buildDisplayContainers(containers, queuedCreates, tasks)
const activeTaskCount = tasks.filter((task) => task.status === 'pending' || task.status === 'running').length
const handleCreateQueued = async (items: CreateContainerRequest[]) => {
setQueuedCreates((current) => {
const next = { ...current }
for (const item of items) {
next[item.name] = item
}
return next
})
fetchTasks()
fetchData()
}
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>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<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>
</div>
<div className="flex items-center 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
onClick={handleRefreshList}
disabled={refreshing}
className="flex items-center gap-1.5 px-3 py-1.5 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 transition-colors text-xs font-medium whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed"
title="刷新列表"
>
<RefreshCw className={`w-3.5 h-3.5 ${refreshing ? 'animate-spin' : ''}`} />
</button>
<button
onClick={() => setShowTasks(true)}
className="flex items-center gap-1.5 px-3 py-1.5 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 transition-colors text-xs font-medium whitespace-nowrap"
>
<ListTodo className="w-3.5 h-3.5" />
{activeTaskCount > 0 && (
<span className="ml-0.5 rounded bg-amber-100 px-1.5 py-0.5 text-[11px] font-medium text-amber-700">
{activeTaskCount}
</span>
)}
</button>
{!isSubUser && (
<button
onClick={() => setShowCreate(true)}
className="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 whitespace-nowrap"
>
<Plus className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{displayContainers.length === 0 ? (
<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">
<Server className="w-8 h-8 text-gray-400" />
</div>
<h3 className="text-lg font-medium text-gray-700 mb-2"></h3>
<p className="text-sm text-gray-500 mb-4">"创建容器"</p>
</div>
) : (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full min-w-[1200px]">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="w-10 px-3 py-3">
{!isSubUser && (
<input
type="checkbox"
checked={displayContainers.length > 0 && selected.size === displayContainers.filter((container) => !container.isPlaceholder && !taskStatusMap[container.id] && !taskNameMap[container.name]).length}
onChange={toggleAll}
className="w-4 h-4 rounded border-gray-300 text-black focus:ring-black accent-black"
/>
)}
</th>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></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><HardDrive className="w-3.5 h-3.5" />DISK</TableHead>
<TableHead icon><Network className="w-3.5 h-3.5" />NET</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead right></TableHead>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{displayContainers.map((container) => {
const isRunning = container.status === 'running'
const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask
const isPlaceholder = !!container.isPlaceholder
const usage = usageByName[container.name]
const cpuPct = isRunning ? clamp(usage?.cpu_usage_pct || 0) : 0
const ramPct = isRunning && container.ram_mb > 0
? clamp(((usage?.memory_usage_bytes || 0) / (container.ram_mb * 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 rx = isRunning ? usage?.network_rx_bps || 0 : 0
const tx = isRunning ? usage?.network_tx_bps || 0 : 0
return (
<tr key={container.isPlaceholder ? `placeholder-${container.name}` : container.id} className="hover:bg-gray-50 transition-colors">
<td className="px-2 py-2 align-top">
{!isSubUser && (
<input
type="checkbox"
checked={selected.has(container.id)}
onChange={() => toggleSelect(container.id)}
disabled={isPlaceholder || !!taskStatusMap[container.id] || !!taskNameMap[container.name]}
className="w-3.5 h-3.5 rounded border-gray-300 text-black focus:ring-black accent-black disabled:opacity-30"
/>
)}
</td>
<td className="px-2.5 py-2 align-top text-xs text-gray-400 font-mono whitespace-nowrap">
#{container.id}
</td>
<td className="px-2.5 py-2 align-top">
<button
onClick={() => navigate(`/container/${encodeURIComponent(container.uuid || String(container.id))}`)}
disabled={isPlaceholder}
className="font-medium text-black hover:underline text-xs disabled:no-underline disabled:text-gray-500 disabled:cursor-not-allowed whitespace-nowrap"
>
{container.name}
</button>
</td>
<td className="px-2.5 py-2 align-top">
<StatusBadge running={isRunning} task={task} placeholder={isPlaceholder} />
</td>
<td className="px-2.5 py-2 align-top text-xs text-gray-600 whitespace-nowrap">
<span className="inline-flex items-center gap-1">
{getTemplateIcon(container.template)}
{getTemplateName(container.template)}
</span>
</td>
<td className="px-2.5 py-2 align-top">
<ProgressCell pct={cpuPct} />
</td>
<td className="px-2.5 py-2 align-top">
<ProgressCell pct={ramPct} />
</td>
<td className="px-2.5 py-2 align-top">
<ProgressCell pct={diskPct} />
</td>
<td className="px-2.5 py-2 align-top">
<div className="space-y-0.5 text-[11px] font-medium tabular-nums min-w-[70px] whitespace-nowrap">
<div className="flex items-center gap-0.5">
<ArrowUp className="w-3 h-3 text-gray-400" />
<span className="text-gray-700">{formatRate(tx)}</span>
</div>
<div className="flex items-center gap-0.5">
<ArrowDown className="w-3 h-3 text-gray-400" />
<span className="text-gray-700">{formatRate(rx)}</span>
</div>
</div>
</td>
<td className="px-2.5 py-2 align-top text-xs text-gray-600 whitespace-nowrap">
{container.vcpu}/{formatRAM(container.ram_mb)}/{container.disk_gb}GB
</td>
<td className="px-2.5 py-2 align-top text-xs whitespace-nowrap">
{container.expires_at ? getRemaining(container.expires_at) : <span className="text-gray-400"></span>}
</td>
<td className="px-2.5 py-2 align-top">
<div className="flex justify-end">
{task?.status === 'failed' ? (
<button
onClick={async () => {
try {
const { default: api } = await import('../services/api')
await api.delete(`/tasks/${task.id}`)
fetchData()
} catch { /* ignore */ }
}}
className="inline-flex items-center gap-1 px-2 py-1 rounded-md border border-red-200 text-[11px] text-red-600 hover:bg-red-50 transition-colors whitespace-nowrap"
>
<Trash2 className="w-3 h-3" />
</button>
) : (
<button
onClick={() => navigate(`/container/${encodeURIComponent(container.uuid || String(container.id))}`)}
disabled={isPlaceholder}
className="inline-flex items-center gap-1 px-2 py-1 rounded-md border border-gray-300 text-[11px] text-gray-700 hover:bg-gray-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
>
<Eye className="w-3 h-3" />
</button>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)}
<CreateContainerModal isOpen={showCreate} onClose={() => setShowCreate(false)} onSuccess={handleCreateQueued} />
{showTasks && (
<TaskQueueModal
tasks={tasks}
onRefresh={fetchTasks}
onClose={() => setShowTasks(false)}
/>
)}
</div>
)
}
function TableHead({ children, right, icon }: { children: ReactNode; right?: boolean; icon?: boolean }) {
return (
<th className={`${right ? 'text-right' : 'text-left'} px-2.5 py-2 text-[11px] font-medium text-gray-500 uppercase whitespace-nowrap`}>
<span className={icon ? 'inline-flex items-center gap-1' : ''}>{children}</span>
</th>
)
}
type DisplayContainer = Container & {
isPlaceholder?: boolean
createTask?: Task
}
function StatusBadge({ running, task, placeholder }: { running: boolean; task?: Task; placeholder?: boolean }) {
const baseClass = "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium whitespace-nowrap"
if (task?.status === 'failed') {
return (
<span className={`${baseClass} bg-red-50 text-red-700`}>
<span className="w-1.5 h-1.5 rounded-full bg-red-500"></span>
</span>
)
}
if (task?.type === 'create' && task.status === 'done') {
return (
<span className={`${baseClass} bg-emerald-50 text-emerald-700`}>
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
</span>
)
}
if (task?.type === 'create' && task.status === 'running') {
return (
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
</span>
)
}
if (placeholder || task?.type === 'create') {
return (
<span className={`${baseClass} bg-gray-100 text-gray-500`}>
<span className="w-1.5 h-1.5 rounded-full bg-gray-400"></span>
</span>
)
}
if (task && task.status !== 'done' && task.status !== 'failed') {
const taskLabels: Record<string, string> = {
start: '开机中', stop: '关机中', restart: '重启中', delete: '删除中', reinstall: '重装中',
}
return (
<span className={`${baseClass} bg-amber-50 text-amber-700`}>
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
{taskLabels[task.type] || '处理中'}
</span>
)
}
return (
<span className={`${baseClass} ${running ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-600'}`}>
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${running ? 'bg-green-500' : 'bg-red-500'}`}></span>
{running ? '在线' : '离线'}
</span>
)
}
function buildDisplayContainers(
containers: Container[],
queuedCreates: Record<string, CreateContainerRequest>,
tasks: Task[]
): DisplayContainer[] {
const realNames = new Set(containers.map((container) => container.name))
const placeholders = new Map<string, DisplayContainer>()
for (const [name, cfg] of Object.entries(queuedCreates)) {
if (!realNames.has(name)) {
placeholders.set(name, toPlaceholder(cfg))
}
}
for (const task of tasks) {
if (task.type !== 'create' || !task.config?.name || realNames.has(task.config.name)) {
continue
}
if (task.status === 'pending' || task.status === 'running' || task.status === 'failed' || placeholders.has(task.config.name)) {
placeholders.set(task.config.name, { ...toPlaceholder(task.config), createTask: task })
}
}
return [...containers, ...placeholders.values()]
}
function toPlaceholder(cfg: CreateContainerRequest): DisplayContainer {
return {
id: 0,
uuid: '',
name: cfg.name,
template: cfg.template_id,
vcpu: cfg.vcpu,
ram_mb: cfg.ram_mb,
disk_gb: cfg.disk_gb,
network_bw_mbps: cfg.network_bw_mbps,
monthly_traffic_gb: cfg.monthly_traffic_gb,
traffic_mode: cfg.traffic_mode || 'total',
traffic_in_gb: cfg.traffic_in_gb || 0,
traffic_out_gb: cfg.traffic_out_gb || 0,
traffic_used_rx: 0,
traffic_used_tx: 0,
traffic_reset_date: '',
io_speed_mbps: cfg.io_speed_mbps,
status: 'creating',
ip: '',
ipv6: '',
ipv6_prefix_len: 0,
ipv6_interface: '',
vnc_port: 0,
ssh_port: 0,
ssh_password: '',
port_mappings: [],
port_mapping_limit: 2,
created_at: '',
expires_at: cfg.expires_at,
isPlaceholder: true,
}
}
function syncQueuedCreates(
current: Record<string, CreateContainerRequest>,
tasks: Task[],
containers: Container[]
): Record<string, CreateContainerRequest> {
const realNames = new Set(containers.map((container) => container.name))
const activeOrFailedCreateNames = new Set(
tasks
.filter((task) => task.type === 'create' && (task.status === 'pending' || task.status === 'running' || task.status === 'failed' || task.status === 'done'))
.map((task) => task.config?.name || task.container_name)
)
const next: Record<string, CreateContainerRequest> = {}
for (const [name, cfg] of Object.entries(current)) {
if (!realNames.has(name)) {
next[name] = cfg
}
}
for (const task of tasks) {
if (task.type === 'create' && task.config?.name && !realNames.has(task.config.name)) {
if (task.status === 'pending' || task.status === 'running' || task.status === 'failed') {
next[task.config.name] = task.config
}
}
}
return next
}
function hasActiveTasks(tasks: Task[]) {
return tasks.some((task) => task.status === 'pending' || task.status === 'running')
}
function taskLineLabel(task: Task, actionLabels: Record<string, string>) {
if (task.status === 'failed') return task.type === 'create' ? '初始化失败' : '处理失败'
if (task.type === 'create' && task.status === 'done') return '初始化完成'
return actionLabels[task.type] || '处理中...'
}
function TaskQueueModal({ tasks, onRefresh, onClose }: {
tasks: Task[]
onRefresh: () => void | Promise<void>
onClose: () => void
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="flex max-h-[86vh] w-full max-w-5xl flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl">
<div className="flex items-center justify-between gap-4 border-b border-gray-200 px-5 py-4">
<div>
<h2 className="text-base font-semibold text-black"></h2>
<p className="mt-0.5 text-xs text-gray-500"> {tasks.length} </p>
</div>
<div className="flex items-center gap-2">
<button
onClick={onRefresh}
className="inline-flex items-center gap-2 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<RefreshCw className="h-4 w-4" />
</button>
<button onClick={onClose} className="rounded p-2 text-gray-500 hover:bg-gray-100" title="关闭">
<X className="h-4 w-4" />
</button>
</div>
</div>
{tasks.length === 0 ? (
<div className="p-8 text-center text-sm text-gray-500"></div>
) : (
<div className="overflow-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 bg-gray-50 text-left text-xs font-medium text-gray-500">
<th className="whitespace-nowrap px-4 py-2.5"></th>
<th className="whitespace-nowrap px-4 py-2.5"></th>
<th className="whitespace-nowrap px-4 py-2.5"></th>
<th className="whitespace-nowrap px-4 py-2.5"></th>
<th className="px-4 py-2.5"></th>
<th className="whitespace-nowrap px-4 py-2.5 w-10"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{tasks.map((task) => (
<tr key={task.id} className="hover:bg-gray-50">
<td className="whitespace-nowrap px-4 py-2.5">
<span className={`rounded px-1.5 py-0.5 text-xs font-medium ${taskStatusClass(task.status)}`}>
{taskStatusLabel(task.status)}
</span>
</td>
<td className="whitespace-nowrap px-4 py-2.5 text-gray-800">{actionLabel(task.type)}</td>
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-xs text-gray-700">{task.container_name}</td>
<td className="whitespace-nowrap px-4 py-2.5 font-mono text-xs text-gray-500">{task.created_at}</td>
<td className="min-w-[260px] px-4 py-2.5 text-gray-600">{task.error || '-'}</td>
<td className="whitespace-nowrap px-2 py-2.5">
{task.status === 'pending' && (
<button
onClick={async () => {
try {
await deleteTask(task.id)
onRefresh()
} catch { /* ignore */ }
}}
className="p-1 rounded hover:bg-red-50 text-gray-400 hover:text-red-600 transition-colors"
title="取消任务"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}
function ProgressCell({ pct }: { pct: number }) {
return (
<div className="flex items-center gap-2 min-w-[100px]">
<span className="w-10 text-xs font-medium tabular-nums text-gray-700">{pct.toFixed(1)}%</span>
<div className="h-1.5 flex-1 rounded-full bg-gray-100 overflow-hidden">
<div
className="h-full bg-gray-500 transition-all duration-500"
style={{ width: `${Math.max(pct, pct > 0 ? 2 : 0)}%` }}
/>
</div>
</div>
)
}
function getTemplateName(id: string) {
const map: Record<string, string> = {
'ubuntu-noble': 'Ubuntu 24.04',
'ubuntu-jammy': 'Ubuntu 22.04',
'debian-bookworm': 'Debian 12',
'debian-bullseye': 'Debian 11',
'alpine-3.21': 'Alpine 3.21',
'centos-9-stream': 'CentOS 9',
'archlinux-current': 'Arch Linux',
'fedora-44': 'Fedora 44',
'rockylinux-10': 'Rocky 10',
}
return map[id] || id
}
function getTemplateIcon(id: string): ReactNode {
const size = 'w-4 h-4'
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('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('centos')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M153.650377 358.349623v112.005247h-3.694326v-108.310921l3.694326-3.694326z" fill="#932279"/><path d="M453.058708 512l-29.554608 29.554608H137.86553v108.310922L0 512l137.86553-137.86553v108.310922h285.63857l29.554608 29.554608zM738.529354 226.529354L553.64513 411.413578V149.956051h108.310921l3.694326 3.694326 72.878977 72.878977z" fill="#932279"/><path d="M649.86553 137.86553h-108.310922v285.63857l-29.554608 29.554608-29.554608-29.554608V137.86553h-108.310922L512 0l137.86553 137.86553zM874.043949 553.64513v108.310921l-3.694326 3.694326-72.878977 72.878977-184.884224-184.884224h261.457527z" fill="#EFA724"/><path d="M886.13447 361.036405v13.098065l-6.04526-6.04526-6.045261-6.045261v108.310921h-3.694326v-125.103312l3.694326 3.694326 6.045261 6.045261 6.04526 6.04526z" fill="#262577"/><path d="M886.13447 649.86553v-108.310922H600.4959L570.941292 512l29.554608-29.554608h285.63857v-108.310922l137.86553 137.86553-137.86553 137.86553z" fill="#262577"/><path d="M411.413578 470.35487H149.956051v-108.310921l3.694326-3.694326L226.529354 285.470646 411.413578 470.35487zM470.35487 149.956051V411.413578L285.470646 226.529354l72.878977-72.878977 3.694326-3.694326h108.310921z" fill="#9CCD2A"/><path d="M738.529354 797.470646L553.64513 612.586422v261.457527h108.310921l3.694326-3.694326 72.878977-72.878977z" fill="#EFA724"/><path d="M649.86553 886.13447h-108.310922V600.4959L512 570.941292l-29.554608 29.554608v285.63857h-108.310922l137.86553 137.86553 137.86553-137.86553z" fill="#9CCD2A"/><path d="M470.35487 874.043949V612.586422L285.470646 797.470646l72.878977 72.878977 3.694326 3.694326h108.310921z" fill="#262577"/><path d="M470.35487 428.541817v41.813053h-41.813053L226.529354 268.342407l-76.573303 76.573303V149.956051h194.959659l-76.573303 76.573303 202.012463 202.012463z" fill="#9CCD2A"/><path d="M880.08921 143.91079v224.17842l-6.045261-6.045261v108.310921H612.586422L797.470646 285.470646l72.878977 72.878977v-13.098065l3.694326 3.694326v-4.030174l-76.573303-76.573303-202.012463 202.012463h-41.813053v-41.813053L755.657593 226.529354l-82.618564-82.618564h207.050181z" fill="#932279"/><path d="M666.993768 137.86553l12.090522 12.090521h194.959659v212.087898l6.045261 6.045261 6.04526 6.04526V137.86553z" fill="#FFF"/><path d="M874.043949 679.08429v194.959659H679.08429L755.657593 797.470646 553.64513 595.458183v-41.813053h41.813053L797.470646 755.657593l76.573303-76.573303z" fill="#EFA724"/><path d="M411.413578 553.64513L226.529354 738.529354l-72.878977-72.878977-3.694326-3.694326v-108.310921H411.413578z" fill="#262577"/><path d="M470.35487 595.458183L268.342407 797.470646l76.573303 76.573303H149.956051V679.08429L226.529354 755.657593l202.012463-202.012463h41.813053v41.813053z" fill="#262577"/><path d="M874.043949 344.91571v4.030174l-3.694326-3.694326v13.098065l3.694326 3.694326 6.045261 6.045261 6.04526 6.04526v-16.792391z" fill="#FFF"/></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('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>
return null
}
function clamp(value: number) {
if (!Number.isFinite(value)) return 0
return Math.max(0, Math.min(value, 100))
}
function formatRAM(mb: number): string {
if (mb >= 1024) return `${(mb / 1024).toFixed(0)} GB`
return `${mb} MB`
}
function getRemaining(expires: string): ReactNode {
const end = new Date(expires).getTime()
const now = Date.now()
const diff = end - now
if (diff <= 0) return <span className="text-red-600 font-medium"></span>
const days = Math.floor(diff / 86400000)
if (days > 30) return `${Math.floor(days / 30)}个月`
if (days > 0) return `${days}`
const hours = Math.floor(diff / 3600000)
if (hours > 0) return `${hours}小时`
return `${Math.floor(diff / 60000)}分钟`
}
function formatRate(value: number) {
if (value < 1024) return `${value.toFixed(0)} B/s`
if (value < 1024 * 1024) return `${(value / 1024).toFixed(2)} KB/s`
return `${(value / 1024 / 1024).toFixed(2)} MB/s`
}
+220
View File
@@ -0,0 +1,220 @@
import { useCallback, useEffect, useState } from 'react'
import { Cpu, HardDrive, MemoryStick, Network, Server } from 'lucide-react'
import RingStats from '../components/RingStats'
import ResourceStatsPanel, {
ChartPoint,
ResourceChartConfig,
StatsRangeKey,
statsRanges,
} from '../components/ResourceStatsPanel'
import { DashboardStats, getDashboard, getHostInfo, HostInfo } from '../services/api'
type HostMetricPoint = {
ts: number
cpu: number
memory: number
network: number
diskIO: number
}
const hostHistoryKey = 'clicd_host_metric_history_v2'
export default function Dashboard() {
const [stats, setStats] = useState<DashboardStats | null>(null)
const [host, setHost] = useState<HostInfo | null>(null)
const [history, setHistory] = useState<HostMetricPoint[]>(readHostHistory)
const [range, setRange] = useState<StatsRangeKey>('30m')
const [loading, setLoading] = useState(true)
const fetchData = useCallback(async () => {
try {
const [dashRes, hostRes] = await Promise.all([getDashboard(), getHostInfo()])
if (dashRes.data.data) setStats(dashRes.data.data)
if (hostRes.data.data) {
const nextHost = hostRes.data.data
setHost(nextHost)
appendHostPoint(nextHost, setHistory)
}
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
const interval = window.setInterval(fetchData, 5000)
return () => window.clearInterval(interval)
}, [fetchData])
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>
)
}
const filtered = filterHistory(history, range)
const memoryPct = host && host.ram.total_mb > 0 ? (host.ram.used_mb / host.ram.total_mb) * 100 : 0
const networkBps = (host?.network.rx_bps || 0) + (host?.network.tx_bps || 0)
const diskIOBps = (host?.disk_io.read_bps || 0) + (host?.disk_io.write_bps || 0)
const charts: ResourceChartConfig[] = [
{
title: 'CPU 使用率',
icon: <Cpu className="w-5 h-5" />,
current: host?.cpu.usage_pct || 0,
points: toChartPoints(filtered, 'cpu'),
max: 100,
formatValue: formatPercent,
detail: `${host?.cpu.cores || 0}`,
},
{
title: '内存使用',
icon: <MemoryStick className="w-5 h-5" />,
current: memoryPct,
points: toChartPoints(filtered, 'memory'),
max: 100,
formatValue: formatPercent,
detail: `${formatMB(host?.ram.used_mb || 0)} / ${formatMB(host?.ram.total_mb || 0)}`,
},
{
title: '网络流量',
icon: <Network className="w-5 h-5" />,
current: networkBps,
points: toChartPoints(filtered, 'network'),
formatValue: formatRate,
detail: `${formatRate(host?.network.rx_bps || 0)} / 出 ${formatRate(host?.network.tx_bps || 0)}`,
},
{
title: '磁盘IO',
icon: <HardDrive className="w-5 h-5" />,
current: diskIOBps,
points: toChartPoints(filtered, 'diskIO'),
formatValue: formatRate,
detail: `${formatRate(host?.disk_io.read_bps || 0)} / 写 ${formatRate(host?.disk_io.write_bps || 0)}`,
},
]
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-black"></h1>
<p className="text-sm text-gray-500 mt-1">宿</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<SummaryCard icon={<Server className="w-5 h-5" />} title="容器总数" value={stats?.total_containers || 0} />
<SummaryCard dot="bg-green-500" title="运行中" value={stats?.running || 0} />
<SummaryCard dot="bg-red-500" title="已停止" value={stats?.stopped || 0} muted />
</div>
{host && (
<RingStats
cpuPercent={host.cpu.usage_pct}
cpuCores={host.cpu.cores}
cpuUsed={host.cpu.usage_pct * host.cpu.cores / 100}
ramPercent={host.ram.total_mb > 0 ? (host.ram.used_mb / host.ram.total_mb) * 100 : 0}
ramUsed={host.ram.used_mb}
ramTotal={host.ram.total_mb}
loadPercent={Math.min((host.load.load1 / host.cpu.cores) * 100, 100)}
loadStatus={host.load.load1 < host.cpu.cores * 0.7 ? '正常' : host.load.load1 < host.cpu.cores * 1.0 ? '中等' : '高'}
diskPercent={host.disk.total_gb > 0 ? (host.disk.used_gb / host.disk.total_gb) * 100 : 0}
diskUsed={host.disk.used_gb * 1024}
diskTotal={host.disk.total_gb * 1024}
/>
)}
<ResourceStatsPanel range={range} onRangeChange={setRange} onRefresh={fetchData} charts={charts} />
</div>
)
}
function SummaryCard({
icon,
dot,
title,
value,
muted = false,
}: {
icon?: JSX.Element
dot?: string
title: string
value: number
muted?: boolean
}) {
return (
<div className="bg-white border border-gray-200 rounded-lg p-5">
<div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
{icon}
{dot && <span className={`w-2 h-2 rounded-full ${dot}`}></span>}
{title}
</div>
<div className={`text-3xl font-bold ${muted ? 'text-gray-600' : 'text-black'}`}>{value}</div>
</div>
)
}
function appendHostPoint(host: HostInfo, setHistory: (updater: (prev: HostMetricPoint[]) => HostMetricPoint[]) => void) {
const point: HostMetricPoint = {
ts: Date.now(),
cpu: clamp(host.cpu.usage_pct),
memory: host.ram.total_mb > 0 ? clamp((host.ram.used_mb / host.ram.total_mb) * 100) : 0,
network: (host.network.rx_bps || 0) + (host.network.tx_bps || 0),
diskIO: (host.disk_io.read_bps || 0) + (host.disk_io.write_bps || 0),
}
setHistory((prev) => {
const cutoff = Date.now() - statsRanges['1w']
const next = [...prev.filter((item) => item.ts >= cutoff), point]
localStorage.setItem(hostHistoryKey, JSON.stringify(next))
return next
})
}
function readHostHistory(): HostMetricPoint[] {
try {
const raw = localStorage.getItem(hostHistoryKey)
if (!raw) return []
const parsed = JSON.parse(raw) as HostMetricPoint[]
const cutoff = Date.now() - statsRanges['1w']
return parsed.filter((item) => item.ts >= cutoff)
} catch {
return []
}
}
function filterHistory(history: HostMetricPoint[], range: StatsRangeKey) {
const cutoff = Date.now() - statsRanges[range]
return history.filter((point) => point.ts >= cutoff)
}
function toChartPoints<T extends keyof Omit<HostMetricPoint, 'ts'>>(history: HostMetricPoint[], key: T): ChartPoint[] {
return history.map((point) => ({ ts: point.ts, value: Number(point[key]) || 0 }))
}
function clamp(value: number) {
if (!Number.isFinite(value)) return 0
return Math.max(0, Math.min(value, 100))
}
function formatPercent(value: number) {
return `${value.toFixed(1)}%`
}
function formatMB(mb: number) {
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`
return `${Math.round(mb)} MB`
}
function formatBytes(value: number) {
if (value < 1024) return `${value.toFixed(0)} B`
if (value < 1024 * 1024) return `${(value / 1024).toFixed(2)} KB`
return `${(value / 1024 / 1024).toFixed(2)} MB`
}
function formatRate(value: number) {
return `${formatBytes(value)}/s`
}
+283
View File
@@ -0,0 +1,283 @@
import { useCallback, useEffect, useState, type ReactNode } from 'react'
import {
Download,
Trash2,
RefreshCw,
CheckCircle2,
XCircle,
ToggleLeft,
ToggleRight,
Loader2,
AlertCircle,
} from 'lucide-react'
import { getImages, downloadImage, deleteImage, toggleImage, ImageInfo } from '../services/api'
export default function ImageManagement() {
const [images, setImages] = useState<ImageInfo[]>([])
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [error, setError] = useState('')
const fetchImages = useCallback(async () => {
try {
const res = await getImages()
setImages(res.data.data || [])
setError('')
} catch {
setError('获取镜像列表失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchImages()
const interval = setInterval(fetchImages, 5000)
return () => clearInterval(interval)
}, [fetchImages])
const handleDownload = async (templateId: string) => {
setActionLoading(templateId)
setError('')
try {
await downloadImage(templateId)
await fetchImages()
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '下载失败'
setError(msg)
} finally {
setActionLoading(null)
}
}
const handleDelete = async (templateId: string) => {
if (!window.confirm('确定要删除该镜像缓存吗?删除后需要重新下载才能使用。')) return
setActionLoading(templateId)
setError('')
try {
await deleteImage(templateId)
await fetchImages()
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '删除失败'
setError(msg)
} finally {
setActionLoading(null)
}
}
const handleToggle = async (templateId: string, enabled: boolean) => {
setActionLoading(templateId)
setError('')
try {
await toggleImage(templateId, !enabled)
await fetchImages()
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '操作失败'
setError(msg)
} finally {
setActionLoading(null)
}
}
const downloadedCount = images.filter((img) => img.downloaded).length
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>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-black"></h1>
<p className="text-sm text-gray-500 mt-1">
LXC
{downloadedCount}/{images.length}
</p>
</div>
<button
onClick={fetchImages}
className="flex items-center gap-1.5 px-3 py-1.5 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 transition-colors text-xs font-medium"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
{error && (
<div className="flex items-center gap-2 bg-red-50 border border-red-200 rounded-lg px-4 py-3 text-sm text-red-700">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="text-left px-4 py-3 text-[11px] font-medium text-gray-500 uppercase whitespace-nowrap">
</th>
<th className="text-left px-4 py-3 text-[11px] font-medium text-gray-500 uppercase whitespace-nowrap">
</th>
<th className="text-left px-4 py-3 text-[11px] font-medium text-gray-500 uppercase whitespace-nowrap">
</th>
<th className="text-left px-4 py-3 text-[11px] font-medium text-gray-500 uppercase whitespace-nowrap">
</th>
<th className="text-left px-4 py-3 text-[11px] font-medium text-gray-500 uppercase whitespace-nowrap">
</th>
<th className="text-right px-4 py-3 text-[11px] font-medium text-gray-500 uppercase whitespace-nowrap">
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{images.map((img) => {
const isBusy = actionLoading === img.id
return (
<tr key={img.id} className="hover:bg-gray-50 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<span className="w-8 h-8 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0">
{getTemplateIcon(img.id)}
</span>
<div>
<span className="font-medium text-gray-900 text-sm">{img.name}</span>
<p className="text-[11px] text-gray-400">{img.description}</p>
</div>
</div>
</td>
<td className="px-4 py-3 text-xs text-gray-600 font-mono">
{img.distro} {img.release}
</td>
<td className="px-4 py-3 text-xs text-gray-500 font-mono">
{img.arch}
</td>
<td className="px-4 py-3 text-xs text-gray-600 tabular-nums">
{formatSize(img.size_bytes)}
</td>
<td className="px-4 py-3">
<StatusBadge img={img} />
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
{!img.downloaded && !img.downloading && (
<button
onClick={() => handleDownload(img.id)}
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"
>
{isBusy ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Download className="w-3.5 h-3.5" />
)}
{isBusy ? '下载中...' : '下载'}
</button>
)}
{img.downloading && (
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-amber-50 border border-amber-200 rounded-md text-amber-700 text-xs font-medium">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
...
</span>
)}
{img.downloaded && (
<>
<button
onClick={() => handleToggle(img.id, img.enabled)}
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 ${
img.enabled
? 'bg-emerald-50 text-emerald-700 border border-emerald-200 hover:bg-emerald-100'
: 'bg-gray-50 text-gray-500 border border-gray-200 hover:bg-gray-100'
}`}
>
{img.enabled ? <ToggleRight className="w-3.5 h-3.5" /> : <ToggleLeft className="w-3.5 h-3.5" />}
{img.enabled ? '启用' : '禁用'}
</button>
<button
onClick={() => handleDelete(img.id)}
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"
title="删除镜像缓存"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
</div>
)
}
function StatusBadge({ img }: { img: ImageInfo }) {
if (img.downloading) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-amber-50 text-amber-700">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
</span>
)
}
if (img.downloaded && img.enabled) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-emerald-50 text-emerald-700">
<CheckCircle2 className="w-3 h-3" />
</span>
)
}
if (img.downloaded && !img.enabled) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-gray-100 text-gray-500">
<XCircle className="w-3 h-3" />
</span>
)
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-red-50 text-red-600">
<XCircle className="w-3 h-3" />
</span>
)
}
function getTemplateIcon(id: string): ReactNode {
const size = 'w-5 h-5'
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('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('centos')) return <svg className={size} viewBox="0 0 1024 1024"><path d="M153.650377 358.349623v112.005247h-3.694326v-108.310921l3.694326-3.694326z" fill="#932279"/><path d="M453.058708 512l-29.554608 29.554608H137.86553v108.310922L0 512l137.86553-137.86553v108.310922h285.63857l29.554608 29.554608zM738.529354 226.529354L553.64513 411.413578V149.956051h108.310921l3.694326 3.694326 72.878977 72.878977z" fill="#932279"/><path d="M649.86553 137.86553h-108.310922v285.63857l-29.554608 29.554608-29.554608-29.554608V137.86553h-108.310922L512 0l137.86553 137.86553zM874.043949 553.64513v108.310921l-3.694326 3.694326-72.878977 72.878977-184.884224-184.884224h261.457527z" fill="#EFA724"/><path d="M886.13447 361.036405v13.098065l-6.04526-6.04526-6.045261-6.045261v108.310921h-3.694326v-125.103312l3.694326 3.694326 6.045261 6.045261 6.04526 6.04526z" fill="#262577"/><path d="M886.13447 649.86553v-108.310922H600.4959L570.941292 512l29.554608-29.554608h285.63857v-108.310922l137.86553 137.86553-137.86553 137.86553z" fill="#262577"/><path d="M411.413578 470.35487H149.956051v-108.310921l3.694326-3.694326L226.529354 285.470646 411.413578 470.35487zM470.35487 149.956051V411.413578L285.470646 226.529354l72.878977-72.878977 3.694326-3.694326h108.310921z" fill="#9CCD2A"/><path d="M738.529354 797.470646L553.64513 612.586422v261.457527h108.310921l3.694326-3.694326 72.878977-72.878977z" fill="#EFA724"/><path d="M649.86553 886.13447h-108.310922V600.4959L512 570.941292l-29.554608 29.554608v285.63857h-108.310922l137.86553 137.86553 137.86553-137.86553z" fill="#9CCD2A"/><path d="M470.35487 874.043949V612.586422L285.470646 797.470646l72.878977 72.878977 3.694326 3.694326h108.310921z" fill="#262577"/><path d="M470.35487 428.541817v41.813053h-41.813053L226.529354 268.342407l-76.573303 76.573303V149.956051h194.959659l-76.573303 76.573303 202.012463 202.012463z" fill="#9CCD2A"/><path d="M880.08921 143.91079v224.17842l-6.045261-6.045261v108.310921H612.586422L797.470646 285.470646l72.878977 72.878977v-13.098065l3.694326 3.694326v-4.030174l-76.573303-76.573303-202.012463 202.012463h-41.813053v-41.813053L755.657593 226.529354l-82.618564-82.618564h207.050181z" fill="#932279"/><path d="M666.993768 137.86553l12.090522 12.090521h194.959659v212.087898l6.045261 6.045261 6.04526 6.04526V137.86553z" fill="#FFF"/><path d="M874.043949 679.08429v194.959659H679.08429L755.657593 797.470646 553.64513 595.458183v-41.813053h41.813053L797.470646 755.657593l76.573303-76.573303z" fill="#EFA724"/><path d="M411.413578 553.64513L226.529354 738.529354l-72.878977-72.878977-3.694326-3.694326v-108.310921H411.413578z" fill="#262577"/><path d="M470.35487 595.458183L268.342407 797.470646l76.573303 76.573303H149.956051V679.08429L226.529354 755.657593l202.012463-202.012463h41.813053v41.813053z" fill="#262577"/><path d="M874.043949 344.91571v4.030174l-3.694326-3.694326v13.098065l3.694326 3.694326 6.045261 6.045261 6.04526 6.04526v-16.792391z" fill="#FFF"/></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('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>
return null
}
function formatSize(bytes: number): string {
if (bytes <= 0) return '-'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
}
+120
View File
@@ -0,0 +1,120 @@
import { FormEvent, useState } from 'react'
import { Lock, User } from 'lucide-react'
import AppIcon from '../components/AppIcon'
import { useAuth } from '../contexts/AuthContext'
async function sha256Hash(input: string): Promise<string> {
const msgBuffer = new TextEncoder().encode(input)
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
}
export default function Login() {
const { login, accessCodeLogin } = useAuth()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
// Check for access code in URL
const urlParams = new URLSearchParams(window.location.search)
const accessCode = urlParams.get('code') || ''
const isAccessCodeLogin = !!accessCode
const handleSubmit = async (event: FormEvent) => {
event.preventDefault()
setError('')
setLoading(true)
try {
if (isAccessCodeLogin) {
await accessCodeLogin(accessCode, password)
} else {
await login(username, password)
}
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
setError(error.response?.data?.message || '登录失败,请检查用户名和密码')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-md">
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-8">
<div className="flex flex-col items-center mb-8">
<div className="w-16 h-16 rounded-lg border border-gray-200 bg-gray-50 flex items-center justify-center mb-4">
<AppIcon className="w-10 h-10" />
</div>
<h1 className="text-2xl font-bold text-gray-950">CLICD</h1>
<p className="text-gray-500 mt-1 text-sm">{isAccessCodeLogin ? '容器管理登录' : 'LXC Container Manager'}</p>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md text-sm">
{error}
</div>
)}
{!isAccessCodeLogin && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User className="h-4 w-4 text-gray-400" />
</div>
<input
type="text"
value={username}
onChange={(event) => setUsername(event.target.value)}
className="block w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-md text-black bg-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-black focus:border-black text-sm"
placeholder="输入用户名"
required
autoComplete="username"
/>
</div>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-4 w-4 text-gray-400" />
</div>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
className="block w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-md text-black bg-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-black focus:border-black text-sm"
placeholder="输入密码"
required
autoComplete="current-password"
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-black text-white py-2.5 rounded-md hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
>
{loading ? '登录中...' : '登录'}
</button>
</form>
</div>
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.0.0</p>
</div>
</div>
)
}
+490
View File
@@ -0,0 +1,490 @@
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
}
+131
View File
@@ -0,0 +1,131 @@
import { useState, useEffect, useCallback } from 'react'
import { RefreshCw } from 'lucide-react'
import { getSecurityAlerts, SecurityAlert } from '../services/api'
const typeLabels: Record<string, string> = {
port_scan: '端口扫描',
horizontal_scan: '横向扫描',
brute_force: '暴力破解',
ddos: 'DDoS/大规模扫描',
spam: '垃圾邮件',
malware: '恶意软件',
mining: '挖矿连接',
proxy: '代理/VPN/Tor',
reflection: 'UDP反射放大',
}
const severityLabels: Record<string, string> = {
critical: '严重',
high: '高危',
medium: '中危',
low: '低危',
}
export default function Security() {
const [alerts, setAlerts] = useState<SecurityAlert[]>([])
const [loading, setLoading] = useState(true)
const fetchData = useCallback(async () => {
try {
const alertRes = await getSecurityAlerts()
if (alertRes.data.data) setAlerts(alertRes.data.data)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
const interval = setInterval(fetchData, 10000)
return () => clearInterval(interval)
}, [fetchData])
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>
)
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-black"></h1>
<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="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div className="px-4 py-3 border-b border-gray-200 bg-gray-50">
<h2 className="text-sm font-semibold text-black"> ({alerts.length})</h2>
</div>
{alerts.length === 0 ? (
<div className="p-8 text-center text-sm text-gray-500"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 text-left text-xs font-medium text-gray-500">
<th className="px-4 py-2.5 whitespace-nowrap"></th>
<th className="px-4 py-2.5 whitespace-nowrap"></th>
<th className="px-4 py-2.5 whitespace-nowrap"></th>
<th className="px-4 py-2.5 whitespace-nowrap"></th>
<th className="px-4 py-2.5 whitespace-nowrap">IP</th>
<th className="px-4 py-2.5 whitespace-nowrap"></th>
<th className="px-4 py-2.5 whitespace-nowrap"></th>
<th className="px-4 py-2.5"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{alerts.map((alert) => (
<tr key={alert.id} className="hover:bg-gray-50">
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 whitespace-nowrap">{alert.timestamp}</td>
<td className="px-4 py-2.5 whitespace-nowrap">
<SeverityBadge severity={alert.severity} />
</td>
<td className="px-4 py-2.5 text-gray-800 whitespace-nowrap">{typeLabels[alert.type] || alert.type}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-700 whitespace-nowrap">{alert.container_name}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-600 whitespace-nowrap">{alert.source_ip}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-600 whitespace-nowrap">
{formatTarget(alert)}
</td>
<td className="px-4 py-2.5 text-gray-600 whitespace-nowrap">{alert.count}</td>
<td className="px-4 py-2.5 text-gray-600 min-w-[260px]">{alert.detail}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}
function SeverityBadge({ severity }: { severity: string }) {
const colors: Record<string, string> = {
critical: 'bg-red-100 text-red-700',
high: 'bg-amber-100 text-amber-700',
medium: 'bg-gray-100 text-gray-700',
low: 'bg-gray-50 text-gray-500',
}
return (
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${colors[severity] || 'bg-gray-100 text-gray-700'}`}>
{severityLabels[severity] || severity}
</span>
)
}
function formatTarget(alert: SecurityAlert): string {
if (alert.target_ip === '*') return '*'
if (!alert.target_ip) return '-'
return alert.target_port > 0 ? `${alert.target_ip}:${alert.target_port}` : alert.target_ip
}
+188
View File
@@ -0,0 +1,188 @@
import { useState, useEffect, useCallback } from 'react'
import { UserCog, Key, LogIn, Monitor, Clock, Globe } from 'lucide-react'
import {
changePassword,
changeUsername,
getLoginLogs,
LoginLog,
} from '../services/api'
import { useDialog } from '../components/Dialog'
import { useAuth } from '../contexts/AuthContext'
export default function Settings() {
const dialog = useDialog()
const { username } = useAuth()
const [logs, setLogs] = useState<LoginLog[]>([])
const [loading, setLoading] = useState(true)
const [logPage, setLogPage] = useState(1)
const pageSize = 10
const [oldPwd, setOldPwd] = useState('')
const [newPwd, setNewPwd] = useState('')
const [newUsername, setNewUsername] = useState('')
const [pwdForUser, setPwdForUser] = useState('')
const fetchLogs = useCallback(async () => {
try {
const res = await getLoginLogs()
if (res.data.data) setLogs(res.data.data)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchLogs(); const t = setInterval(fetchLogs, 15000); return () => clearInterval(t) }, [fetchLogs])
const handleSaveAccount = async () => {
if (!oldPwd) { dialog.alert('提示', '请输入当前密码以确认修改'); return }
if (!newPwd && !newUsername) { dialog.alert('提示', '至少填写新密码或新用户名中的一项'); return }
if (newPwd && newPwd.length < 6) { dialog.alert('提示', '新密码至少 6 位'); return }
if (newUsername && newUsername.length < 3) { dialog.alert('提示', '用户名至少 3 位'); return }
let results: string[] = []
try {
// 先改用户名(用旧密码验证),再改密码,否则改完密码后旧密码就失效了
if (newUsername) {
const res = await changeUsername(newUsername, oldPwd)
if (res.data.success) results.push('用户名已修改')
else results.push('用户名修改失败')
}
if (newPwd) {
const res = await changePassword(oldPwd, newPwd)
if (res.data.success) results.push('密码已修改')
else results.push('密码修改失败')
}
if (results.length > 0) {
dialog.alert('完成', results.join('') + '。下次登录生效')
setOldPwd(''); setNewPwd(''); setNewUsername('')
}
} catch (err: unknown) {
const e = err as { response?: { data?: { message?: string } } }
dialog.alert('失败', e.response?.data?.message || '修改失败')
}
}
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>
)
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-black"></h1>
<p className="text-sm text-gray-500 mt-1"></p>
</div>
{/* Account Settings */}
<div className="bg-white border border-gray-200 rounded-lg p-5">
<h2 className="text-sm font-semibold text-black mb-4 flex items-center gap-2">
<UserCog className="w-4 h-4" />
</h2>
<div className="space-y-4">
<div>
<label className="block text-xs text-gray-500 mb-1"></label>
<input type="text" value={username || ''} disabled className="w-full px-3 py-2 border border-gray-200 rounded-md text-sm text-gray-400 bg-gray-50" />
</div>
<div>
<label className="block text-xs text-gray-500 mb-1"></label>
<input type="text" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" placeholder="至少 3 位" />
</div>
<div className="border-t border-gray-100 pt-3">
<label className="block text-xs text-gray-500 mb-1"></label>
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" placeholder="至少 6 位" />
</div>
<div>
<label className="block text-xs text-gray-500 mb-1"></label>
<input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm text-black bg-white" placeholder="输入当前密码以确认修改" />
</div>
<button onClick={handleSaveAccount} className="w-full px-4 py-2 bg-black text-white rounded-md text-sm hover:bg-gray-800"></button>
</div>
</div>
{/* Login Logs */}
<div className="bg-white border border-gray-200 rounded-lg p-5">
<h2 className="text-sm font-semibold text-black mb-4 flex items-center gap-2">
<LogIn className="w-4 h-4" />
</h2>
{logs.length === 0 ? (
<p className="text-sm text-gray-400"></p>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-gray-400 border-b border-gray-100">
<th className="text-left py-2 font-medium w-40"><span className="inline-flex items-center gap-1"><Clock className="w-3 h-3" /></span></th>
<th className="text-left py-2 font-medium"></th>
<th className="text-left py-2 font-medium"><span className="inline-flex items-center gap-1"><Globe className="w-3 h-3" />IP</span></th>
<th className="text-left py-2 font-medium"><span className="inline-flex items-center gap-1"><Monitor className="w-3 h-3" /></span></th>
<th className="text-left py-2 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{logs.slice((logPage - 1) * pageSize, logPage * pageSize).map((log, i) => (
<tr key={i}>
<td className="py-1.5 text-gray-500 font-mono whitespace-nowrap">{log.time}</td>
<td className="py-1.5 text-gray-700">{log.username}</td>
<td className="py-1.5 text-gray-500 font-mono">{log.ip}</td>
<td className="py-1.5 text-gray-500 max-w-[180px] truncate" title={log.user_agent}>{formatUA(log.user_agent)}</td>
<td className="py-1.5">
<span className={`px-1.5 py-0.5 rounded text-xs ${log.success ? 'bg-gray-100 text-gray-700' : 'bg-red-50 text-red-600'}`}>
{log.success ? '成功' : '失败'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{logs.length > pageSize && (
<div className="flex items-center justify-between mt-3 pt-3 border-t border-gray-100">
<span className="text-xs text-gray-400"> {logs.length} {logPage}/{Math.ceil(logs.length / pageSize)} </span>
<div className="flex items-center gap-1">
<button onClick={() => setLogPage(1)} disabled={logPage === 1} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30"></button>
<button onClick={() => setLogPage(p => Math.max(1, p - 1))} disabled={logPage === 1} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30"></button>
{Array.from({length: Math.min(5, Math.ceil(logs.length / pageSize))}, (_, i) => {
const totalPages = Math.ceil(logs.length / pageSize)
let start = Math.max(1, logPage - 2)
if (start + 4 > totalPages) start = Math.max(1, totalPages - 4)
const page = start + i
if (page > totalPages) return null
return (
<button key={page} onClick={() => setLogPage(page)} className={`w-7 h-7 text-xs rounded ${page === logPage ? 'bg-black text-white' : 'border border-gray-200 hover:bg-gray-50'}`}>{page}</button>
)
})}
<button onClick={() => setLogPage(p => Math.min(Math.ceil(logs.length / pageSize), p + 1))} disabled={logPage >= Math.ceil(logs.length / pageSize)} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30"></button>
<button onClick={() => setLogPage(Math.ceil(logs.length / pageSize))} disabled={logPage >= Math.ceil(logs.length / pageSize)} className="px-2 py-1 text-xs border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-30"></button>
</div>
</div>
)}
</>
)}
</div>
</div>
)
}
function formatUA(ua: string): string {
// Extract browser/OS info from UA string
const parts: string[] = []
if (ua.includes('Windows NT')) parts.push('Windows')
else if (ua.includes('Mac OS X')) parts.push('macOS')
else if (ua.includes('Linux')) parts.push('Linux')
else if (ua.includes('Android')) parts.push('Android')
else if (ua.includes('iPhone') || ua.includes('iPad')) parts.push('iOS')
if (ua.includes('Chrome') && !ua.includes('Edg')) parts.push('Chrome')
else if (ua.includes('Firefox')) parts.push('Firefox')
else if (ua.includes('Edg')) parts.push('Edge')
else if (ua.includes('Safari') && !ua.includes('Chrome')) parts.push('Safari')
return parts.join(' / ') || ua.substring(0, 40)
}
+454
View File
@@ -0,0 +1,454 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
})
// Request interceptor to add auth token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('clicd_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Response interceptor to handle auth errors
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('clicd_token')
localStorage.removeItem('clicd_username')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export interface LoginResponse {
token: string
username: string
}
export type ContainerIdentifier = number | string
export interface PortMapping {
container_port: number
host_port: number
protocol: string
description: string
}
export interface Container {
id: number
uuid: string
name: string
template: string
vcpu: number
ram_mb: number
disk_gb: number
network_bw_mbps: number
monthly_traffic_gb: number
traffic_mode: string
traffic_in_gb: number
traffic_out_gb: number
traffic_used_rx: number
traffic_used_tx: number
traffic_reset_date: string
io_speed_mbps: number
status: string
ip: string
ipv6: string
ipv6_prefix_len: number
ipv6_interface: string
vnc_port: number
ssh_port: number
ssh_password: string
port_mappings: PortMapping[]
port_mapping_limit: number
created_at: string
expires_at: string
}
export interface Template {
id: string
name: string
distro: string
release: string
arch: string
variant?: string
description: string
}
export interface CreateContainerRequest {
name: string
template_id: string
vcpu: number
cpu_percent: number
ram_mb: number
disk_gb: number
network_bw_mbps: number
monthly_traffic_gb: number
traffic_mode: string
traffic_in_gb: number
traffic_out_gb: number
io_speed_mbps: number
extra_ports: number[]
port_mapping_count: number
assign_ipv6: boolean
expires_at: string
}
export interface IPv6PrefixInfo {
interface: string
address: string
prefix: string
prefix_len: number
gateway: string
is_tunnel?: boolean
source?: string
}
export interface IPv6Status {
available: boolean
reachable: boolean
reason: string
prefixes: IPv6PrefixInfo[]
}
export interface DashboardStats {
total_containers: number
running: number
stopped: number
}
export interface HostInfo {
cpu: { cores: number; usage_pct: number }
ram: { total_mb: number; used_mb: number; free_mb: number }
disk: { total_gb: number; used_gb: number; free_gb: number }
network: {
rx_bytes: number
tx_bytes: number
rx_bps: number
tx_bps: number
public_ipv4?: string
public_ipv4_interface?: string
public_ipv6?: string
public_ipv6_interface?: string
ipv6_prefixes?: IPv6PrefixInfo[]
}
disk_io: { read_bytes: number; write_bytes: number; read_bps: number; write_bps: number }
load: { load1: number; load5: number; load15: number }
}
export interface ContainerUsage {
memory_usage_bytes: number
cpu_usage_usec: number
cpu_usage_pct: number
disk_usage_bytes: number
network_rx_bytes: number
network_tx_bytes: number
network_rx_bps: number
network_tx_bps: number
disk_read_bytes: number
disk_write_bytes: number
disk_read_bps: number
disk_write_bps: number
}
export interface APIResponse<T = unknown> {
success: boolean
message?: string
data?: T
}
// Auth
export const login = (username: string, password: string) =>
api.post<APIResponse<LoginResponse>>('/login', { username, password })
export const checkAuth = () =>
api.get<APIResponse>('/check-auth')
export const changePassword = (oldPassword: string, newPassword: string) =>
api.post<APIResponse>('/change-password', { old_password: oldPassword, new_password: newPassword })
export const changeUsername = (newUsername: string, password: string) =>
api.post<APIResponse>('/change-username', { new_username: newUsername, password })
// Login Logs
export interface LoginLog {
time: string
username: string
ip: string
user_agent: string
success: boolean
}
export const getLoginLogs = () =>
api.get<APIResponse<LoginLog[]>>('/login-logs')
// Containers
export const getContainers = () =>
api.get<APIResponse<Container[]>>('/containers')
export const getContainer = (id: ContainerIdentifier) =>
api.get<APIResponse<Container>>(`/containers/${id}`)
export const createContainer = (data: CreateContainerRequest) =>
api.post<APIResponse>('/containers', data)
export const deleteContainer = (id: ContainerIdentifier) =>
api.delete<APIResponse>(`/containers/${id}/delete`)
export const startContainer = (id: ContainerIdentifier) =>
api.post<APIResponse>(`/containers/${id}/start`)
export const stopContainer = (id: ContainerIdentifier) =>
api.post<APIResponse>(`/containers/${id}/stop`)
export const restartContainer = (id: ContainerIdentifier) =>
api.post<APIResponse>(`/containers/${id}/restart`)
export const reinstallContainer = (id: ContainerIdentifier, templateId: string) =>
api.post<APIResponse>(`/containers/${id}/reinstall`, { template_id: templateId })
export const resetSSHPassword = (id: ContainerIdentifier) =>
api.post<APIResponse<{ password: string }>>(`/containers/${id}/reset-password`)
export const getContainerUsage = (id: ContainerIdentifier) =>
api.get<APIResponse<ContainerUsage>>(`/containers/${id}/usage`)
export interface TrafficInfo {
total_used_bytes: number
rx_used_bytes: number
tx_used_bytes: number
mode: string
limit_gb: number
in_limit_gb: number
out_limit_gb: number
used_pct: number
reset_date: string
}
export const getTrafficInfo = (id: ContainerIdentifier) =>
api.get<APIResponse<TrafficInfo>>(`/containers/${id}/traffic`)
export const resetTraffic = (id: ContainerIdentifier) =>
api.post<APIResponse>(`/containers/${id}/traffic-reset`)
export const updateTrafficLimit = (id: ContainerIdentifier, data: {
traffic_mode: string
monthly_traffic_gb: number
traffic_in_gb: number
traffic_out_gb: number
}) =>
api.put<APIResponse>(`/containers/${id}/traffic-limit`, data)
export const updateResourceLimit = (id: ContainerIdentifier, data: {
vcpu: number
ram_mb: number
io_speed_mbps: number
network_bw_mbps: number
}) =>
api.put<APIResponse>(`/containers/${id}/resource-limit`, data)
export const addPortMapping = (id: ContainerIdentifier, data: PortMapping) =>
api.post<APIResponse<PortMapping[]>>(`/containers/${id}/port-mappings`, data)
export const updatePortMapping = (id: ContainerIdentifier, index: number, data: PortMapping) =>
api.put<APIResponse<PortMapping[]>>(`/containers/${id}/port-mappings/${index}`, data)
export const deletePortMapping = (id: ContainerIdentifier, index: number) =>
api.delete<APIResponse<PortMapping[]>>(`/containers/${id}/port-mappings/${index}`)
export const updateContainerExpiry = (id: ContainerIdentifier, expiresAt: string) =>
api.put<APIResponse>(`/containers/${id}/expiry`, { expires_at: expiresAt })
export const getIPv6Status = () =>
api.get<APIResponse<IPv6Status>>('/ipv6/status')
export const assignIPv6 = (id: ContainerIdentifier) =>
api.post<APIResponse<Container>>(`/containers/${id}/ipv6`)
// Templates
export const getTemplates = () =>
api.get<APIResponse<Template[]>>('/templates')
// Images (template download/enable management)
export interface ImageInfo {
id: string
name: string
distro: string
release: string
arch: string
description: string
downloaded: boolean
enabled: boolean
downloading: boolean
size_bytes: number
}
export const getImages = () =>
api.get<APIResponse<ImageInfo[]>>('/images')
export const downloadImage = (templateId: string) =>
api.post<APIResponse>('/images/download', { template_id: templateId }, { timeout: 600000 }) // 10min timeout
export const deleteImage = (templateId: string) =>
api.delete<APIResponse>('/images/delete', { data: { template_id: templateId } })
export const toggleImage = (templateId: string, enabled: boolean) =>
api.put<APIResponse>('/images/toggle', { template_id: templateId, enabled })
export const getEnabledImages = () =>
api.get<APIResponse<Template[]>>('/images/enabled')
// Dashboard
export const getDashboard = () =>
api.get<APIResponse<DashboardStats>>('/dashboard')
export const getHostInfo = () =>
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')
// WebSSH URL generator
export const getWebSSHUrl = (containerName: string) => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const params = new URLSearchParams({ container: containerName })
return `${protocol}//${window.location.host}/api/ssh?${params.toString()}`
}
// Task Queue
export interface Task {
id: string
type: string
container_id?: number
container_name: string
status: string
error?: string
created_at: string
template_id?: string
config?: CreateContainerRequest
}
export const getTasks = () =>
api.get<APIResponse<Task[]>>('/tasks')
export const deleteTask = (taskId: string) =>
api.delete<APIResponse>(`/tasks/${taskId}`)
export const batchCreate = (containers: CreateContainerRequest[]) =>
api.post<APIResponse<string[]>>('/batch-create', { containers })
export const batchAction = (action: string, containers: number[], templateId?: string) =>
api.post<APIResponse>('/batch-action', { action, containers, template_id: templateId })
// Sub Users
export interface SubUser {
id: string
username: string
password: string
container_names: string[]
container_uuids?: string[]
token: string
access_code: string
created_at: string
}
export const createSubUser = (containerId: ContainerIdentifier) =>
api.post<APIResponse<SubUser>>('/sub-user/create', { container_name: String(containerId) })
// Audit Logs
export interface AuditLog {
time: string
action: string
target: string
detail: string
user: string
}
export const getAuditLogs = () =>
api.get<APIResponse<AuditLog[]>>('/audit-logs')
// Security
export interface SecurityAlert {
id: string
container_name: string
type: string
severity: string
source_ip: string
target_ip: string
target_port: number
detail: string
log_line: string
timestamp: string
count: number
}
export interface SecuritySummary {
total_alerts: number
critical: number
high: number
medium: number
low: number
}
export const getSecurityAlerts = () =>
api.get<APIResponse<SecurityAlert[]>>('/security/alerts')
export const checkContainerSecurity = (containerName: string) =>
api.post<APIResponse>('/security/check', { container_name: containerName })
export const getSecurityLogs = (containerName: string) =>
api.get<APIResponse>('/security/logs', { params: { container: containerName } })
export const getSecuritySummary = () =>
api.get<APIResponse<SecuritySummary>>('/security/summary')
export const createWebSSHTicket = (containerName: string) =>
api.post<APIResponse<{ ticket: string }>>('/ssh-ticket', { container_name: containerName })
export default api
+36
View File
@@ -0,0 +1,36 @@
export function actionLabel(action: string): string {
const map: Record<string, string> = {
create: '创建',
start: '开机',
stop: '关机',
restart: '重启',
delete: '删除',
reinstall: '重装',
}
return map[action] || action
}
export function taskStatusLabel(status: string): string {
const map: Record<string, string> = {
pending: '等待中',
running: '执行中',
done: '已完成',
failed: '失败',
}
return map[status] || status
}
export function taskStatusClass(status: string): string {
const map: Record<string, string> = {
pending: 'bg-gray-100 text-gray-700',
running: 'bg-amber-100 text-amber-700',
done: 'bg-emerald-50 text-emerald-700',
failed: 'bg-red-50 text-red-700',
}
return map[status] || 'bg-gray-100 text-gray-700'
}
export function formatMB(mb: number): string {
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`
return `${mb} MB`
}
+11
View File
@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8999',
changeOrigin: true,
}
}
},
build: {
outDir: 'dist',
}
})
+111
View File
@@ -0,0 +1,111 @@
#!/bin/bash
set -euo pipefail
echo "====================================="
echo " CLICD Installation"
echo "====================================="
if [ "$EUID" -ne 0 ]; then
echo "Please run as root: sudo ./install.sh"
exit 1
fi
if ! command -v lxc-create >/dev/null 2>&1; then
echo "LXC is not installed. Installing dependencies..."
if command -v apt-get >/dev/null 2>&1; then
apt-get update
apt-get install -y lxc lxc-templates bridge-utils xz-utils quota
elif command -v yum >/dev/null 2>&1; then
yum install -y epel-release
yum install -y lxc lxc-templates xz quota
elif command -v dnf >/dev/null 2>&1; then
dnf install -y lxc lxc-templates xz quota
else
echo "Could not detect package manager. Please install LXC manually."
exit 1
fi
fi
# Setup subordinate UID/GID for unprivileged containers
echo "Setting up subordinate UID/GID ranges..."
grep -q '^root:' /etc/subuid 2>/dev/null || echo 'root:100000:65536' >> /etc/subuid
grep -q '^root:' /etc/subgid 2>/dev/null || echo 'root:100000:65536' >> /etc/subgid
# Enable ext4 project quota if supported
if tune2fs -l /dev/sda1 2>/dev/null | grep -q 'Filesystem features'; then
echo "Enabling ext4 project quota..."
mkdir -p /etc/initramfs-tools/hooks /etc/initramfs-tools/scripts/local-premount
# Hook to copy tune2fs into initramfs
cat > /etc/initramfs-tools/hooks/tune2fs-hook << 'HOOK'
#!/bin/sh
PREREQ=""
prereqs() { echo "$PREREQ"; }
case "$1" in prereqs) prereqs; exit 0;; esac
. /usr/share/initramfs-tools/hook-functions
copy_exec /sbin/tune2fs /sbin/tune2fs
copy_exec /usr/sbin/setquota /usr/sbin/setquota
HOOK
chmod +x /etc/initramfs-tools/hooks/tune2fs-hook
# Script to run tune2fs before mount
cat > /etc/initramfs-tools/scripts/local-premount/prjquota << 'SCRIPT'
#!/bin/sh
PREREQ=""
prereqs() { echo "$PREREQ"; }
case "$1" in prereqs) prereqs; exit 0;; esac
/sbin/tune2fs -O project -Q prjquota /dev/sda1 2>/dev/null
SCRIPT
chmod +x /etc/initramfs-tools/scripts/local-premount/prjquota
update-initramfs -u -k all 2>/dev/null || true
# Add prjquota to fstab if not already there
grep -q 'prjquota' /etc/fstab 2>/dev/null || sed -i 's|ext4 rw,|ext4 rw,prjquota,|' /etc/fstab
fi
if [ ! -f "./clicd" ]; then
echo "ERROR: clicd binary not found in current directory"
exit 1
fi
cp ./clicd /usr/local/bin/clicd
chmod +x /usr/local/bin/clicd
echo "Installed binary: /usr/local/bin/clicd"
cat > /etc/systemd/system/clicd.service << 'EOF'
[Unit]
Description=CLICD - LXC Container Manager
After=network.target lxc.service
[Service]
Type=simple
ExecStart=/usr/local/bin/clicd server
Restart=always
RestartSec=5
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable clicd
systemctl restart clicd
sleep 2
echo ""
echo "====================================="
echo " Installation Complete"
echo "====================================="
echo " Web: http://YOUR_SERVER_IP:8999"
echo " Service: systemctl {start|stop|restart|status} clicd"
echo " Logs: journalctl -u clicd -f"
echo "====================================="
echo ""
echo "Initial credentials, if this was the first run:"
journalctl -u clicd --no-pager -n 80 | grep -E "Username:|Password:" || true
echo ""
echo "If no password is shown, this server already had /root/.clicd/config.json."
echo "The existing admin password cannot be recovered from the bcrypt hash."