mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-04 21:31:23 +08:00
first commit
This commit is contained in:
@@ -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
@@ -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
|
||||
@@ -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`,其中包含敏感信息,不要提交到公开仓库。
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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: "用户名修改成功"})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, "[]")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
+102
@@ -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 面板已自动启动")
|
||||
}
|
||||
}
|
||||
@@ -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 "====================================="
|
||||
@@ -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>
|
||||
Generated
+3023
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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 |
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} -> 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} -> {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'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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`
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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`
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
@@ -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."
|
||||
Reference in New Issue
Block a user