mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-07 22:24:42 +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