mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-05 05:36:07 +08:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4edf94800 | |||
| 82b42e7961 | |||
| e971d99070 | |||
| 8dd09ff009 | |||
| 7f4755788a | |||
| c24df1d42f | |||
| cf00d0d03d | |||
| 79be2d5cbd | |||
| e66327db29 | |||
| 2b4fe4f5bc |
@@ -4,20 +4,57 @@
|
||||
|
||||
<h1 align="center">CLICD</h1>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Release" src="https://img.shields.io/github/v/release/MengMengCode/CLICD?style=flat-square">
|
||||
<img alt="Stars" src="https://img.shields.io/github/stars/MengMengCode/CLICD?style=flat-square">
|
||||
<img alt="Forks" src="https://img.shields.io/github/forks/MengMengCode/CLICD?style=flat-square">
|
||||
<img alt="Downloads" src="https://img.shields.io/github/downloads/MengMengCode/CLICD/total?style=flat-square">
|
||||
<img alt="Last Commit" src="https://img.shields.io/github/last-commit/MengMengCode/CLICD?style=flat-square">
|
||||
<img alt="License" src="https://img.shields.io/github/license/MengMengCode/CLICD.svg?style=flat-square">
|
||||
</p>
|
||||
|
||||
<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">
|
||||
<img alt="KVM" src="https://img.shields.io/badge/KVM-virtualization-EE0000?style=flat-square&logo=linux&logoColor=white">
|
||||
<img alt="LXC" src="https://img.shields.io/badge/LXC-Supported-111111?style=flat-square">
|
||||
<img alt="KVM" src="https://img.shields.io/badge/KVM-Supported-EE0000?style=flat-square">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="WebSSH" src="https://img.shields.io/badge/WebSSH-Built--in-009688?style=flat-square">
|
||||
<img alt="VNC" src="https://img.shields.io/badge/VNC-Supported-7B1FA2?style=flat-square">
|
||||
<img alt="IPv6" src="https://img.shields.io/badge/IPv6-Native-1976D2?style=flat-square">
|
||||
<img alt="NAT" src="https://img.shields.io/badge/NAT-Port_Forwarding-FF9800?style=flat-square">
|
||||
<img alt="REST API" src="https://img.shields.io/badge/API-REST-4CAF50?style=flat-square">
|
||||
<img alt="Multi User" src="https://img.shields.io/badge/Multi_User-Supported-8E24AA?style=flat-square">
|
||||
<img alt="Traffic Control" src="https://img.shields.io/badge/Traffic-Control-795548?style=flat-square">
|
||||
<img alt="Security Alert" src="https://img.shields.io/badge/Security-Alert-orange?style=flat-square">
|
||||
<img alt="CLI" src="https://img.shields.io/badge/CLI-Mode-424242?style=flat-square">
|
||||
<img alt="TLS" src="https://img.shields.io/badge/TLS-Let's_Encrypt-003A70?style=flat-square&logo=letsencrypt&logoColor=white">
|
||||
</p>
|
||||
|
||||
CLICD is a lightweight virtualization management panel for LXC and KVM, featuring a web console, CLI management, batch operations, image management, NAT networking, IPv6 allocation, WebSSH, VNC access, resource controls, bandwidth limiting, and security alerting.
|
||||
It is designed for managing LXC containers and KVM virtual machines on VPS servers, and is particularly suitable for environments that require bulk provisioning and delegated access management through sub-user management links.
|
||||
|
||||
CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板,提供 Web 控制台、CLI、批量任务、镜像管理、NAT 端口、IPv6 分配、WebSSH、VNC、资源限制、流量限制和安全告警能力。它适合用来管理小型 VPS 上的 LXC 容器和 KVM 虚拟机,也适合需要批量创建和分发子用户管理链接的场景。
|
||||
|
||||
## 功能介绍
|
||||
## Features / 功能介绍
|
||||
|
||||
### English
|
||||
1. Supports Ubuntu, Debian, Alpine, CentOS, Arch Linux, Fedora, Rocky Linux, and other operating system images. Images can be downloaded on demand through the image management interface. For hosts with limited resources, lightweight distributions such as Alpine are recommended.
|
||||
2. Supports WebSSH management, allowing users to access container terminals directly from the browser without manually copying SSH credentials.
|
||||
3. Supports NAT4 port quotas, port forwarding, and protocol restrictions, as well as public IPv6 allocation. IPv6 assignment requires the host machine to have a routable IPv6 prefix.
|
||||
4. Supports both inbound and outbound traffic limits. Containers are automatically powered off when configured limits are reached, preventing bandwidth overuse.
|
||||
5. Supports container expiration dates. Expired containers are automatically shut down, and delegated users lose access until an administrator extends the expiration period.
|
||||
6. Includes lightweight conntrack-based security monitoring. The system does not store full logs of normal connections, but generates audit alerts for suspicious activities such as port scanning, lateral scanning, brute-force attempts, SMTP abuse, UDP reflection attacks, cryptocurrency mining ports, and proxy/VPN/Tor usage.
|
||||
7. Supports delegated management links. Administrators can assign specific containers to sub-users, while ensuring that each user can only manage the containers explicitly authorized to them.
|
||||
8. Provides a REST API for automating the management of containers, tasks, images, networking, traffic controls, and security alerts.
|
||||
9. Supports operating entirely through the CLI. When the web console is not required, administrators can stop and disable the systemd service and launch CLI-only mode using `clicd cli --no-web`.
|
||||
|
||||
### 中文
|
||||
1. 支持 Ubuntu、Debian、Alpine、CentOS、Arch Linux、Fedora、Rocky Linux 等系统镜像。镜像可以在镜像管理中按需下载;如果宿主机资源比较小,建议优先选择 Alpine 这类轻量镜像。
|
||||
2. 支持 WebSSH 管理,可以在浏览器里一键进入容器终端,不需要手动复制 SSH 密码。
|
||||
3. 支持设置 NAT4 端口数量、NAT 端口映射和协议限制,并支持分配公网 IPv6。IPv6 分配要求宿主机本身拥有可路由的 IPv6 地址段。
|
||||
@@ -28,22 +65,22 @@ CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板,提供 Web 控制
|
||||
8. 支持 API 接入,可以通过 API 完成容器、任务、镜像、端口、流量、安全告警等功能的自动化控制。
|
||||
9. 支持仅使用 CLI 管理。需要关闭 Web 控制台时,可以停止并禁用 systemd 服务,然后使用 `clicd cli --no-web` 进入命令行模式。
|
||||
|
||||
## 技术栈
|
||||
## Technology Stack / 技术栈
|
||||
|
||||
- Backend: Go, net/http, LXC, KVM/libvirt, cgroup v2, iptables, conntrack
|
||||
- Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js
|
||||
- Runtime: Linux, systemd, LXC, KVM/QEMU
|
||||
- Build: GitHub Actions, Node.js 20, Go 1.22
|
||||
|
||||
## 安装
|
||||
## Installation / 安装
|
||||
|
||||
一键安装:
|
||||
One-click Install / 一键安装:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
|
||||
```
|
||||
|
||||
一键卸载:
|
||||
One-click Uninstall / 一键卸载:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh -s -- uninstall
|
||||
@@ -83,4 +120,4 @@ This open-source software is intended solely for educational purposes, specifica
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=MengMengCode/CLICD&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
</a>
|
||||
|
||||
@@ -1424,6 +1424,7 @@ func detectHostEnvironment() []HostEnvCheck {
|
||||
commandCheck("genisoimage", "KVM cloud-init ISO 工具", false, "genisoimage", "xorriso/mkisofs 可替代"),
|
||||
commandCheck("xorriso", "ISO 备用工具", false, "xorriso", ""),
|
||||
commandCheck("smartctl", "硬盘健康检测", false, "smartctl", ""),
|
||||
certbotCheck(),
|
||||
}
|
||||
checks = append(checks, HostEnvCheck{Key: "dev-kvm", Label: "/dev/kvm 硬件虚拟化", OK: fileExists("/dev/kvm"), Required: false, Detail: boolDetail(fileExists("/dev/kvm"))})
|
||||
checks = append(checks, HostEnvCheck{Key: "ipv4-forward", Label: "IPv4 转发", OK: strings.TrimSpace(readFirstExistingFile("/proc/sys/net/ipv4/ip_forward")) == "1", Required: true, Detail: strings.TrimSpace(readFirstExistingFile("/proc/sys/net/ipv4/ip_forward"))})
|
||||
@@ -1446,6 +1447,70 @@ func commandCheck(key, label string, required bool, cmd string, fallback string)
|
||||
return HostEnvCheck{Key: key, Label: label, OK: ok, Required: required, Detail: detail}
|
||||
}
|
||||
|
||||
func certbotCheck() HostEnvCheck {
|
||||
check := HostEnvCheck{Key: "certbot", Label: "Certbot 证书工具 >= 5.4", Required: false, Detail: "missing"}
|
||||
if !commandExists("certbot") {
|
||||
return check
|
||||
}
|
||||
detail := strings.TrimSpace(runCommandOutput(3*time.Second, "certbot", "--version"))
|
||||
if detail == "" {
|
||||
detail = strings.TrimSpace(runCommandOutput(3*time.Second, "sh", "-c", "certbot --version 2>&1 | head -n 1"))
|
||||
}
|
||||
if detail == "" {
|
||||
detail = "installed, version unknown"
|
||||
}
|
||||
check.Detail = detail
|
||||
version := extractCertbotVersion(detail)
|
||||
check.OK = certbotVersionAtLeast(version, 5, 4)
|
||||
if version == "" {
|
||||
check.Detail = detail + " (version unknown, need >= 5.4)"
|
||||
} else if !check.OK {
|
||||
check.Detail = detail + " (need >= 5.4)"
|
||||
}
|
||||
return check
|
||||
}
|
||||
|
||||
func extractCertbotVersion(output string) string {
|
||||
for _, field := range strings.Fields(output) {
|
||||
field = strings.Trim(field, "vV,;:()[]{}")
|
||||
if field == "" || field[0] < '0' || field[0] > '9' {
|
||||
continue
|
||||
}
|
||||
return field
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func certbotVersionAtLeast(version string, minMajor, minMinor int) bool {
|
||||
parts := strings.Split(version, ".")
|
||||
if len(parts) < 2 {
|
||||
return false
|
||||
}
|
||||
major, err := strconv.Atoi(numericPrefix(parts[0]))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
minor, err := strconv.Atoi(numericPrefix(parts[1]))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if major != minMajor {
|
||||
return major > minMajor
|
||||
}
|
||||
return minor >= minMinor
|
||||
}
|
||||
|
||||
func numericPrefix(value string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
if r < '0' || r > '9' {
|
||||
break
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func envCheckOK(checks []HostEnvCheck, key string) bool {
|
||||
for _, check := range checks {
|
||||
if check.Key == key {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExtractCertbotVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
output string
|
||||
want string
|
||||
}{
|
||||
{"certbot 5.4.0", "5.4.0"},
|
||||
{"certbot v5.10.1", "5.10.1"},
|
||||
{"certbot, version 4.9", "4.9"},
|
||||
{"installed", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := extractCertbotVersion(tt.output); got != tt.want {
|
||||
t.Fatalf("extractCertbotVersion(%q) = %q, want %q", tt.output, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertbotVersionAtLeast54(t *testing.T) {
|
||||
tests := []struct {
|
||||
version string
|
||||
want bool
|
||||
}{
|
||||
{"5.4", true},
|
||||
{"5.4.0", true},
|
||||
{"5.10", true},
|
||||
{"6.0.0", true},
|
||||
{"5.3.9", false},
|
||||
{"4.99", false},
|
||||
{"5", false},
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := certbotVersionAtLeast(tt.version, 5, 4); got != tt.want {
|
||||
t.Fatalf("certbotVersionAtLeast(%q, 5, 4) = %v, want %v", tt.version, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,34 @@ type LoginLog struct {
|
||||
|
||||
var loginLogs = make([]LoginLog, 0)
|
||||
|
||||
// HandleLanguage returns or updates the global panel language.
|
||||
func HandleLanguage(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{
|
||||
"language": config.NormalizeLanguage(config.AppConfig.Language),
|
||||
}})
|
||||
case http.MethodPost, http.MethodPut:
|
||||
var req struct {
|
||||
Language string `json:"language"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"})
|
||||
return
|
||||
}
|
||||
config.AppConfig.Language = config.NormalizeLanguage(req.Language)
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save language"})
|
||||
return
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{
|
||||
"language": config.AppConfig.Language,
|
||||
}})
|
||||
default:
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
+37
-30
@@ -17,7 +17,6 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -92,10 +91,12 @@ func updateSSLSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if target == "" {
|
||||
target = detectedRequestHost(r)
|
||||
}
|
||||
if target == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "SSL target is required"})
|
||||
normalizedTarget, err := config.NormalizeSSLCertificateTarget(target)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
target = normalizedTarget
|
||||
|
||||
next, err := resolveSSLModeCertificate(mode, target, strings.TrimSpace(req.Email), req.CertPEM, req.KeyPEM)
|
||||
if err != nil {
|
||||
@@ -220,12 +221,10 @@ func saveUploadedCertificate(certPEM, keyPEM string) (string, string, error) {
|
||||
if _, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM)); err != nil {
|
||||
return "", "", fmt.Errorf("certificate/private key mismatch: %v", err)
|
||||
}
|
||||
dir := sslStorageDir()
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
certPath, keyPath, err := config.UploadedSSLPaths()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
certPath := filepath.Join(dir, "uploaded-fullchain.pem")
|
||||
keyPath := filepath.Join(dir, "uploaded-privkey.pem")
|
||||
if err := os.WriteFile(certPath, []byte(certPEM+"\n"), 0600); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
@@ -237,9 +236,11 @@ func saveUploadedCertificate(certPEM, keyPEM string) (string, string, error) {
|
||||
|
||||
func generateSelfSignedCertificate(target string) (string, string, error) {
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" {
|
||||
return "", "", fmt.Errorf("self-signed certificate target is required")
|
||||
normalizedTarget, err := config.NormalizeSSLCertificateTarget(target)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
target = normalizedTarget
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
@@ -273,12 +274,10 @@ func generateSelfSignedCertificate(target string) (string, string, error) {
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
dir := sslStorageDir()
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
certPath, keyPath, err := config.SelfSignedSSLPaths()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
certPath := filepath.Join(dir, "self-signed-fullchain.pem")
|
||||
keyPath := filepath.Join(dir, "self-signed-privkey.pem")
|
||||
certOut := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyOut := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
if err := os.WriteFile(certPath, certOut, 0600); err != nil {
|
||||
@@ -295,9 +294,11 @@ func requestLetsEncryptCertificate(target, email string) (string, string, error)
|
||||
return "", "", fmt.Errorf("certbot is not installed on this server")
|
||||
}
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" {
|
||||
return "", "", fmt.Errorf("Let's Encrypt target is required")
|
||||
normalizedTarget, err := config.NormalizeSSLCertificateTarget(target)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
target = normalizedTarget
|
||||
args := []string{"certonly", "--non-interactive", "--agree-tos", "--standalone"}
|
||||
if email != "" {
|
||||
args = append(args, "--email", email)
|
||||
@@ -317,12 +318,14 @@ func requestLetsEncryptCertificate(target, email string) (string, string, error)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("Let's Encrypt request failed: %s", strings.TrimSpace(string(output)))
|
||||
}
|
||||
certPath := filepath.Join("/etc/letsencrypt/live", target, "fullchain.pem")
|
||||
keyPath := filepath.Join("/etc/letsencrypt/live", target, "privkey.pem")
|
||||
if _, err := os.Stat(certPath); err != nil {
|
||||
certPath, keyPath, err := config.LetsEncryptSSLPaths(target)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if _, err := config.ReadableFileStat(certPath); err != nil {
|
||||
return "", "", fmt.Errorf("Let's Encrypt certificate file not found after issuance: %s", certPath)
|
||||
}
|
||||
if _, err := os.Stat(keyPath); err != nil {
|
||||
if _, err := config.ReadableFileStat(keyPath); err != nil {
|
||||
return "", "", fmt.Errorf("Let's Encrypt private key file not found after issuance: %s", keyPath)
|
||||
}
|
||||
return certPath, keyPath, nil
|
||||
@@ -347,11 +350,19 @@ func ensureCertbotSupportsIPCertificates() error {
|
||||
}
|
||||
|
||||
func validateCertificatePair(certPath, keyPath string) error {
|
||||
certPEM, err := os.ReadFile(certPath)
|
||||
safeCertPath, err := config.ResolveSSLPath(certPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keyPEM, err := os.ReadFile(keyPath)
|
||||
safeKeyPath, err := config.ResolveSSLPath(keyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
certPEM, err := os.ReadFile(safeCertPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keyPEM, err := os.ReadFile(safeKeyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -443,7 +454,11 @@ func readLeafCertificate(certPath string) (*x509.Certificate, error) {
|
||||
if certPath == "" {
|
||||
return nil, errors.New("certificate path is empty")
|
||||
}
|
||||
data, err := os.ReadFile(certPath)
|
||||
safeCertPath, err := config.ResolveSSLPath(certPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(safeCertPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -494,14 +509,6 @@ func firstPublicInterfaceIP() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func sslStorageDir() string {
|
||||
dataDir := config.AppConfig.DataDir
|
||||
if dataDir == "" {
|
||||
dataDir = "/root/.clicd"
|
||||
}
|
||||
return filepath.Join(dataDir, "ssl")
|
||||
}
|
||||
|
||||
func maskExistingPath(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
|
||||
+395
-115
@@ -9,6 +9,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -26,18 +27,170 @@ const (
|
||||
libvirtDefaultNetworkMarker = "/var/lib/clicd/kvm/default-network.created"
|
||||
)
|
||||
|
||||
var cliEnglish = detectCLIEnglish()
|
||||
|
||||
var cliTranslations = map[string]string{
|
||||
"重新加载配置失败": "Failed to reload config",
|
||||
"请选择操作": "Select an action",
|
||||
"再见": "Goodbye",
|
||||
"无效选择": "Invalid choice",
|
||||
"CLICD - LXC 容器管理器": "CLICD - LXC Container Manager",
|
||||
"Web 面板": "Web panel",
|
||||
"端口": "port",
|
||||
"运行中": "running",
|
||||
"已停止": "stopped",
|
||||
"当前版本": "Current version",
|
||||
"查看容器列表": "List containers",
|
||||
"创建容器": "Create container",
|
||||
"开机容器": "Start container",
|
||||
"关机容器": "Stop container",
|
||||
"重启容器": "Restart container",
|
||||
"删除容器": "Delete container",
|
||||
"重装容器系统": "Reinstall container OS",
|
||||
"重置 Web 管理员密码": "Reset web admin password",
|
||||
"启动": "Start",
|
||||
"停止": "Stop",
|
||||
"导入现有 LXC 容器": "Import existing LXC containers",
|
||||
"检查并升级 CLICD": "Check and upgrade CLICD",
|
||||
"卸载 CLICD": "Uninstall CLICD",
|
||||
"系统信息": "System info",
|
||||
"退出": "Exit",
|
||||
"获取容器列表失败": "Failed to get container list",
|
||||
"暂无容器": "No containers",
|
||||
"容器": "Container",
|
||||
"名称": "Name",
|
||||
"状态": "Status",
|
||||
"镜像": "Image",
|
||||
"内存(MB)": "Memory(MB)",
|
||||
"磁盘(GB)": "Disk(GB)",
|
||||
"容器名称": "Container name",
|
||||
"容器名称不能为空": "Container name cannot be empty",
|
||||
"可用镜像": "Available images",
|
||||
"镜像选择无效": "Invalid image selection",
|
||||
"内存 (MB)": "Memory (MB)",
|
||||
"磁盘 (GB)": "Disk (GB)",
|
||||
"网络带宽 (Mbps)": "Network bandwidth (Mbps)",
|
||||
"月流量 (GB)": "Monthly traffic (GB)",
|
||||
"IO 速度 (MB/s)": "IO speed (MB/s)",
|
||||
"额外 NAT 端口,多个用逗号分隔": "Extra NAT ports, comma-separated",
|
||||
"正在创建容器": "Creating container",
|
||||
"创建失败": "Create failed",
|
||||
"创建成功": "created successfully",
|
||||
"端口未分配": "port not assigned",
|
||||
"密码已保存,请在 Web 面板中查看或重置": "Password saved. View or reset it in the web panel",
|
||||
"开机失败": "Start failed",
|
||||
"已开机": "started",
|
||||
"关机失败": "Stop failed",
|
||||
"已关机": "stopped",
|
||||
"重启失败": "Restart failed",
|
||||
"已重启": "restarted",
|
||||
"开机": "start",
|
||||
"关机": "stop",
|
||||
"重启": "restart",
|
||||
"删除": "delete",
|
||||
"重装": "reinstall",
|
||||
"确认删除容器": "Delete container",
|
||||
"输入 yes 继续": "type yes to continue",
|
||||
"已取消": "Cancelled",
|
||||
"删除失败": "Delete failed",
|
||||
"已删除": "deleted",
|
||||
"确认重装容器": "Reinstall container",
|
||||
"重装失败": "Reinstall failed",
|
||||
"已重装": "reinstalled",
|
||||
"新的管理员密码(至少 6 位)": "New admin password (at least 6 characters)",
|
||||
"密码至少需要 6 位": "Password must be at least 6 characters",
|
||||
"确认密码": "Confirm password",
|
||||
"两次输入的密码不一致": "Passwords do not match",
|
||||
"管理员密码已重置。": "Admin password has been reset.",
|
||||
"按 Enter 返回菜单": "Press Enter to return to menu",
|
||||
"选择要": "Select a container to ",
|
||||
"的容器": "",
|
||||
"选择无效": "Invalid selection",
|
||||
"主机名": "Hostname",
|
||||
"管理员用户": "Admin user",
|
||||
"容器总数": "Total containers",
|
||||
"切换语言": "Switch language",
|
||||
"当前语言": "Current language",
|
||||
"请选择语言": "Select language",
|
||||
"语言已切换为": "Language switched to",
|
||||
"保存语言失败": "Failed to save language",
|
||||
"简体中文": "Simplified Chinese",
|
||||
"重置失败": "Reset failed",
|
||||
"停止 Web 面板失败": "Failed to stop web panel",
|
||||
"Web 面板已停止,LXC 容器不会受影响。": "Web panel stopped. LXC containers are not affected.",
|
||||
"启动 Web 面板失败": "Failed to start web panel",
|
||||
"Web 面板已启动": "Web panel started",
|
||||
"升级只会替换 /usr/local/bin/clicd,并保留 /root/.clicd 里的配置、容器数据和任务记录。": "The upgrade only replaces /usr/local/bin/clicd and keeps configuration, container data, and task records under /root/.clicd.",
|
||||
"升级需要 root 权限。请使用: sudo clicd cli": "Upgrade requires root privileges. Use: sudo clicd cli",
|
||||
"检查仓库": "Checking repository",
|
||||
"检查 GitHub 最新版本失败": "Failed to check the latest GitHub version",
|
||||
"GitHub Release 没有 tag_name,无法判断最新版本。": "GitHub Release has no tag_name, so the latest version cannot be determined.",
|
||||
"最新版本": "Latest version",
|
||||
"发布页面": "Release page",
|
||||
"最新 Release 没有找到 clicd-linux-amd64.tar.gz,无法自动升级。": "The latest release does not contain clicd-linux-amd64.tar.gz, so automatic upgrade is unavailable.",
|
||||
"当前已经是最新版本。": "The current version is already the latest.",
|
||||
"是否仍然重新安装最新版本?输入 reinstall 继续": "Reinstall the latest version anyway? Type reinstall to continue",
|
||||
"输入 upgrade 开始升级": "Type upgrade to start upgrade",
|
||||
"已取消。": "Cancelled.",
|
||||
"升级失败": "Upgrade failed",
|
||||
"升级完成": "Upgrade completed",
|
||||
"原有数据已保留,Web 服务已重启。": "Existing data has been kept and the web service has been restarted.",
|
||||
"GitHub API 返回": "GitHub API returned",
|
||||
"GitHub API 被限流,已切换到备用检查方式。": "GitHub API rate limit reached; switched to fallback check.",
|
||||
"GitHub API 不可用,已切换到备用检查方式。": "GitHub API is unavailable; switched to fallback check.",
|
||||
"GitHub releases/latest 返回": "GitHub releases/latest returned",
|
||||
"无法从 GitHub releases/latest 跳转结果解析最新版本": "Unable to parse the latest version from the GitHub releases/latest redirect",
|
||||
"正在下载升级包...": "Downloading upgrade package...",
|
||||
"正在解压升级包...": "Extracting upgrade package...",
|
||||
"解压失败": "Extraction failed",
|
||||
"备份旧二进制失败": "Failed to back up old binary",
|
||||
"旧版本已备份": "Old version backed up",
|
||||
"正在替换二进制...": "Replacing binary...",
|
||||
"停止 Web 服务失败,继续尝试替换": "Failed to stop web service; continuing replacement attempt",
|
||||
"二进制已替换,但重启 Web 服务失败": "Binary was replaced, but restarting the web service failed",
|
||||
"下载失败,HTTP": "Download failed, HTTP",
|
||||
"升级包内未找到 clicd 二进制": "No clicd binary found in the upgrade package",
|
||||
"将 /var/lib/lxc 里的容器导入 CLICD 配置。": "Import containers under /var/lib/lxc into CLICD configuration.",
|
||||
"导入后会保留真实 LXC 名称,Web 和 CLI 都能管理同一个容器。": "After import, real LXC names are kept and both Web and CLI can manage the same containers.",
|
||||
"导入失败": "Import failed",
|
||||
"没有发现新的 ct-* 容器。": "No new ct-* containers found.",
|
||||
"已导入": "Imported",
|
||||
"个容器": "containers",
|
||||
"将删除 CLICD 服务和 /usr/local/bin/clicd。": "This will remove the CLICD service and /usr/local/bin/clicd.",
|
||||
"同时会删除 /root/.clicd、/var/lib/lxc、/var/lib/clicd、镜像缓存、备份、临时文件、/swapfile 和 CLICD 网络规则。": "It will also remove /root/.clicd, /var/lib/lxc, /var/lib/clicd, image caches, backups, temporary files, /swapfile, and CLICD network rules.",
|
||||
"卸载需要 root 权限。": "Uninstall requires root privileges.",
|
||||
"请运行: sudo clicd cli --no-web": "Run: sudo clicd cli --no-web",
|
||||
"输入 uninstall 继续卸载": "Type uninstall to continue uninstalling",
|
||||
"CLICD 已卸载。": "CLICD has been uninstalled.",
|
||||
"服务、二进制、配置、容器/虚拟机、本地镜像、缓存、备份、临时文件和 CLICD 网络规则均已删除。": "Service, binary, configuration, containers/VMs, local images, cache, backups, temporary files, and CLICD network rules have been removed.",
|
||||
"检测到非 CLICD 虚拟机仍在使用 libvirt default 网络,已保留 default/virbr0。": "Non-CLICD VMs are still using the libvirt default network, so default/virbr0 has been kept.",
|
||||
"Web 面板重载跳过": "Web panel reload skipped",
|
||||
"Web 面板已重载并应用配置变更。": "Web panel reloaded and configuration changes applied.",
|
||||
"读取容器状态失败": "Failed to read container status",
|
||||
"CLICD 版本": "CLICD version",
|
||||
"Web 端口": "Web port",
|
||||
"LXC 版本": "LXC version",
|
||||
"暂无可用容器": "No available containers",
|
||||
"忽略无效端口": "Ignoring invalid port",
|
||||
"?": "? ",
|
||||
"。": ". ",
|
||||
",": ", ",
|
||||
":": ": ",
|
||||
}
|
||||
|
||||
// Run starts the CLI interface.
|
||||
func Run() {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
for {
|
||||
if _, err := config.InitConfig(); err != nil {
|
||||
fmt.Printf("重新加载配置失败: %v\n", err)
|
||||
cliPrintf("重新加载配置失败: %v\n", err)
|
||||
waitEnter(reader)
|
||||
}
|
||||
refreshCLILanguage()
|
||||
clearScreen()
|
||||
printMenu()
|
||||
fmt.Print("\n请选择操作 [1-12,0/q]: ")
|
||||
cliPrint("\n请选择操作 [1-12,l,0/q]: ")
|
||||
input, _ := reader.ReadString('\n')
|
||||
input = strings.TrimSpace(input)
|
||||
|
||||
@@ -94,11 +247,15 @@ func Run() {
|
||||
clearScreen()
|
||||
cliShowInfo()
|
||||
waitEnter(reader)
|
||||
case "l", "lang", "language":
|
||||
clearScreen()
|
||||
cliSwitchLanguage(reader)
|
||||
waitEnter(reader)
|
||||
case "q", "exit", "quit":
|
||||
fmt.Println("再见")
|
||||
cliPrintln("再见")
|
||||
return
|
||||
default:
|
||||
fmt.Println("无效选择")
|
||||
cliPrintln("无效选择")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,49 +265,86 @@ func printMenu() {
|
||||
if isWebPanelRunning() {
|
||||
webStatus = "停止"
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println(" ==========================================")
|
||||
fmt.Println(" CLICD - LXC 容器管理器")
|
||||
fmt.Println(" ==========================================")
|
||||
fmt.Println()
|
||||
fmt.Printf(" Web 面板: %s (端口 %d)\n", func() string {
|
||||
cliPrintln("")
|
||||
cliPrintln(" ==========================================")
|
||||
cliPrintln(" CLICD - LXC 容器管理器")
|
||||
cliPrintln(" ==========================================")
|
||||
cliPrintln("")
|
||||
cliPrintf(" Web 面板: %s (端口 %d)\n", func() string {
|
||||
if isWebPanelRunning() {
|
||||
return "运行中"
|
||||
}
|
||||
return "已停止"
|
||||
}(), config.AppConfig.Port)
|
||||
fmt.Printf(" 当前版本: %s\n", version.Current())
|
||||
fmt.Println()
|
||||
fmt.Println(" 1. 查看容器列表")
|
||||
fmt.Println(" 2. 创建容器")
|
||||
fmt.Println(" 3. 开机容器")
|
||||
fmt.Println(" 4. 关机容器")
|
||||
fmt.Println(" 5. 重启容器")
|
||||
fmt.Println(" 6. 删除容器")
|
||||
fmt.Println(" 7. 重装容器系统")
|
||||
fmt.Println(" 8. 重置 Web 管理员密码")
|
||||
fmt.Printf(" 9. %s Web 面板\n", webStatus)
|
||||
fmt.Println(" 10. 导入现有 LXC 容器")
|
||||
fmt.Println(" 11. 检查并升级 CLICD")
|
||||
fmt.Println(" 12. 卸载 CLICD")
|
||||
fmt.Println(" 0. 系统信息")
|
||||
fmt.Println(" q. 退出")
|
||||
cliPrintf(" 当前版本: %s\n", version.Current())
|
||||
cliPrintln("")
|
||||
cliPrintln(" 1. 查看容器列表")
|
||||
cliPrintln(" 2. 创建容器")
|
||||
cliPrintln(" 3. 开机容器")
|
||||
cliPrintln(" 4. 关机容器")
|
||||
cliPrintln(" 5. 重启容器")
|
||||
cliPrintln(" 6. 删除容器")
|
||||
cliPrintln(" 7. 重装容器系统")
|
||||
cliPrintln(" 8. 重置 Web 管理员密码")
|
||||
cliPrintf(" 9. %s Web 面板\n", webStatus)
|
||||
cliPrintln(" 10. 导入现有 LXC 容器")
|
||||
cliPrintln(" 11. 检查并升级 CLICD")
|
||||
cliPrintln(" 12. 卸载 CLICD")
|
||||
cliPrintln(" 0. 系统信息")
|
||||
cliPrintln(" l. 切换语言")
|
||||
cliPrintln(" q. 退出")
|
||||
}
|
||||
|
||||
func cliSwitchLanguage(reader *bufio.Reader) {
|
||||
cliPrintf("\n--- %s ---\n", cliT("切换语言"))
|
||||
cliPrintf("%s: %s\n", cliT("当前语言"), cliLanguageLabel(config.NormalizeLanguage(config.AppConfig.Language)))
|
||||
cliPrintln(" 1. 简体中文")
|
||||
cliPrintln(" 2. English")
|
||||
choice := promptString(reader, "请选择语言 [1/2]", func() string {
|
||||
if config.NormalizeLanguage(config.AppConfig.Language) == "en" {
|
||||
return "2"
|
||||
}
|
||||
return "1"
|
||||
}())
|
||||
|
||||
next := "zh"
|
||||
switch strings.ToLower(strings.TrimSpace(choice)) {
|
||||
case "2", "en", "english":
|
||||
next = "en"
|
||||
case "1", "zh", "cn", "chinese":
|
||||
next = "zh"
|
||||
default:
|
||||
cliPrintln("无效选择")
|
||||
return
|
||||
}
|
||||
|
||||
config.AppConfig.Language = next
|
||||
if err := config.SaveConfig(); err != nil {
|
||||
cliPrintf("保存语言失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
_ = os.Setenv("CLICD_LANG", next)
|
||||
refreshCLILanguage()
|
||||
cliPrintf("%s: %s\n", cliT("语言已切换为"), cliLanguageLabel(next))
|
||||
if isWebPanelRunning() {
|
||||
restartWebPanelForConfigChange()
|
||||
}
|
||||
}
|
||||
|
||||
func cliListContainers() {
|
||||
containers, err := manager.ListContainers()
|
||||
if err != nil {
|
||||
fmt.Printf("获取容器列表失败: %v\n", err)
|
||||
cliPrintf("获取容器列表失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(containers) == 0 {
|
||||
fmt.Println("\n暂无容器")
|
||||
cliPrintln("\n暂无容器")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf("%-18s %-10s %-18s %-6s %-10s %-10s %-16s\n", "名称", "状态", "镜像", "vCPU", "内存(MB)", "磁盘(GB)", "SSH")
|
||||
fmt.Printf("%-18s %-10s %-18s %-6s %-10s %-10s %-16s\n", cliT("名称"), cliT("状态"), cliT("镜像"), "vCPU", cliT("内存(MB)"), cliT("磁盘(GB)"), "SSH")
|
||||
fmt.Println(strings.Repeat("-", 94))
|
||||
for _, c := range containers {
|
||||
ssh := "-"
|
||||
@@ -163,23 +357,23 @@ func cliListContainers() {
|
||||
}
|
||||
|
||||
func cliCreateContainer(reader *bufio.Reader) {
|
||||
fmt.Println("\n--- 创建容器 ---")
|
||||
cliPrintln("\n--- 创建容器 ---")
|
||||
|
||||
name := promptString(reader, "容器名称", "")
|
||||
if name == "" {
|
||||
fmt.Println("容器名称不能为空")
|
||||
cliPrintln("容器名称不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
templates := lxc.GetTemplates()
|
||||
fmt.Println("\n可用镜像:")
|
||||
cliPrintln("\n可用镜像:")
|
||||
for i, template := range templates {
|
||||
fmt.Printf(" %d. %s\n", i+1, template.Name)
|
||||
}
|
||||
|
||||
tmplIdx := promptInt(reader, fmt.Sprintf("镜像 [1-%d]", len(templates)), 1)
|
||||
if tmplIdx < 1 || tmplIdx > len(templates) {
|
||||
fmt.Println("镜像选择无效")
|
||||
cliPrintln("镜像选择无效")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -195,16 +389,16 @@ func cliCreateContainer(reader *bufio.Reader) {
|
||||
ExtraPorts: promptPortList(reader, "额外 NAT 端口,多个用逗号分隔"),
|
||||
}
|
||||
|
||||
fmt.Printf("\n正在创建容器 %s ...\n", name)
|
||||
cliPrintf("\n正在创建容器 %s ...\n", name)
|
||||
if err := manager.CreateContainer(cfg); err != nil {
|
||||
fmt.Printf("创建失败: %v\n", err)
|
||||
cliPrintf("创建失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
container := config.FindContainerByName(name)
|
||||
fmt.Printf("容器 %s 创建成功\n", name)
|
||||
cliPrintf("容器 %s 创建成功\n", name)
|
||||
if container != nil {
|
||||
fmt.Print(formatSSHAccess(container.SSHPort))
|
||||
cliPrint(formatSSHAccess(container.SSHPort))
|
||||
}
|
||||
restartWebPanelForConfigChange()
|
||||
}
|
||||
@@ -222,10 +416,10 @@ func cliStartContainer(reader *bufio.Reader) {
|
||||
return
|
||||
}
|
||||
if err := manager.StartContainer(id); err != nil {
|
||||
fmt.Printf("开机失败: %v\n", err)
|
||||
cliPrintf("开机失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("容器 %s 已开机\n", name)
|
||||
cliPrintf("容器 %s 已开机\n", name)
|
||||
}
|
||||
|
||||
func cliStopContainer(reader *bufio.Reader) {
|
||||
@@ -234,10 +428,10 @@ func cliStopContainer(reader *bufio.Reader) {
|
||||
return
|
||||
}
|
||||
if err := manager.StopContainer(id); err != nil {
|
||||
fmt.Printf("关机失败: %v\n", err)
|
||||
cliPrintf("关机失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("容器 %s 已关机\n", name)
|
||||
cliPrintf("容器 %s 已关机\n", name)
|
||||
}
|
||||
|
||||
func cliRestartContainer(reader *bufio.Reader) {
|
||||
@@ -246,10 +440,10 @@ func cliRestartContainer(reader *bufio.Reader) {
|
||||
return
|
||||
}
|
||||
if err := manager.RestartContainer(id); err != nil {
|
||||
fmt.Printf("重启失败: %v\n", err)
|
||||
cliPrintf("重启失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("容器 %s 已重启\n", name)
|
||||
cliPrintf("容器 %s 已重启\n", name)
|
||||
}
|
||||
|
||||
func cliDeleteContainer(reader *bufio.Reader) {
|
||||
@@ -259,14 +453,14 @@ func cliDeleteContainer(reader *bufio.Reader) {
|
||||
}
|
||||
confirm := promptString(reader, fmt.Sprintf("确认删除容器 %s?输入 yes 继续", name), "no")
|
||||
if strings.ToLower(confirm) != "yes" {
|
||||
fmt.Println("已取消")
|
||||
cliPrintln("已取消")
|
||||
return
|
||||
}
|
||||
if err := manager.DestroyContainer(id); err != nil {
|
||||
fmt.Printf("删除失败: %v\n", err)
|
||||
cliPrintf("删除失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("容器 %s 已删除\n", name)
|
||||
cliPrintf("容器 %s 已删除\n", name)
|
||||
restartWebPanelForConfigChange()
|
||||
}
|
||||
|
||||
@@ -277,66 +471,66 @@ func cliReinstallContainer(reader *bufio.Reader) {
|
||||
}
|
||||
|
||||
templates := lxc.GetTemplates()
|
||||
fmt.Println("\n可用镜像:")
|
||||
cliPrintln("\n可用镜像:")
|
||||
for i, template := range templates {
|
||||
fmt.Printf(" %d. %s\n", i+1, template.Name)
|
||||
}
|
||||
|
||||
tmplIdx := promptInt(reader, fmt.Sprintf("镜像 [1-%d]", len(templates)), 1)
|
||||
if tmplIdx < 1 || tmplIdx > len(templates) {
|
||||
fmt.Println("镜像选择无效")
|
||||
cliPrintln("镜像选择无效")
|
||||
return
|
||||
}
|
||||
|
||||
confirm := promptString(reader, fmt.Sprintf("确认重装容器 %s?输入 yes 继续", name), "no")
|
||||
if strings.ToLower(confirm) != "yes" {
|
||||
fmt.Println("已取消")
|
||||
cliPrintln("已取消")
|
||||
return
|
||||
}
|
||||
|
||||
if err := manager.ReinstallContainer(id, templates[tmplIdx-1].ID); err != nil {
|
||||
fmt.Printf("重装失败: %v\n", err)
|
||||
cliPrintf("重装失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("容器 %s 已重装\n", name)
|
||||
cliPrintf("容器 %s 已重装\n", name)
|
||||
restartWebPanelForConfigChange()
|
||||
}
|
||||
|
||||
func cliResetPassword(reader *bufio.Reader) {
|
||||
newPass := promptString(reader, "新的管理员密码(至少 6 位)", "")
|
||||
if len(newPass) < 6 {
|
||||
fmt.Println("密码至少需要 6 位")
|
||||
cliPrintln("密码至少需要 6 位")
|
||||
return
|
||||
}
|
||||
confirm := promptString(reader, "确认密码", "")
|
||||
if newPass != confirm {
|
||||
fmt.Println("两次输入的密码不一致")
|
||||
cliPrintln("两次输入的密码不一致")
|
||||
return
|
||||
}
|
||||
|
||||
if err := config.ResetAdminPassword(newPass); err != nil {
|
||||
fmt.Printf("重置失败: %v\n", err)
|
||||
cliPrintf("重置失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Println("管理员密码已重置。")
|
||||
cliPrintln("管理员密码已重置。")
|
||||
restartWebPanelForConfigChange()
|
||||
}
|
||||
|
||||
func cliToggleWebPanel() {
|
||||
if isWebPanelRunning() {
|
||||
if err := stopService("clicd"); err != nil {
|
||||
fmt.Printf("停止 Web 面板失败: %v\n", err)
|
||||
cliPrintf("停止 Web 面板失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Println("Web 面板已停止,LXC 容器不会受影响。")
|
||||
cliPrintln("Web 面板已停止,LXC 容器不会受影响。")
|
||||
return
|
||||
}
|
||||
|
||||
if err := startService("clicd"); err != nil {
|
||||
fmt.Printf("启动 Web 面板失败: %v\n", err)
|
||||
cliPrintf("启动 Web 面板失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Println("Web 面板已启动")
|
||||
cliPrintln("Web 面板已启动")
|
||||
}
|
||||
|
||||
type githubRelease struct {
|
||||
@@ -350,11 +544,11 @@ type githubRelease struct {
|
||||
}
|
||||
|
||||
func cliUpgradeSystem(reader *bufio.Reader) {
|
||||
fmt.Println("\n--- 检查并升级 CLICD ---")
|
||||
fmt.Println("升级只会替换 /usr/local/bin/clicd,并保留 /root/.clicd 里的配置、容器数据和任务记录。")
|
||||
cliPrintln("\n--- 检查并升级 CLICD ---")
|
||||
cliPrintln("升级只会替换 /usr/local/bin/clicd,并保留 /root/.clicd 里的配置、容器数据和任务记录。")
|
||||
|
||||
if os.Geteuid() != 0 {
|
||||
fmt.Println("升级需要 root 权限。请使用: sudo clicd cli")
|
||||
cliPrintln("升级需要 root 权限。请使用: sudo clicd cli")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -363,51 +557,51 @@ func cliUpgradeSystem(reader *bufio.Reader) {
|
||||
repo = version.Repo
|
||||
}
|
||||
current := version.Current()
|
||||
fmt.Printf("当前版本: %s\n", current)
|
||||
fmt.Printf("检查仓库: https://github.com/%s\n", repo)
|
||||
cliPrintf("当前版本: %s\n", current)
|
||||
cliPrintf("检查仓库: https://github.com/%s\n", repo)
|
||||
|
||||
release, err := fetchLatestRelease(repo)
|
||||
if err != nil {
|
||||
fmt.Printf("检查 GitHub 最新版本失败: %v\n", err)
|
||||
cliPrintf("检查 GitHub 最新版本失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
latest := strings.TrimSpace(release.TagName)
|
||||
if latest == "" {
|
||||
fmt.Println("GitHub Release 没有 tag_name,无法判断最新版本。")
|
||||
cliPrintln("GitHub Release 没有 tag_name,无法判断最新版本。")
|
||||
return
|
||||
}
|
||||
fmt.Printf("最新版本: %s\n", latest)
|
||||
cliPrintf("最新版本: %s\n", latest)
|
||||
if release.HTMLURL != "" {
|
||||
fmt.Printf("发布页面: %s\n", release.HTMLURL)
|
||||
cliPrintf("发布页面: %s\n", release.HTMLURL)
|
||||
}
|
||||
|
||||
assetURL := findReleaseAsset(release, "clicd-linux-amd64.tar.gz")
|
||||
if assetURL == "" {
|
||||
fmt.Println("最新 Release 没有找到 clicd-linux-amd64.tar.gz,无法自动升级。")
|
||||
cliPrintln("最新 Release 没有找到 clicd-linux-amd64.tar.gz,无法自动升级。")
|
||||
return
|
||||
}
|
||||
|
||||
if sameVersion(current, latest) {
|
||||
fmt.Println("当前已经是最新版本。")
|
||||
cliPrintln("当前已经是最新版本。")
|
||||
confirm := promptString(reader, "是否仍然重新安装最新版本?输入 reinstall 继续", "no")
|
||||
if strings.ToLower(confirm) != "reinstall" {
|
||||
fmt.Println("已取消。")
|
||||
cliPrintln("已取消。")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
confirm := promptString(reader, "输入 upgrade 开始升级", "no")
|
||||
if strings.ToLower(confirm) != "upgrade" {
|
||||
fmt.Println("已取消。")
|
||||
cliPrintln("已取消。")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := upgradeFromReleaseAsset(assetURL, latest); err != nil {
|
||||
fmt.Printf("升级失败: %v\n", err)
|
||||
cliPrintf("升级失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("升级完成: %s -> %s\n", current, latest)
|
||||
fmt.Println("原有数据已保留,Web 服务已重启。")
|
||||
cliPrintf("升级完成: %s -> %s\n", current, latest)
|
||||
cliPrintln("原有数据已保留,Web 服务已重启。")
|
||||
}
|
||||
|
||||
func fetchLatestRelease(repo string) (*githubRelease, error) {
|
||||
@@ -434,9 +628,9 @@ func fetchLatestRelease(repo string) (*githubRelease, error) {
|
||||
apiErr := fmt.Errorf("GitHub API 返回 %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
||||
if fallback, fallbackErr := fetchLatestReleaseFallback(repo); fallbackErr == nil {
|
||||
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
|
||||
fmt.Println("GitHub API 被限流,已切换到备用检查方式。")
|
||||
cliPrintln("GitHub API 被限流,已切换到备用检查方式。")
|
||||
} else {
|
||||
fmt.Println("GitHub API 不可用,已切换到备用检查方式。")
|
||||
cliPrintln("GitHub API 不可用,已切换到备用检查方式。")
|
||||
}
|
||||
return fallback, nil
|
||||
}
|
||||
@@ -530,12 +724,12 @@ func upgradeFromReleaseAsset(assetURL, latest string) error {
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
archivePath := filepath.Join(tmpDir, "clicd-linux-amd64.tar.gz")
|
||||
fmt.Println("正在下载升级包...")
|
||||
cliPrintln("正在下载升级包...")
|
||||
if err := downloadFile(assetURL, archivePath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("正在解压升级包...")
|
||||
cliPrintln("正在解压升级包...")
|
||||
if out, err := exec.Command("tar", "-xzf", archivePath, "-C", tmpDir).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("解压失败: %v, output: %s", err, string(out))
|
||||
}
|
||||
@@ -555,12 +749,12 @@ func upgradeFromReleaseAsset(assetURL, latest string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("备份旧二进制失败: %w", err)
|
||||
}
|
||||
fmt.Printf("旧版本已备份: %s\n", backupPath)
|
||||
cliPrintf("旧版本已备份: %s\n", backupPath)
|
||||
}
|
||||
|
||||
fmt.Println("正在替换二进制...")
|
||||
cliPrintln("正在替换二进制...")
|
||||
if err := stopService("clicd"); err != nil {
|
||||
fmt.Printf("停止 Web 服务失败,继续尝试替换: %v\n", err)
|
||||
cliPrintf("停止 Web 服务失败,继续尝试替换: %v\n", err)
|
||||
}
|
||||
tmpBin := clicdNewBinaryPath
|
||||
if err := copyFileToUpgradeTemp(newBinary, 0755); err != nil {
|
||||
@@ -715,21 +909,21 @@ func isWebPanelRunning() bool {
|
||||
}
|
||||
|
||||
func cliImportExistingContainers() {
|
||||
fmt.Println("\n--- 导入现有 LXC 容器 ---")
|
||||
fmt.Println("将 /var/lib/lxc 里的容器导入 CLICD 配置。")
|
||||
fmt.Println("导入后会保留真实 LXC 名称,Web 和 CLI 都能管理同一个容器。")
|
||||
cliPrintln("\n--- 导入现有 LXC 容器 ---")
|
||||
cliPrintln("将 /var/lib/lxc 里的容器导入 CLICD 配置。")
|
||||
cliPrintln("导入后会保留真实 LXC 名称,Web 和 CLI 都能管理同一个容器。")
|
||||
|
||||
imported, err := manager.ImportExistingClicdContainers()
|
||||
if err != nil {
|
||||
fmt.Printf("导入失败: %v\n", err)
|
||||
cliPrintf("导入失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
if len(imported) == 0 {
|
||||
fmt.Println("没有发现新的 ct-* 容器。")
|
||||
cliPrintln("没有发现新的 ct-* 容器。")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("已导入 %d 个容器:\n", len(imported))
|
||||
cliPrintf("已导入 %d 个容器:\n", len(imported))
|
||||
for _, c := range imported {
|
||||
fmt.Printf(" [%d] %s [%s]\n", c.ID, c.Name, c.Status)
|
||||
}
|
||||
@@ -737,19 +931,19 @@ func cliImportExistingContainers() {
|
||||
}
|
||||
|
||||
func cliUninstall(reader *bufio.Reader) {
|
||||
fmt.Println("\n--- 卸载 CLICD ---")
|
||||
fmt.Println("将删除 CLICD 服务和 /usr/local/bin/clicd。")
|
||||
fmt.Println("同时会删除 /root/.clicd、/var/lib/lxc、/var/lib/clicd、镜像缓存、备份、临时文件、/swapfile 和 CLICD 网络规则。")
|
||||
cliPrintln("\n--- 卸载 CLICD ---")
|
||||
cliPrintln("将删除 CLICD 服务和 /usr/local/bin/clicd。")
|
||||
cliPrintln("同时会删除 /root/.clicd、/var/lib/lxc、/var/lib/clicd、镜像缓存、备份、临时文件、/swapfile 和 CLICD 网络规则。")
|
||||
|
||||
if os.Geteuid() != 0 {
|
||||
fmt.Println("卸载需要 root 权限。")
|
||||
fmt.Println("请运行: sudo clicd cli --no-web")
|
||||
cliPrintln("卸载需要 root 权限。")
|
||||
cliPrintln("请运行: sudo clicd cli --no-web")
|
||||
return
|
||||
}
|
||||
|
||||
confirm := promptString(reader, "输入 uninstall 继续卸载", "no")
|
||||
if strings.ToLower(confirm) != "uninstall" {
|
||||
fmt.Println("已取消")
|
||||
cliPrintln("已取消")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -776,8 +970,8 @@ func cliUninstall(reader *bufio.Reader) {
|
||||
reloadSysctl()
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("CLICD 已卸载。")
|
||||
fmt.Println("服务、二进制、配置、容器/虚拟机、本地镜像、缓存、备份、临时文件和 CLICD 网络规则均已删除。")
|
||||
cliPrintln("CLICD 已卸载。")
|
||||
cliPrintln("服务、二进制、配置、容器/虚拟机、本地镜像、缓存、备份、临时文件和 CLICD 网络规则均已删除。")
|
||||
}
|
||||
|
||||
func destroyAllLXCContainers() {
|
||||
@@ -847,7 +1041,7 @@ func removeCLICDLibvirtDefaultNetwork() {
|
||||
return
|
||||
}
|
||||
if libvirtDefaultUsedByNonCLICDDomain() {
|
||||
fmt.Println("检测到非 CLICD 虚拟机仍在使用 libvirt default 网络,已保留 default/virbr0。")
|
||||
cliPrintln("检测到非 CLICD 虚拟机仍在使用 libvirt default 网络,已保留 default/virbr0。")
|
||||
return
|
||||
}
|
||||
fmt.Println("Removing CLICD-created libvirt default network...")
|
||||
@@ -1242,10 +1436,10 @@ func shellQuote(value string) string {
|
||||
|
||||
func restartWebPanelForConfigChange() {
|
||||
if err := restartService("clicd"); err != nil {
|
||||
fmt.Printf("Web 面板重载跳过: %v\n", err)
|
||||
cliPrintf("Web 面板重载跳过: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Println("Web 面板已重载并应用配置变更。")
|
||||
cliPrintln("Web 面板已重载并应用配置变更。")
|
||||
}
|
||||
|
||||
func stopService(name string) error {
|
||||
@@ -1281,7 +1475,7 @@ func restartService(name string) error {
|
||||
func cliShowInfo() {
|
||||
containers, err := manager.ListContainers()
|
||||
if err != nil {
|
||||
fmt.Printf("读取容器状态失败: %v\n", err)
|
||||
cliPrintf("读取容器状态失败: %v\n", err)
|
||||
}
|
||||
|
||||
total := len(containers)
|
||||
@@ -1292,44 +1486,44 @@ func cliShowInfo() {
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\n--- 系统信息 ---")
|
||||
fmt.Printf("CLICD 版本: %s\n", version.Current())
|
||||
fmt.Printf("Web 端口: %d\n", config.AppConfig.Port)
|
||||
fmt.Printf("管理员用户: %s\n", config.AppConfig.AdminUser)
|
||||
fmt.Printf("容器总数: %d\n", total)
|
||||
fmt.Printf("运行中: %d\n", running)
|
||||
fmt.Printf("已停止: %d\n", total-running)
|
||||
cliPrintln("\n--- 系统信息 ---")
|
||||
cliPrintf("CLICD 版本: %s\n", version.Current())
|
||||
cliPrintf("Web 端口: %d\n", config.AppConfig.Port)
|
||||
cliPrintf("管理员用户: %s\n", config.AppConfig.AdminUser)
|
||||
cliPrintf("容器总数: %d\n", total)
|
||||
cliPrintf("运行中: %d\n", running)
|
||||
cliPrintf("已停止: %d\n", total-running)
|
||||
|
||||
if hostname, err := os.Hostname(); err == nil {
|
||||
fmt.Printf("主机名: %s\n", hostname)
|
||||
cliPrintf("主机名: %s\n", hostname)
|
||||
}
|
||||
|
||||
cmd := exec.Command("lxc-info", "--version")
|
||||
output, err := cmd.Output()
|
||||
if err == nil {
|
||||
fmt.Printf("LXC 版本: %s", string(output))
|
||||
cliPrintf("LXC 版本: %s", string(output))
|
||||
}
|
||||
}
|
||||
|
||||
func selectContainer(reader *bufio.Reader, action string) (int, string) {
|
||||
containers, err := manager.ListContainers()
|
||||
if err != nil {
|
||||
fmt.Printf("获取容器列表失败: %v\n", err)
|
||||
cliPrintf("获取容器列表失败: %v\n", err)
|
||||
return 0, ""
|
||||
}
|
||||
if len(containers) == 0 {
|
||||
fmt.Println("暂无可用容器")
|
||||
cliPrintln("暂无可用容器")
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
fmt.Printf("\n--- 选择要%s的容器 ---\n", action)
|
||||
cliPrintf("\n--- 选择要%s的容器 ---\n", cliT(action))
|
||||
for i, container := range containers {
|
||||
fmt.Printf(" %d. [%d] %s [%s]\n", i+1, container.ID, container.Name, container.Status)
|
||||
}
|
||||
|
||||
idx := promptInt(reader, "容器", 0)
|
||||
if idx < 1 || idx > len(containers) {
|
||||
fmt.Println("选择无效")
|
||||
cliPrintln("选择无效")
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
@@ -1338,6 +1532,7 @@ func selectContainer(reader *bufio.Reader, action string) (int, string) {
|
||||
}
|
||||
|
||||
func promptString(reader *bufio.Reader, label string, fallback string) string {
|
||||
label = cliT(label)
|
||||
if fallback == "" {
|
||||
fmt.Printf("%s: ", label)
|
||||
} else {
|
||||
@@ -1375,7 +1570,7 @@ func clearScreen() {
|
||||
}
|
||||
|
||||
func waitEnter(reader *bufio.Reader) {
|
||||
fmt.Print("\n按 Enter 返回菜单...")
|
||||
cliPrint("\n按 Enter 返回菜单...")
|
||||
reader.ReadString('\n')
|
||||
}
|
||||
|
||||
@@ -1389,10 +1584,95 @@ func promptPortList(reader *bufio.Reader, label string) []int {
|
||||
for _, part := range strings.Split(input, ",") {
|
||||
value, err := strconv.Atoi(strings.TrimSpace(part))
|
||||
if err != nil || value <= 0 || value > 65535 {
|
||||
fmt.Printf("忽略无效端口: %s\n", strings.TrimSpace(part))
|
||||
cliPrintf("忽略无效端口: %s\n", strings.TrimSpace(part))
|
||||
continue
|
||||
}
|
||||
ports = append(ports, value)
|
||||
}
|
||||
return ports
|
||||
}
|
||||
|
||||
func detectCLIEnglish() bool {
|
||||
lang := strings.ToLower(strings.TrimSpace(os.Getenv("CLICD_LANG")))
|
||||
if lang == "en" || strings.HasPrefix(lang, "en_") || strings.HasPrefix(lang, "en-") {
|
||||
return true
|
||||
}
|
||||
if lang == "zh" || strings.HasPrefix(lang, "zh_") || strings.HasPrefix(lang, "zh-") {
|
||||
return false
|
||||
}
|
||||
if config.AppConfig != nil {
|
||||
return config.NormalizeLanguage(config.AppConfig.Language) == "en"
|
||||
}
|
||||
env := strings.ToLower(os.Getenv("LC_ALL") + " " + os.Getenv("LC_MESSAGES") + " " + os.Getenv("LANG"))
|
||||
return strings.Contains(env, "en_") || strings.Contains(env, "en-") || strings.Contains(env, "english")
|
||||
}
|
||||
|
||||
func refreshCLILanguage() {
|
||||
cliEnglish = detectCLIEnglish()
|
||||
}
|
||||
|
||||
func cliLanguageLabel(language string) string {
|
||||
if config.NormalizeLanguage(language) == "en" {
|
||||
return "English"
|
||||
}
|
||||
return cliT("简体中文")
|
||||
}
|
||||
|
||||
func cliT(text string) string {
|
||||
if !cliEnglish {
|
||||
return text
|
||||
}
|
||||
translated := text
|
||||
keys := make([]string, 0, len(cliTranslations))
|
||||
for zh := range cliTranslations {
|
||||
keys = append(keys, zh)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if len(keys[i]) == len(keys[j]) {
|
||||
return keys[i] < keys[j]
|
||||
}
|
||||
return len(keys[i]) > len(keys[j])
|
||||
})
|
||||
for _, zh := range keys {
|
||||
en := cliTranslations[zh]
|
||||
translated = strings.ReplaceAll(translated, zh, en)
|
||||
}
|
||||
return translated
|
||||
}
|
||||
|
||||
func cliPrint(args ...interface{}) {
|
||||
if cliEnglish {
|
||||
for i, arg := range args {
|
||||
if s, ok := arg.(string); ok {
|
||||
args[i] = cliT(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Print(args...)
|
||||
}
|
||||
|
||||
func cliPrintln(args ...interface{}) {
|
||||
if cliEnglish {
|
||||
for i, arg := range args {
|
||||
if s, ok := arg.(string); ok {
|
||||
args[i] = cliT(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Println(args...)
|
||||
}
|
||||
|
||||
func cliPrintf(format string, args ...interface{}) {
|
||||
if cliEnglish {
|
||||
for i, arg := range args {
|
||||
if s, ok := arg.(string); ok {
|
||||
args[i] = cliT(s)
|
||||
continue
|
||||
}
|
||||
if err, ok := arg.(error); ok {
|
||||
args[i] = cliT(err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Printf(cliT(format), args...)
|
||||
}
|
||||
|
||||
@@ -243,6 +243,7 @@ type ClicdConfig struct {
|
||||
EnabledImages []string `json:"enabled_images"`
|
||||
Snapshots []Snapshot `json:"snapshots"`
|
||||
SecurityAutoShutdown bool `json:"security_auto_shutdown"`
|
||||
Language string `json:"language"`
|
||||
SSL SSLConfig `json:"ssl"`
|
||||
SSLCertificates map[string]SSLConfig `json:"ssl_certificates"`
|
||||
}
|
||||
@@ -454,12 +455,29 @@ func normalizeConfigDefaults(dataDir string) bool {
|
||||
AppConfig.EnabledImages = make([]string, 0)
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.Language == "" {
|
||||
AppConfig.Language = "zh"
|
||||
changed = true
|
||||
}
|
||||
if AppConfig.Language != "zh" && AppConfig.Language != "en" {
|
||||
AppConfig.Language = "zh"
|
||||
changed = true
|
||||
}
|
||||
if normalizeSSLDefaults() {
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func NormalizeLanguage(language string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(language)) {
|
||||
case "en", "en-us", "en_us", "english":
|
||||
return "en"
|
||||
default:
|
||||
return "zh"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSSLDefaults() bool {
|
||||
changed := false
|
||||
previousMode := AppConfig.SSL.Mode
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const letsEncryptLiveDir = "/etc/letsencrypt/live"
|
||||
|
||||
var dnsNamePattern = regexp.MustCompile(`^[A-Za-z0-9.-]+$`)
|
||||
|
||||
func SSLStorageDir() string {
|
||||
dataDir := ""
|
||||
if AppConfig != nil {
|
||||
dataDir = AppConfig.DataDir
|
||||
}
|
||||
if dataDir == "" {
|
||||
dataDir = getDataDir()
|
||||
}
|
||||
return filepath.Join(dataDir, "ssl")
|
||||
}
|
||||
|
||||
func UploadedSSLPaths() (string, string, error) {
|
||||
dir, err := safeSSLStorageDir()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return filepath.Join(dir, "uploaded-fullchain.pem"), filepath.Join(dir, "uploaded-privkey.pem"), nil
|
||||
}
|
||||
|
||||
func SelfSignedSSLPaths() (string, string, error) {
|
||||
dir, err := safeSSLStorageDir()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return filepath.Join(dir, "self-signed-fullchain.pem"), filepath.Join(dir, "self-signed-privkey.pem"), nil
|
||||
}
|
||||
|
||||
func LetsEncryptSSLPaths(target string) (string, string, error) {
|
||||
name, err := NormalizeSSLCertificateTarget(target)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
base := filepath.Join(letsEncryptLiveDir, name)
|
||||
return filepath.Join(base, "fullchain.pem"), filepath.Join(base, "privkey.pem"), nil
|
||||
}
|
||||
|
||||
func ResolveSSLConfigPaths(ssl SSLConfig) (string, string, error) {
|
||||
mode := NormalizeSSLMode(ssl.Mode)
|
||||
switch mode {
|
||||
case SSLModeUploaded:
|
||||
if ssl.CertPath != "" && ssl.KeyPath != "" {
|
||||
return ResolveSSLPathPair(ssl.CertPath, ssl.KeyPath)
|
||||
}
|
||||
return UploadedSSLPaths()
|
||||
case SSLModeSelfSigned:
|
||||
if ssl.CertPath != "" && ssl.KeyPath != "" {
|
||||
return ResolveSSLPathPair(ssl.CertPath, ssl.KeyPath)
|
||||
}
|
||||
return SelfSignedSSLPaths()
|
||||
case SSLModeLetsEncrypt:
|
||||
if strings.TrimSpace(ssl.Target) == "" && ssl.CertPath != "" && ssl.KeyPath != "" {
|
||||
return ResolveSSLPathPair(ssl.CertPath, ssl.KeyPath)
|
||||
}
|
||||
return LetsEncryptSSLPaths(ssl.Target)
|
||||
default:
|
||||
return "", "", fmt.Errorf("SSL is disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func ResolveSSLPathPair(certPath, keyPath string) (string, string, error) {
|
||||
safeCertPath, err := ResolveSSLPath(certPath)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
safeKeyPath, err := ResolveSSLPath(keyPath)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return safeCertPath, safeKeyPath, nil
|
||||
}
|
||||
|
||||
func ResolveSSLPath(path string) (string, error) {
|
||||
cleaned, err := cleanAbsolutePath(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if isPathUnder(cleaned, SSLStorageDir()) || isPathUnder(cleaned, letsEncryptLiveDir) || isPathUnder(cleaned, "/etc/letsencrypt/archive") {
|
||||
return cleaned, nil
|
||||
}
|
||||
return "", fmt.Errorf("SSL path is outside allowed certificate directories")
|
||||
}
|
||||
|
||||
func ReadableFileStat(path string) (os.FileInfo, error) {
|
||||
safePath, err := ResolveSSLPath(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.Stat(safePath)
|
||||
}
|
||||
|
||||
func NormalizeSSLCertificateTarget(target string) (string, error) {
|
||||
target = strings.TrimSpace(strings.Trim(target, "[]"))
|
||||
if target == "" {
|
||||
return "", fmt.Errorf("SSL target is required")
|
||||
}
|
||||
if strings.Contains(target, "/") || strings.Contains(target, "\\") || strings.Contains(target, "..") {
|
||||
return "", fmt.Errorf("SSL target contains invalid path characters")
|
||||
}
|
||||
if ip := net.ParseIP(target); ip != nil {
|
||||
return ip.String(), nil
|
||||
}
|
||||
if len(target) > 253 || !dnsNamePattern.MatchString(target) {
|
||||
return "", fmt.Errorf("SSL target must be a valid IP address or DNS name")
|
||||
}
|
||||
labels := strings.Split(target, ".")
|
||||
for _, label := range labels {
|
||||
if label == "" || len(label) > 63 || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
|
||||
return "", fmt.Errorf("SSL target must be a valid IP address or DNS name")
|
||||
}
|
||||
}
|
||||
return strings.ToLower(target), nil
|
||||
}
|
||||
|
||||
func safeSSLStorageDir() (string, error) {
|
||||
dir, err := cleanAbsolutePath(SSLStorageDir())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dataDir := ""
|
||||
if AppConfig != nil {
|
||||
dataDir = AppConfig.DataDir
|
||||
}
|
||||
if dataDir == "" {
|
||||
dataDir = getDataDir()
|
||||
}
|
||||
if !isPathUnder(dir, dataDir) {
|
||||
return "", fmt.Errorf("SSL storage directory is outside the data directory")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func cleanAbsolutePath(path string) (string, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return "", fmt.Errorf("path is empty")
|
||||
}
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Clean(abs), nil
|
||||
}
|
||||
|
||||
func isPathUnder(path, root string) bool {
|
||||
cleanPath, err := cleanAbsolutePath(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
cleanRoot, err := cleanAbsolutePath(root)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
rel, err := filepath.Rel(cleanRoot, cleanPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
|
||||
}
|
||||
@@ -373,6 +373,7 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) {
|
||||
NextSSHPort: atoi(meta["next_ssh_port"]),
|
||||
SetupComplete: atob(meta["setup_complete"]),
|
||||
SecurityAutoShutdown: atob(meta["security_auto_shutdown"]),
|
||||
Language: meta["language"],
|
||||
}
|
||||
if raw := strings.TrimSpace(meta["ssl"]); raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &cfg.SSL)
|
||||
@@ -485,6 +486,7 @@ func saveMeta(tx *sql.Tx) error {
|
||||
"next_ssh_port": strconv.Itoa(AppConfig.NextSSHPort),
|
||||
"setup_complete": btoa(AppConfig.SetupComplete),
|
||||
"security_auto_shutdown": btoa(AppConfig.SecurityAutoShutdown),
|
||||
"language": NormalizeLanguage(AppConfig.Language),
|
||||
"ssl": string(sslJSON),
|
||||
"ssl_certificates": string(sslCertificatesJSON),
|
||||
"schema_version": "1",
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"clicd/internal/api"
|
||||
@@ -73,6 +72,7 @@ func isLoopbackHost(host string) bool {
|
||||
func setupRoutes(mux *http.ServeMux) {
|
||||
// API routes
|
||||
mux.HandleFunc("/api/login", corsMiddleware(api.HandleLogin))
|
||||
mux.HandleFunc("/api/language", corsMiddleware(api.HandleLanguage))
|
||||
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)))
|
||||
@@ -120,6 +120,7 @@ func setupRoutes(mux *http.ServeMux) {
|
||||
|
||||
// Versioned external API routes
|
||||
mux.HandleFunc("/api/v1/dashboard", corsMiddleware(api.AuthMiddleware(api.HandleDashboard)))
|
||||
mux.HandleFunc("/api/v1/language", corsMiddleware(api.HandleLanguage))
|
||||
mux.HandleFunc("/api/v1/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers))))
|
||||
mux.HandleFunc("/api/v1/containers/list", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainerListAlias))))
|
||||
mux.HandleFunc("/api/v1/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer))))
|
||||
@@ -213,11 +214,26 @@ func Run() error {
|
||||
}
|
||||
|
||||
if sslEnabled() {
|
||||
certPath, keyPath, err := config.ResolveSSLConfigPaths(config.AppConfig.SSL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
server.TLSConfig = &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
cert, err := tls.LoadX509KeyPair(config.AppConfig.SSL.CertPath, config.AppConfig.SSL.KeyPath)
|
||||
return &cert, err
|
||||
safeCertPath, err := config.ResolveSSLPath(certPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
safeKeyPath, err := config.ResolveSSLPath(keyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cert, err := tls.LoadX509KeyPair(safeCertPath, safeKeyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cert, nil
|
||||
},
|
||||
}
|
||||
log.Printf("CLICD Web Server SSL enabled on https://0.0.0.0:%d", config.AppConfig.Port)
|
||||
@@ -229,14 +245,29 @@ func Run() error {
|
||||
|
||||
func sslEnabled() bool {
|
||||
ssl := config.AppConfig.SSL
|
||||
if !ssl.Enabled || ssl.CertPath == "" || ssl.KeyPath == "" {
|
||||
if !ssl.Enabled {
|
||||
return false
|
||||
}
|
||||
if _, err := os.Stat(ssl.CertPath); err != nil {
|
||||
certPath, keyPath, err := config.ResolveSSLConfigPaths(ssl)
|
||||
if err != nil {
|
||||
log.Printf("SSL paths are invalid, falling back to HTTP: %v", err)
|
||||
return false
|
||||
}
|
||||
safeCertPath, err := config.ResolveSSLPath(certPath)
|
||||
if err != nil {
|
||||
log.Printf("SSL certificate path is not allowed, falling back to HTTP: %v", err)
|
||||
return false
|
||||
}
|
||||
safeKeyPath, err := config.ResolveSSLPath(keyPath)
|
||||
if err != nil {
|
||||
log.Printf("SSL private key path is not allowed, falling back to HTTP: %v", err)
|
||||
return false
|
||||
}
|
||||
if _, err := config.ReadableFileStat(safeCertPath); err != nil {
|
||||
log.Printf("SSL certificate is not readable, falling back to HTTP: %v", err)
|
||||
return false
|
||||
}
|
||||
if _, err := os.Stat(ssl.KeyPath); err != nil {
|
||||
if _, err := config.ReadableFileStat(safeKeyPath); err != nil {
|
||||
log.Printf("SSL private key is not readable, falling back to HTTP: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package version
|
||||
|
||||
var (
|
||||
Version = "1.1.7"
|
||||
Version = "1.1.9"
|
||||
Repo = "MengMengCode/CLICD"
|
||||
)
|
||||
|
||||
|
||||
+27
-2
@@ -107,8 +107,33 @@ func isWebPanelSystemdRunning() bool {
|
||||
func startWebPanelSystemd() {
|
||||
cmd := exec.Command("systemctl", "start", "clicd")
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "警告: 自动启动 Web 面板失败: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "%s: %v\n", mainT("警告: 自动启动 Web 面板失败"), err)
|
||||
} else {
|
||||
fmt.Println("Web 面板已自动启动")
|
||||
fmt.Println(mainT("Web 面板已自动启动"))
|
||||
}
|
||||
}
|
||||
|
||||
func mainT(text string) string {
|
||||
if !mainEnglish() {
|
||||
return text
|
||||
}
|
||||
switch text {
|
||||
case "警告: 自动启动 Web 面板失败":
|
||||
return "Warning: failed to auto-start web panel"
|
||||
case "Web 面板已自动启动":
|
||||
return "Web panel auto-started"
|
||||
default:
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
func mainEnglish() bool {
|
||||
lang := strings.ToLower(strings.TrimSpace(os.Getenv("CLICD_LANG")))
|
||||
if lang == "en" || strings.HasPrefix(lang, "en_") || strings.HasPrefix(lang, "en-") {
|
||||
return true
|
||||
}
|
||||
if lang == "zh" || strings.HasPrefix(lang, "zh_") || strings.HasPrefix(lang, "zh-") {
|
||||
return false
|
||||
}
|
||||
return config.AppConfig != nil && config.NormalizeLanguage(config.AppConfig.Language) == "en"
|
||||
}
|
||||
|
||||
Generated
+245
-128
@@ -369,9 +369,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -382,13 +382,13 @@
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
|
||||
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
|
||||
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -399,13 +399,13 @@
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -416,13 +416,13 @@
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -433,13 +433,13 @@
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -450,13 +450,13 @@
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -467,13 +467,13 @@
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -484,13 +484,13 @@
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -501,13 +501,13 @@
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
|
||||
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
|
||||
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -518,13 +518,13 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -535,13 +535,13 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
|
||||
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
|
||||
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -552,13 +552,13 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
|
||||
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
|
||||
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -569,13 +569,13 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
|
||||
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
|
||||
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -586,13 +586,13 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
|
||||
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
|
||||
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -603,13 +603,13 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
|
||||
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
|
||||
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -620,13 +620,13 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
|
||||
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
|
||||
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -637,13 +637,13 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -654,13 +654,30 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -671,13 +688,30 @@
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -688,13 +722,30 @@
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -705,13 +756,13 @@
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -722,13 +773,13 @@
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
|
||||
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
|
||||
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -739,13 +790,13 @@
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -756,7 +807,7 @@
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@iconify-json/simple-icons": {
|
||||
@@ -1706,9 +1757,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -1716,32 +1767,35 @@
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.21.5",
|
||||
"@esbuild/android-arm": "0.21.5",
|
||||
"@esbuild/android-arm64": "0.21.5",
|
||||
"@esbuild/android-x64": "0.21.5",
|
||||
"@esbuild/darwin-arm64": "0.21.5",
|
||||
"@esbuild/darwin-x64": "0.21.5",
|
||||
"@esbuild/freebsd-arm64": "0.21.5",
|
||||
"@esbuild/freebsd-x64": "0.21.5",
|
||||
"@esbuild/linux-arm": "0.21.5",
|
||||
"@esbuild/linux-arm64": "0.21.5",
|
||||
"@esbuild/linux-ia32": "0.21.5",
|
||||
"@esbuild/linux-loong64": "0.21.5",
|
||||
"@esbuild/linux-mips64el": "0.21.5",
|
||||
"@esbuild/linux-ppc64": "0.21.5",
|
||||
"@esbuild/linux-riscv64": "0.21.5",
|
||||
"@esbuild/linux-s390x": "0.21.5",
|
||||
"@esbuild/linux-x64": "0.21.5",
|
||||
"@esbuild/netbsd-x64": "0.21.5",
|
||||
"@esbuild/openbsd-x64": "0.21.5",
|
||||
"@esbuild/sunos-x64": "0.21.5",
|
||||
"@esbuild/win32-arm64": "0.21.5",
|
||||
"@esbuild/win32-ia32": "0.21.5",
|
||||
"@esbuild/win32-x64": "0.21.5"
|
||||
"@esbuild/aix-ppc64": "0.25.12",
|
||||
"@esbuild/android-arm": "0.25.12",
|
||||
"@esbuild/android-arm64": "0.25.12",
|
||||
"@esbuild/android-x64": "0.25.12",
|
||||
"@esbuild/darwin-arm64": "0.25.12",
|
||||
"@esbuild/darwin-x64": "0.25.12",
|
||||
"@esbuild/freebsd-arm64": "0.25.12",
|
||||
"@esbuild/freebsd-x64": "0.25.12",
|
||||
"@esbuild/linux-arm": "0.25.12",
|
||||
"@esbuild/linux-arm64": "0.25.12",
|
||||
"@esbuild/linux-ia32": "0.25.12",
|
||||
"@esbuild/linux-loong64": "0.25.12",
|
||||
"@esbuild/linux-mips64el": "0.25.12",
|
||||
"@esbuild/linux-ppc64": "0.25.12",
|
||||
"@esbuild/linux-riscv64": "0.25.12",
|
||||
"@esbuild/linux-s390x": "0.25.12",
|
||||
"@esbuild/linux-x64": "0.25.12",
|
||||
"@esbuild/netbsd-arm64": "0.25.12",
|
||||
"@esbuild/netbsd-x64": "0.25.12",
|
||||
"@esbuild/openbsd-arm64": "0.25.12",
|
||||
"@esbuild/openbsd-x64": "0.25.12",
|
||||
"@esbuild/openharmony-arm64": "0.25.12",
|
||||
"@esbuild/sunos-x64": "0.25.12",
|
||||
"@esbuild/win32-arm64": "0.25.12",
|
||||
"@esbuild/win32-ia32": "0.25.12",
|
||||
"@esbuild/win32-x64": "0.25.12"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
@@ -1751,6 +1805,24 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"picomatch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/focus-trap": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz",
|
||||
@@ -2037,6 +2109,19 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
@@ -2258,6 +2343,23 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/trim-lines": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
||||
@@ -2373,21 +2475,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
"rollup": "^4.20.0"
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
"picomatch": "^4.0.2",
|
||||
"postcss": "^8.5.3",
|
||||
"rollup": "^4.34.9",
|
||||
"tinyglobby": "^0.2.13"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/vitejs/vite?sponsor=1"
|
||||
@@ -2396,19 +2501,25 @@
|
||||
"fsevents": "~2.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^18.0.0 || >=20.0.0",
|
||||
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "*",
|
||||
"lightningcss": "^1.21.0",
|
||||
"sass": "*",
|
||||
"sass-embedded": "*",
|
||||
"stylus": "*",
|
||||
"sugarss": "*",
|
||||
"terser": "^5.4.0"
|
||||
"terser": "^5.16.0",
|
||||
"tsx": "^4.8.1",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"jiti": {
|
||||
"optional": true
|
||||
},
|
||||
"less": {
|
||||
"optional": true
|
||||
},
|
||||
@@ -2429,6 +2540,12 @@
|
||||
},
|
||||
"terser": {
|
||||
"optional": true
|
||||
},
|
||||
"tsx": {
|
||||
"optional": true
|
||||
},
|
||||
"yaml": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -9,5 +9,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitepress": "^1.6.4"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "6.4.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "clicd-frontend",
|
||||
"private": true,
|
||||
"version": "1.1.7",
|
||||
"version": "1.1.9",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { shouldTranslateText, translateText } from '../utils/i18n'
|
||||
|
||||
const translatedTitleAttr = 'data-i18n-title-original'
|
||||
const translatedPlaceholderAttr = 'data-i18n-placeholder-original'
|
||||
const translatedAriaLabelAttr = 'data-i18n-aria-label-original'
|
||||
|
||||
const attributeNames = ['title', 'placeholder', 'aria-label'] as const
|
||||
const translatedTextNodes = new Set<Text>()
|
||||
const textOriginals = new WeakMap<Text, string>()
|
||||
const wholeTextSelector = 'button,a,span,label,option,th,td,p,h1,h2,h3,h4,small'
|
||||
|
||||
export default function AutoTranslate() {
|
||||
const { language } = useLanguage()
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
if (language === 'zh') {
|
||||
restoreTranslatedNodes(document.body)
|
||||
return
|
||||
}
|
||||
|
||||
translateNode(document.body)
|
||||
|
||||
const pending = new Set<Node>()
|
||||
let scheduled = false
|
||||
const flush = () => {
|
||||
scheduled = false
|
||||
const nodes = Array.from(pending)
|
||||
pending.clear()
|
||||
for (const node of nodes) {
|
||||
if (node.isConnected) translateNode(node)
|
||||
}
|
||||
}
|
||||
const schedule = (node: Node) => {
|
||||
pending.add(node)
|
||||
if (scheduled) return
|
||||
scheduled = true
|
||||
window.requestAnimationFrame(flush)
|
||||
}
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.type === 'childList') {
|
||||
mutation.addedNodes.forEach(schedule)
|
||||
} else {
|
||||
schedule(mutation.target)
|
||||
}
|
||||
}
|
||||
})
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
attributes: true,
|
||||
attributeFilter: [...attributeNames],
|
||||
})
|
||||
return () => observer.disconnect()
|
||||
}, [language, location.pathname, location.search])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function translateNode(root: Node) {
|
||||
if (root.nodeType === Node.TEXT_NODE) {
|
||||
translateTextNode(root as Text)
|
||||
return
|
||||
}
|
||||
if (!(root instanceof Element)) return
|
||||
if (shouldSkipElement(root)) return
|
||||
|
||||
translateWholeTextElement(root)
|
||||
root.querySelectorAll<HTMLElement>(wholeTextSelector).forEach(translateWholeTextElement)
|
||||
translateElementAttributes(root)
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode(node) {
|
||||
if (!node.textContent || !shouldTranslateText(node.textContent)) return NodeFilter.FILTER_REJECT
|
||||
const parent = node.parentElement
|
||||
if (!parent || shouldSkipElement(parent)) {
|
||||
return NodeFilter.FILTER_REJECT
|
||||
}
|
||||
return NodeFilter.FILTER_ACCEPT
|
||||
},
|
||||
})
|
||||
|
||||
const nodes: Text[] = []
|
||||
while (walker.nextNode()) nodes.push(walker.currentNode as Text)
|
||||
for (const node of nodes) translateTextNode(node)
|
||||
root.querySelectorAll<HTMLElement>('[title], [placeholder], [aria-label]').forEach(translateElementAttributes)
|
||||
}
|
||||
|
||||
function translateTextNode(node: Text) {
|
||||
const original = node.textContent || ''
|
||||
if (!shouldTranslateText(original)) return
|
||||
const parent = node.parentElement
|
||||
if (!parent || shouldSkipElement(parent)) return
|
||||
const translated = translateText(original)
|
||||
if (translated === original) return
|
||||
textOriginals.set(node, original)
|
||||
translatedTextNodes.add(node)
|
||||
node.textContent = translated
|
||||
}
|
||||
|
||||
function translateWholeTextElement(el: Element) {
|
||||
if (!(el instanceof HTMLElement) || shouldSkipElement(el) || !isSimpleTextElement(el)) return
|
||||
const original = el.textContent || ''
|
||||
if (!shouldTranslateText(original)) return
|
||||
const translated = translateText(original)
|
||||
if (translated === original) return
|
||||
|
||||
const textNodes = directTextNodes(el)
|
||||
if (textNodes.length === 0) return
|
||||
textNodes.forEach((node, index) => {
|
||||
textOriginals.set(node, node.textContent || '')
|
||||
translatedTextNodes.add(node)
|
||||
node.textContent = index === 0 ? translated : ''
|
||||
})
|
||||
}
|
||||
|
||||
function directTextNodes(el: HTMLElement) {
|
||||
return Array.from(el.childNodes).filter((node): node is Text => node.nodeType === Node.TEXT_NODE)
|
||||
}
|
||||
|
||||
function translateElementAttributes(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return
|
||||
translateAttribute(el, 'title', translatedTitleAttr)
|
||||
translateAttribute(el, 'placeholder', translatedPlaceholderAttr)
|
||||
translateAttribute(el, 'aria-label', translatedAriaLabelAttr)
|
||||
}
|
||||
|
||||
function restoreTranslatedNodes(root: ParentNode) {
|
||||
for (const node of Array.from(translatedTextNodes)) {
|
||||
if (!node.isConnected) {
|
||||
translatedTextNodes.delete(node)
|
||||
continue
|
||||
}
|
||||
if (root instanceof Document || root.contains(node)) {
|
||||
node.textContent = textOriginals.get(node) || node.textContent
|
||||
translatedTextNodes.delete(node)
|
||||
}
|
||||
}
|
||||
root.querySelectorAll<HTMLElement>(`[${translatedTitleAttr}]`).forEach((el) => {
|
||||
el.setAttribute('title', el.getAttribute(translatedTitleAttr) || '')
|
||||
el.removeAttribute(translatedTitleAttr)
|
||||
})
|
||||
root.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>(`[${translatedPlaceholderAttr}]`).forEach((el) => {
|
||||
el.setAttribute('placeholder', el.getAttribute(translatedPlaceholderAttr) || '')
|
||||
el.removeAttribute(translatedPlaceholderAttr)
|
||||
})
|
||||
root.querySelectorAll<HTMLElement>(`[${translatedAriaLabelAttr}]`).forEach((el) => {
|
||||
el.setAttribute('aria-label', el.getAttribute(translatedAriaLabelAttr) || '')
|
||||
el.removeAttribute(translatedAriaLabelAttr)
|
||||
})
|
||||
}
|
||||
|
||||
function translateAttribute(el: HTMLElement, attr: 'title' | 'placeholder' | 'aria-label', originalAttr: string) {
|
||||
const storedOriginal = el.getAttribute(originalAttr)
|
||||
const original = storedOriginal || el.getAttribute(attr) || ''
|
||||
if (!shouldTranslateText(original)) return
|
||||
const translated = translateText(original)
|
||||
if (translated === original) return
|
||||
if (!storedOriginal) {
|
||||
el.setAttribute(originalAttr, original)
|
||||
}
|
||||
if (el.getAttribute(attr) !== translated) {
|
||||
el.setAttribute(attr, translated)
|
||||
}
|
||||
}
|
||||
|
||||
function shouldSkipElement(el: Element) {
|
||||
return !!el.closest('script, style, code, pre, textarea, [data-no-translate]')
|
||||
}
|
||||
|
||||
function isSimpleTextElement(el: HTMLElement) {
|
||||
if (!el.matches(wholeTextSelector)) return false
|
||||
if (el.querySelector('input, textarea, select, button, table, pre, code, canvas, iframe')) return false
|
||||
const textNodes = directTextNodes(el)
|
||||
if (textNodes.length === 0) return false
|
||||
return Array.from(el.children).every((child) => child.tagName.toLowerCase() === 'svg')
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
|
||||
export default function BrowserDialogTranslator() {
|
||||
const { t } = useLanguage()
|
||||
|
||||
useEffect(() => {
|
||||
const originalAlert = window.alert
|
||||
const originalConfirm = window.confirm
|
||||
window.alert = (message?: unknown) => originalAlert(t(String(message ?? '')))
|
||||
window.confirm = (message?: string) => originalConfirm(t(String(message ?? '')))
|
||||
return () => {
|
||||
window.alert = originalAlert
|
||||
window.confirm = originalConfirm
|
||||
}
|
||||
}, [t])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useCallback, createContext, useContext, ReactNode } from 'react'
|
||||
import { AlertTriangle, CheckCircle, X } from 'lucide-react'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
|
||||
type DialogType = 'confirm' | 'alert'
|
||||
|
||||
@@ -20,6 +21,7 @@ const DialogContext = createContext<DialogContextType | undefined>(undefined)
|
||||
|
||||
export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
const [dialog, setDialog] = useState<DialogState>({ open: false, type: 'alert', title: '', message: '' })
|
||||
const { t } = useLanguage()
|
||||
|
||||
const confirm = useCallback((title: string, message: string) => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
@@ -50,7 +52,7 @@ export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
}`}>
|
||||
{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>
|
||||
<h3 className="text-sm font-semibold text-black flex-1">{t(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" />
|
||||
@@ -58,7 +60,7 @@ export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<p className="text-sm text-gray-600">{dialog.message}</p>
|
||||
<p className="text-sm text-gray-600">{t(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' && (
|
||||
@@ -66,7 +68,7 @@ export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
onClick={() => close(false)}
|
||||
className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-200 rounded-md transition-colors"
|
||||
>
|
||||
取消
|
||||
{t('取消')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -77,7 +79,7 @@ export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
: 'bg-black text-white hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{dialog.type === 'confirm' ? '确认' : '确定'}
|
||||
{dialog.type === 'confirm' ? t('确认') : t('确定')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import Sidebar from './Sidebar'
|
||||
import { useState } from 'react'
|
||||
import AutoTranslate from './AutoTranslate'
|
||||
import BrowserDialogTranslator from './BrowserDialogTranslator'
|
||||
|
||||
export default function Layout() {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex dark:bg-gray-950">
|
||||
<AutoTranslate />
|
||||
<BrowserDialogTranslator />
|
||||
<Sidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed(!sidebarCollapsed)} />
|
||||
<main className={`flex-1 transition-all duration-300 ${sidebarCollapsed ? 'ml-16' : 'ml-60'}`}>
|
||||
<div className="p-6">
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
UserCog,
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
import { getVersion } from '../services/api'
|
||||
import AppIcon from './AppIcon'
|
||||
@@ -45,11 +46,23 @@ function GitHubIcon({ className = '' }: { className?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function LanguageIcon({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path
|
||||
d="M213.333333 640v85.333333a85.333333 85.333333 0 0 0 78.933334 85.12L298.666667 810.666667h128v85.333333H298.666667a170.666667 170.666667 0 0 1-170.666667-170.666667v-85.333333h85.333333z m554.666667-213.333333l187.733333 469.333333h-91.946666l-51.242667-128h-174.506667l-51.157333 128h-91.904L682.666667 426.666667h85.333333z m-42.666667 123.093333L672.128 682.666667h106.325333L725.333333 549.76zM341.333333 85.333333v85.333334h170.666667v298.666666H341.333333v128H256v-128H85.333333V170.666667h170.666667V85.333333h85.333333z m384 42.666667a170.666667 170.666667 0 0 1 170.666667 170.666667v85.333333h-85.333333V298.666667a85.333333 85.333333 0 0 0-85.333334-85.333334h-128V128h128zM256 256H170.666667v128h85.333333V256z m170.666667 0H341.333333v128h85.333334V256z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { logout, isSubUser } = useAuth()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const { language, toggleLanguage, t } = useLanguage()
|
||||
const [version, setVersion] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
@@ -99,7 +112,7 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="p-1 rounded hover:bg-gray-100 text-gray-500 dark:hover:bg-gray-800 dark:text-gray-400"
|
||||
title="切换侧边栏"
|
||||
title={t('切换侧边栏')}
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
@@ -253,18 +266,28 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-2 space-y-1">
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
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 dark:text-gray-400 dark:hover:bg-gray-800"
|
||||
title={theme === 'dark' ? '切换亮色模式' : '切换暗黑模式'}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<Sun className="w-4 h-4" />
|
||||
) : (
|
||||
<Moon className="w-4 h-4" />
|
||||
)}
|
||||
{!collapsed && <span>{theme === 'dark' ? '亮色模式' : '暗黑模式'}</span>}
|
||||
</button>
|
||||
<div className={collapsed ? 'space-y-1' : 'flex items-center gap-1'}>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className={`${collapsed ? 'w-full justify-center' : 'flex-1'} flex items-center gap-3 px-3 py-2.5 rounded-md text-sm text-gray-600 hover:bg-gray-100 transition-colors dark:text-gray-400 dark:hover:bg-gray-800`}
|
||||
title={t(theme === 'dark' ? '切换亮色模式' : '切换暗黑模式')}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<Sun className="w-4 h-4" />
|
||||
) : (
|
||||
<Moon className="w-4 h-4" />
|
||||
)}
|
||||
{!collapsed && <span>{theme === 'dark' ? '亮色模式' : '暗黑模式'}</span>}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { void toggleLanguage() }}
|
||||
className={`${collapsed ? 'w-full' : 'w-10'} flex items-center justify-center rounded-md px-2 py-2.5 text-sm text-gray-600 hover:bg-gray-100 transition-colors dark:text-gray-400 dark:hover:bg-gray-800`}
|
||||
title={language === 'en' ? '切换中文' : 'Switch to English'}
|
||||
>
|
||||
<LanguageIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Version */}
|
||||
{version && (
|
||||
|
||||
@@ -1,8 +1,39 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Monitor, RefreshCw, Send, X } from 'lucide-react'
|
||||
import RFB from '@novnc/novnc'
|
||||
import RFBModule from '@novnc/novnc/lib/rfb'
|
||||
import { createVNCTicket, getWebVNCUrl } from '../services/api'
|
||||
|
||||
type RFBConstructor = new (
|
||||
target: HTMLElement,
|
||||
url: string,
|
||||
options?: { credentials?: Record<string, string>; shared?: boolean; repeaterID?: string; wsProtocols?: string[] }
|
||||
) => RFBInstance
|
||||
|
||||
interface RFBInstance extends EventTarget {
|
||||
scaleViewport: boolean
|
||||
resizeSession: boolean
|
||||
focusOnClick: boolean
|
||||
viewOnly: boolean
|
||||
qualityLevel: number
|
||||
compressionLevel: number
|
||||
background: string
|
||||
disconnect(): void
|
||||
sendCtrlAltDel(): void
|
||||
}
|
||||
|
||||
const RFB = resolveRFBConstructor(RFBModule)
|
||||
|
||||
function resolveRFBConstructor(moduleValue: unknown): RFBConstructor {
|
||||
if (typeof moduleValue === 'function') {
|
||||
return moduleValue as RFBConstructor
|
||||
}
|
||||
const maybeDefault = (moduleValue as { default?: unknown })?.default
|
||||
if (typeof maybeDefault === 'function') {
|
||||
return maybeDefault as RFBConstructor
|
||||
}
|
||||
throw new Error('noVNC RFB constructor is unavailable')
|
||||
}
|
||||
|
||||
interface WebVNCViewerProps {
|
||||
containerName: string
|
||||
onClose: () => void
|
||||
@@ -10,7 +41,7 @@ interface WebVNCViewerProps {
|
||||
|
||||
export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerProps) {
|
||||
const screenRef = useRef<HTMLDivElement>(null)
|
||||
const rfbRef = useRef<RFB | null>(null)
|
||||
const rfbRef = useRef<RFBInstance | null>(null)
|
||||
const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected' | 'error'>('connecting')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
@@ -80,6 +111,7 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
|
||||
setErrorMsg(error.response?.data?.message || 'WebVNC ticket 创建失败,请重新登录后再试')
|
||||
return
|
||||
}
|
||||
|
||||
if (!ticket) {
|
||||
setStatus('error')
|
||||
setErrorMsg('WebVNC ticket 为空,请重新登录后再试')
|
||||
@@ -97,9 +129,7 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
|
||||
rfb.qualityLevel = 6
|
||||
rfb.compressionLevel = 2
|
||||
rfb.background = '#050505'
|
||||
rfb.addEventListener('connect', () => {
|
||||
setStatus('connected')
|
||||
})
|
||||
rfb.addEventListener('connect', () => setStatus('connected'))
|
||||
rfb.addEventListener('disconnect', (event) => {
|
||||
const detail = (event as CustomEvent<{ clean?: boolean }>).detail
|
||||
setStatus((current) => current === 'error' ? current : 'disconnected')
|
||||
@@ -133,35 +163,31 @@ export default function WebVNCViewer({ containerName, onClose }: WebVNCViewerPro
|
||||
}, [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 h-full flex-col overflow-hidden rounded-lg border border-gray-200 bg-white">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4 text-gray-600" />
|
||||
<Monitor className="h-4 w-4 text-gray-600" />
|
||||
<span className="text-sm font-medium text-black">WebVNC - {containerName}</span>
|
||||
{status === 'connected' && <span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700">已连接</span>}
|
||||
{status === 'connecting' && <span className="text-xs px-1.5 py-0.5 rounded bg-yellow-100 text-yellow-700">连接中...</span>}
|
||||
{status === 'disconnected' && <span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">已断开</span>}
|
||||
{status === 'error' && <span className="text-xs px-1.5 py-0.5 rounded bg-red-100 text-red-700">连接失败</span>}
|
||||
{status === 'connected' && <span className="rounded bg-green-100 px-1.5 py-0.5 text-xs text-green-700">已连接</span>}
|
||||
{status === 'connecting' && <span className="rounded bg-yellow-100 px-1.5 py-0.5 text-xs text-yellow-700">连接中...</span>}
|
||||
{status === 'disconnected' && <span className="rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600">已断开</span>}
|
||||
{status === 'error' && <span className="rounded bg-red-100 px-1.5 py-0.5 text-xs text-red-700">连接失败</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => rfbRef.current?.sendCtrlAltDel()}
|
||||
className="inline-flex items-center gap-1 px-2 py-1.5 hover:bg-gray-200 rounded text-gray-500 text-xs"
|
||||
title="发送 Ctrl+Alt+Del"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
<button onClick={() => rfbRef.current?.sendCtrlAltDel()} className="inline-flex items-center gap-1 rounded px-2 py-1.5 text-xs text-gray-500 hover:bg-gray-200" title="发送 Ctrl+Alt+Del">
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
Ctrl+Alt+Del
|
||||
</button>
|
||||
<button onClick={connect} className="p-1.5 hover:bg-gray-200 rounded text-gray-500 text-xs" title="重新连接">
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
<button onClick={connect} className="rounded p-1.5 text-xs text-gray-500 hover:bg-gray-200" title="重新连接">
|
||||
<RefreshCw className="h-3.5 w-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 onClick={onClose} className="rounded p-1.5 text-gray-500 hover:bg-gray-200" title="关闭">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex-1 min-h-0 bg-black overflow-hidden">
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden bg-black">
|
||||
<div ref={screenRef} className="h-full w-full [&>div]:h-full [&>div]:w-full [&_canvas]:block" />
|
||||
{(status === 'connecting' || status === 'error' || (status === 'disconnected' && errorMsg)) && (
|
||||
<div className={`absolute inset-x-0 bottom-0 border-t px-4 py-2 text-sm ${status === 'error' ? 'border-red-900 bg-red-950 text-red-100' : 'border-gray-800 bg-gray-950 text-gray-200'}`}>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ReactNode, createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { translateText } from '../utils/i18n'
|
||||
import { getLanguage, updateLanguage } from '../services/api'
|
||||
|
||||
export type Language = 'zh' | 'en'
|
||||
|
||||
interface LanguageContextValue {
|
||||
language: Language
|
||||
setLanguage: (language: Language) => void
|
||||
toggleLanguage: () => Promise<void>
|
||||
t: (value: string) => string
|
||||
}
|
||||
|
||||
const LanguageContext = createContext<LanguageContextValue | undefined>(undefined)
|
||||
function initialLanguage(): Language {
|
||||
return 'zh'
|
||||
}
|
||||
|
||||
export function LanguageProvider({ children }: { children: ReactNode }) {
|
||||
const [language, setLanguageState] = useState<Language>(initialLanguage)
|
||||
|
||||
const setLanguageLocal = (next: Language) => {
|
||||
setLanguageState(next)
|
||||
}
|
||||
|
||||
const setLanguage = (next: Language) => {
|
||||
setLanguageLocal(next)
|
||||
updateLanguage(next).catch(() => {})
|
||||
}
|
||||
|
||||
const value = useMemo<LanguageContextValue>(() => ({
|
||||
language,
|
||||
setLanguage,
|
||||
toggleLanguage: async () => {
|
||||
const next = language === 'zh' ? 'en' : 'zh'
|
||||
setLanguageLocal(next)
|
||||
try {
|
||||
const res = await updateLanguage(next)
|
||||
setLanguageLocal(res.data.data?.language || next)
|
||||
} catch {
|
||||
setLanguageLocal(language)
|
||||
}
|
||||
},
|
||||
t: (text: string) => language === 'en' ? translateText(text) : text,
|
||||
}), [language])
|
||||
|
||||
useEffect(() => {
|
||||
getLanguage()
|
||||
.then((res) => {
|
||||
const serverLanguage = res.data.data?.language
|
||||
if (serverLanguage === 'zh' || serverLanguage === 'en') {
|
||||
setLanguageLocal(serverLanguage)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language === 'en' ? 'en' : 'zh-CN'
|
||||
document.documentElement.dataset.language = language
|
||||
}, [language])
|
||||
|
||||
return <LanguageContext.Provider value={value}>{children}</LanguageContext.Provider>
|
||||
}
|
||||
|
||||
export function useLanguage() {
|
||||
const context = useContext(LanguageContext)
|
||||
if (!context) {
|
||||
throw new Error('useLanguage must be used within LanguageProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import { ThemeProvider } from './contexts/ThemeContext'
|
||||
import { LanguageProvider } from './contexts/LanguageContext'
|
||||
import { DialogProvider } from './components/Dialog'
|
||||
import './index.css'
|
||||
|
||||
@@ -11,11 +12,13 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<DialogProvider>
|
||||
<App />
|
||||
</DialogProvider>
|
||||
</AuthProvider>
|
||||
<LanguageProvider>
|
||||
<AuthProvider>
|
||||
<DialogProvider>
|
||||
<App />
|
||||
</DialogProvider>
|
||||
</AuthProvider>
|
||||
</LanguageProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
declare module '@novnc/novnc' {
|
||||
declare module '@novnc/novnc/lib/rfb' {
|
||||
export default class RFB extends EventTarget {
|
||||
constructor(target: HTMLElement, url: string, options?: { credentials?: Record<string, string>; shared?: boolean; repeaterID?: string; wsProtocols?: string[] })
|
||||
scaleViewport: boolean
|
||||
|
||||
@@ -2,9 +2,21 @@ import { FormEvent, useState } from 'react'
|
||||
import { Lock, User } from 'lucide-react'
|
||||
import AppIcon from '../components/AppIcon'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import AutoTranslate from '../components/AutoTranslate'
|
||||
import BrowserDialogTranslator from '../components/BrowserDialogTranslator'
|
||||
|
||||
function LanguageIcon({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M213.333333 640v85.333333a85.333333 85.333333 0 0 0 78.933334 85.12L298.666667 810.666667h128v85.333333H298.666667a170.666667 170.666667 0 0 1-170.666667-170.666667v-85.333333h85.333333z m554.666667-213.333333l187.733333 469.333333h-91.946666l-51.242667-128h-174.506667l-51.157333 128h-91.904L682.666667 426.666667h85.333333z m-42.666667 123.093333L672.128 682.666667h106.325333L725.333333 549.76zM341.333333 85.333333v85.333334h170.666667v298.666666H341.333333v128H256v-128H85.333333V170.666667h170.666667V85.333333h85.333333z m384 42.666667a170.666667 170.666667 0 0 1 170.666667 170.666667v85.333333h-85.333333V298.666667a85.333333 85.333333 0 0 0-85.333334-85.333334h-128V128h128zM256 256H170.666667v128h85.333333V256z m170.666667 0H341.333333v128h85.333334V256z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const { login, accessCodeLogin } = useAuth()
|
||||
const { language, toggleLanguage, t } = useLanguage()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
@@ -29,7 +41,7 @@ export default function Login() {
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { message?: string } } }
|
||||
setError(error.response?.data?.message || '登录失败,请检查用户名和密码')
|
||||
setError(error.response?.data?.message || t('登录失败,请检查用户名和密码'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -37,6 +49,16 @@ export default function Login() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<AutoTranslate />
|
||||
<BrowserDialogTranslator />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void toggleLanguage() }}
|
||||
className="absolute right-4 top-4 inline-flex items-center gap-1.5 rounded-md border border-gray-200 bg-white px-3 py-1.5 text-xs font-medium text-gray-600 shadow-sm hover:bg-gray-50"
|
||||
>
|
||||
<LanguageIcon className="h-3.5 w-3.5" />
|
||||
{language === 'en' ? '中文' : 'English'}
|
||||
</button>
|
||||
<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">
|
||||
@@ -106,7 +128,7 @@ export default function Login() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.7</p>
|
||||
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.9</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -714,6 +714,15 @@ export const createWebSSHTicket = (containerName: string) =>
|
||||
export const createVNCTicket = (containerName: string) =>
|
||||
api.post<APIResponse<{ ticket: string }>>('/vnc-ticket', { container_name: containerName })
|
||||
|
||||
// Language
|
||||
export type PanelLanguage = 'zh' | 'en'
|
||||
|
||||
export const getLanguage = () =>
|
||||
api.get<APIResponse<{ language: PanelLanguage }>>('/language')
|
||||
|
||||
export const updateLanguage = (language: PanelLanguage) =>
|
||||
api.post<APIResponse<{ language: PanelLanguage }>>('/language', { language })
|
||||
|
||||
// Version
|
||||
export const getVersion = () =>
|
||||
api.get<APIResponse<{ version: string }>>('/version')
|
||||
|
||||
@@ -0,0 +1,910 @@
|
||||
const exact: Record<string, string> = {
|
||||
'控制面板': 'Dashboard',
|
||||
'共': 'Total',
|
||||
'第': 'Page',
|
||||
'页': 'page',
|
||||
'个': 'items',
|
||||
'条': 'records',
|
||||
'核': 'cores',
|
||||
'个容器': 'containers',
|
||||
'条操作记录': 'audit records',
|
||||
'个地址': 'addresses',
|
||||
'列表': 'List',
|
||||
'主机资源': 'Host Resources',
|
||||
'主机资源状态': 'Host Resource Status',
|
||||
'容器管理': 'Containers',
|
||||
'镜像管理': 'Images',
|
||||
'安全告警': 'Security Alerts',
|
||||
'快照管理': 'Snapshots',
|
||||
'路由管理': 'Routing',
|
||||
'操作日志': 'Audit Logs',
|
||||
'子用户管理': 'Sub Users',
|
||||
'API 集成': 'API Integration',
|
||||
'宿主机信息': 'Host Info',
|
||||
'面板设置': 'Panel Settings',
|
||||
'退出登录': 'Log out',
|
||||
'亮色模式': 'Light Mode',
|
||||
'暗黑模式': 'Dark Mode',
|
||||
'切换亮色模式': 'Switch to light mode',
|
||||
'切换暗黑模式': 'Switch to dark mode',
|
||||
'切换侧边栏': 'Toggle sidebar',
|
||||
'刷新': 'Refresh',
|
||||
'搜索': 'Search',
|
||||
'复制': 'Copy',
|
||||
'编辑': 'Edit',
|
||||
'删除': 'Delete',
|
||||
'保存': 'Save',
|
||||
'提交': 'Submit',
|
||||
'应用': 'Apply',
|
||||
'查看': 'View',
|
||||
'详情': 'Details',
|
||||
'返回': 'Back',
|
||||
'返回列表': 'Back to list',
|
||||
'取消': 'Cancel',
|
||||
'确认': 'Confirm',
|
||||
'确定': 'OK',
|
||||
'完成': 'Done',
|
||||
'失败': 'Failed',
|
||||
'成功': 'Success',
|
||||
'提示': 'Notice',
|
||||
'警告': 'Warning',
|
||||
'开机': 'Start',
|
||||
'关机': 'Stop',
|
||||
'重启': 'Restart',
|
||||
'重装': 'Reinstall',
|
||||
'创建': 'Create',
|
||||
'在线': 'Online',
|
||||
'离线': 'Offline',
|
||||
'永久': 'Permanent',
|
||||
'长期有效': 'No expiration',
|
||||
'长期': 'No expiration',
|
||||
'不限制': 'Unlimited',
|
||||
'未设置流量限制': 'No traffic limit set',
|
||||
'未设置': 'Not set',
|
||||
'已选': 'Selected',
|
||||
',已选': ', selected',
|
||||
'筛选后': 'Filtered',
|
||||
',筛选后': ', filtered',
|
||||
'每页数量': 'Items per page',
|
||||
'任务中': 'In task',
|
||||
'1周': '1 week',
|
||||
'1 周': '1 week',
|
||||
'资源配置': 'Resource Configuration',
|
||||
'实时状态': 'Live Status',
|
||||
'连接信息': 'Connection Info',
|
||||
'管理链接': 'Management Link',
|
||||
'NAT 管理': 'NAT Management',
|
||||
'快照': 'Snapshots',
|
||||
'系统': 'System',
|
||||
'全部类型': 'All types',
|
||||
'全部系统': 'All systems',
|
||||
'全部状态': 'All statuses',
|
||||
'类型筛选': 'Type filter',
|
||||
'系统筛选': 'System filter',
|
||||
'状态筛选': 'Status filter',
|
||||
'内网': 'Private IP',
|
||||
'内网 IP': 'Private IP',
|
||||
'策略封禁': 'Policy Blocked',
|
||||
'已封禁': 'Blocked',
|
||||
'已到期': 'Expired',
|
||||
'识别码': 'Identifier',
|
||||
'CPU 累计时间': 'CPU Total Time',
|
||||
'创建时间': 'Created At',
|
||||
'网络速率': 'Network Speed',
|
||||
'IO 速度': 'IO Speed',
|
||||
'月流量': 'Monthly Traffic',
|
||||
'统计信息': 'Statistics',
|
||||
'CPU 使用率': 'CPU Usage',
|
||||
'内存使用': 'Memory Usage',
|
||||
'网络流量': 'Network Traffic',
|
||||
'磁盘IO': 'Disk IO',
|
||||
'磁盘 IO': 'Disk IO',
|
||||
'负载': 'Load',
|
||||
'平均': 'Average',
|
||||
'峰值': 'Peak',
|
||||
'容量': 'Capacity',
|
||||
'累计': 'Total',
|
||||
'读': 'Read',
|
||||
'写': 'Write',
|
||||
'入': 'In',
|
||||
'出': 'Out',
|
||||
'运行中': 'Running',
|
||||
'已停止': 'Stopped',
|
||||
'已完成': 'Completed',
|
||||
'等待中': 'Pending',
|
||||
'执行中': 'Running',
|
||||
'未知': 'Unknown',
|
||||
'必要': 'Required',
|
||||
'可选': 'Optional',
|
||||
'用户名': 'Username',
|
||||
'密码': 'Password',
|
||||
'输入用户名': 'Enter username',
|
||||
'输入密码': 'Enter password',
|
||||
'登录': 'Log in',
|
||||
'登录中...': 'Logging in...',
|
||||
'登录失败,请检查用户名和密码': 'Login failed. Check your username and password.',
|
||||
'Authentication required': 'Authentication required',
|
||||
'Administrator permission required': 'Administrator permission required',
|
||||
'Method not allowed': 'Method not allowed',
|
||||
'Invalid request body': 'Invalid request body',
|
||||
'Invalid credentials': 'Invalid credentials',
|
||||
'Access denied': 'Access denied',
|
||||
'Access denied to this container': 'Access denied to this container',
|
||||
'Container not found': 'Container not found',
|
||||
'Template not found': 'Template not found',
|
||||
'Template is required': 'Template is required',
|
||||
'Template is not enabled or downloaded': 'Template is not enabled or downloaded',
|
||||
'Container name is required': 'Container name is required',
|
||||
'Container created successfully': 'Container created successfully',
|
||||
'Password changed successfully': 'Password changed successfully',
|
||||
'SSL settings saved': 'SSL settings saved',
|
||||
'Save SSL settings failed': 'Save SSL settings failed',
|
||||
'Task deleted': 'Task deleted',
|
||||
'Snapshot deleted': 'Snapshot deleted',
|
||||
'Snapshot restored': 'Snapshot restored',
|
||||
'Security check completed': 'Security check completed',
|
||||
'当前密码不正确': 'Current password is incorrect',
|
||||
'密码不正确': 'Password is incorrect',
|
||||
'新密码至少 6 位': 'New password must be at least 6 characters',
|
||||
'用户名至少 3 位': 'Username must be at least 3 characters',
|
||||
'密码加密失败': 'Failed to hash password',
|
||||
'保存配置失败': 'Failed to save configuration',
|
||||
'密码修改成功': 'Password changed successfully',
|
||||
'用户名修改成功': 'Username changed successfully',
|
||||
'容器已到期,不允许此操作': 'Container has expired. This action is not allowed.',
|
||||
'容器管理登录': 'Container Access Login',
|
||||
'操作失败': 'Action failed',
|
||||
'错误': 'Error',
|
||||
'保存失败': 'Save failed',
|
||||
'重装失败': 'Reinstall failed',
|
||||
'密码重置失败': 'Password reset failed',
|
||||
'端口配额已满': 'Port quota reached',
|
||||
'输入错误': 'Input error',
|
||||
'请输入有效的内部端口': 'Enter a valid internal port',
|
||||
'密码长度必须为 8-64 位': 'Password length must be 8-64 characters',
|
||||
'密码不能包含空白字符': 'Password cannot contain whitespace',
|
||||
'密码至少需要包含字母': 'Password must contain at least one letter',
|
||||
'密码至少需要包含数字': 'Password must contain at least one number',
|
||||
'密码格式不正确': 'Invalid password format',
|
||||
'策略临时封禁': 'Temporarily blocked by policy',
|
||||
'虚拟机被策略临时封禁,暂不能执行操作。': 'This VM is temporarily blocked by policy and cannot perform actions.',
|
||||
'确定要删除容器': 'Delete container',
|
||||
'吗?此操作不可撤销。': '? This action cannot be undone.',
|
||||
'容器名称不能包含空格': 'Container name cannot contain spaces',
|
||||
'该容器名称已存在': 'Container name already exists',
|
||||
'请填写容器名称并选择系统模板': 'Enter a container name and select a system template',
|
||||
'资源配置有误': 'Invalid resource configuration',
|
||||
'请按红色提示修改 vCPU、内存或磁盘配置': 'Fix the vCPU, memory, or disk fields marked in red',
|
||||
'创建失败': 'Create failed',
|
||||
'创建新容器': 'Create New Container',
|
||||
'批量创建数量': 'Batch Count',
|
||||
'虚拟化架构': 'Virtualization',
|
||||
'LXC 容器': 'LXC Container',
|
||||
'KVM 虚拟机': 'KVM VM',
|
||||
'系统模板': 'System Template',
|
||||
'搜索名称、ID、UUID、IP': 'Search name, ID, UUID, IP',
|
||||
'带宽 (Mbps)': 'Bandwidth (Mbps)',
|
||||
'双向统计': 'Total In+Out',
|
||||
'入/出分离': 'Separate In/Out',
|
||||
'GB (0=不限制)': 'GB (0=unlimited)',
|
||||
'入站 (GB)': 'Inbound (GB)',
|
||||
'出站 (GB)': 'Outbound (GB)',
|
||||
'NAT 端口映射数量': 'NAT Port Mapping Count',
|
||||
'子用户快照上限': 'Sub-user Snapshot Limit',
|
||||
'到期时间': 'Expiration Time',
|
||||
'不选择则长期有效;选择日期后,到期会自动关机。': 'Leave blank for no expiration. If a date is selected, the container will shut down automatically when it expires.',
|
||||
'创建中...': 'Creating...',
|
||||
'请输入 vCPU': 'Enter vCPU',
|
||||
'内存 (MB)': 'Memory (MB)',
|
||||
'磁盘 (GB)': 'Disk (GB)',
|
||||
'IO 速度 (MB/s)': 'IO Speed (MB/s)',
|
||||
'将创建': 'Will create',
|
||||
'暂无可用的': 'No available',
|
||||
'不能小于': 'Cannot be less than',
|
||||
'不能大于': 'Cannot be greater than',
|
||||
'KVM vCPU 必须是整数': 'KVM vCPU must be an integer',
|
||||
'请输入内存': 'Enter memory',
|
||||
'请输入磁盘': 'Enter disk',
|
||||
'轮换失败': 'Rotation failed',
|
||||
'获取操作日志失败': 'Failed to load audit logs',
|
||||
'获取登录日志失败': 'Failed to load login logs',
|
||||
'暂无子用户': 'No sub-users',
|
||||
'容器名称': 'Container Name',
|
||||
'最后登录': 'Last Login',
|
||||
'从未登录': 'Never logged in',
|
||||
'查看密码': 'View Password',
|
||||
'查看操作日志': 'View Audit Logs',
|
||||
'查看登录日志': 'View Login Logs',
|
||||
'轮换密码': 'Rotate Password',
|
||||
'轮换中...': 'Rotating...',
|
||||
'用户': 'User',
|
||||
'未保存,请轮换生成新密码': 'Not saved. Rotate to generate a new password.',
|
||||
'操作时间': 'Action Time',
|
||||
'登录时间': 'Login Time',
|
||||
'登录 IP': 'Login IP',
|
||||
'请输入当前密码以确认修改': 'Enter current password to confirm changes',
|
||||
'至少填写新密码或新用户名中的一项': 'Enter at least a new password or a new username',
|
||||
'用户名已修改': 'Username changed',
|
||||
'用户名修改失败': 'Username change failed',
|
||||
'密码已修改': 'Password changed',
|
||||
'密码修改失败': 'Password change failed',
|
||||
'下次登录生效': 'Takes effect at next login',
|
||||
'修改失败': 'Change failed',
|
||||
'账号、安全证书与登录日志': 'Account, certificates, and login logs',
|
||||
'SSL 设置已保存,服务正在重启。稍后请用新的协议重新打开面板。': 'SSL settings saved. The service is restarting. Reopen the panel with the new protocol shortly.',
|
||||
'SSL 设置已保存,重启 clicd 服务后生效。': 'SSL settings saved. Restart the clicd service to apply them.',
|
||||
'SSL 设置保存失败': 'Failed to save SSL settings',
|
||||
'纯 IP 证书需要服务器安装 Certbot 5.4+,且验证时 80 端口必须能被 Let’s Encrypt 访问。IP 证书是短有效期证书,certbot 需要保持自动续签。': 'Pure IP certificates require Certbot 5.4+ on the server, and port 80 must be reachable by Let’s Encrypt during validation. IP certificates are short-lived, so certbot auto-renewal must remain enabled.',
|
||||
'自签证书可以加密面板和 VNC,但浏览器会提示证书不受信任;证书快到期时系统会自动重新签发。': 'Self-signed certificates can encrypt the panel and VNC, but browsers will show an untrusted certificate warning. The system will renew them automatically before expiration.',
|
||||
'上传来源还没有保存证书,请粘贴证书和私钥后保存。': 'No certificate has been saved for the uploaded source. Paste the certificate and private key, then save.',
|
||||
'当前来源还没有保存证书,保存 SSL 设置时会自动生成或申请。': 'No certificate has been saved for the current source. It will be generated or requested when SSL settings are saved.',
|
||||
'暂无容器': 'No containers',
|
||||
'暂无快照': 'No snapshots',
|
||||
'暂无操作日志': 'No audit logs',
|
||||
'暂无登录日志': 'No login logs',
|
||||
'暂无登录记录': 'No login records',
|
||||
'暂无 NAT4 端口映射': 'No NAT4 port mappings',
|
||||
'暂无 IPv6 地址分配': 'No IPv6 assignments',
|
||||
'暂无镜像': 'No images',
|
||||
'暂无数据': 'No data',
|
||||
'容器': 'Container',
|
||||
'名称': 'Name',
|
||||
'状态': 'Status',
|
||||
'剩余时间': 'Time Left',
|
||||
'配置': 'Config',
|
||||
'镜像': 'Image',
|
||||
'内存': 'Memory',
|
||||
'磁盘': 'Disk',
|
||||
'流量': 'Traffic',
|
||||
'操作': 'Actions',
|
||||
'类型': 'Type',
|
||||
'创建者': 'Creator',
|
||||
'管理员密码': 'Admin Password',
|
||||
'SSH 密码': 'SSH Password',
|
||||
'SSH 地址': 'SSH Address',
|
||||
'RDP 地址': 'RDP Address',
|
||||
'VNC 端口': 'VNC Port',
|
||||
'点击隐藏': 'Click to hide',
|
||||
'点击显示': 'Click to show',
|
||||
'编辑资源限制': 'Edit resource limits',
|
||||
'编辑流量限制': 'Edit traffic limit',
|
||||
'修改到期时间': 'Change expiration time',
|
||||
'新 SSH 密码': 'New SSH Password',
|
||||
'生成随机密码': 'Generate random password',
|
||||
'密码已修改成功': 'Password changed successfully',
|
||||
'修改中...': 'Changing...',
|
||||
'确认修改': 'Confirm Change',
|
||||
'容器不存在': 'Container not found',
|
||||
'容器未运行,请先开机': 'Container is not running. Start it first.',
|
||||
'VNC 控制台暂不可用,请确认 KVM 虚拟机已开机并刷新页面': 'VNC console is unavailable. Make sure the KVM VM is running and refresh the page.',
|
||||
'虚拟机被策略临时封禁': 'VM temporarily blocked by policy',
|
||||
'虚拟机被策略临时封禁,连接信息暂不可用。': 'This VM is temporarily blocked by policy. Connection info is unavailable.',
|
||||
'已达到管理员分配的 NAT 端口配额。': 'The NAT port quota assigned by the administrator has been reached.',
|
||||
'保存端口映射失败': 'Failed to save port mapping',
|
||||
'删除端口映射失败': 'Failed to delete port mapping',
|
||||
'删除映射': 'Delete Mapping',
|
||||
'确定要删除这条映射规则吗?': 'Delete this mapping rule?',
|
||||
'快照配额已满': 'Snapshot quota reached',
|
||||
'已达到管理员设置的快照配额,请先删除旧快照。': 'The snapshot quota set by the administrator has been reached. Delete old snapshots first.',
|
||||
'拍摄快照': 'Take Snapshot',
|
||||
'拍摄快照需要先关机,完成后会自动重启容器': 'Taking a snapshot requires shutdown first. The container will restart automatically afterward',
|
||||
'是否继续?': 'Continue?',
|
||||
'创建快照失败': 'Failed to create snapshot',
|
||||
'参数错误': 'Invalid parameters',
|
||||
'自动快照周期最低是 1 天一次。': 'The minimum automatic snapshot interval is once per day.',
|
||||
'定时快照失败': 'Scheduled snapshot failed',
|
||||
'保存快照配额失败': 'Failed to save snapshot quota',
|
||||
'删除快照失败': 'Failed to delete snapshot',
|
||||
'恢复快照失败': 'Failed to restore snapshot',
|
||||
'确定恢复到': 'Restore to',
|
||||
'当前容器数据会被覆盖。': 'Current container data will be overwritten.',
|
||||
'确定删除': 'Delete',
|
||||
'的快照吗?': 'snapshot?',
|
||||
'新建快照': 'New Snapshot',
|
||||
'定时设置': 'Schedule Settings',
|
||||
'定时快照': 'Scheduled Snapshot',
|
||||
'处理中...': 'Processing...',
|
||||
'快照数量:': 'Snapshot count:',
|
||||
'子用户配额:': 'Sub-user quota:',
|
||||
'定时状态:': 'Schedule status:',
|
||||
'下次执行:': 'Next run:',
|
||||
'未开启': 'Off',
|
||||
'已开启': 'On',
|
||||
'每': 'Every',
|
||||
'执行': 'run',
|
||||
'子用户每台容器快照上限': 'Sub-user snapshot limit per container',
|
||||
'自动快照周期': 'Automatic snapshot interval',
|
||||
'天': 'days',
|
||||
'小时': 'hours',
|
||||
'分钟': 'minutes',
|
||||
'秒': 'seconds',
|
||||
'大小': 'Size',
|
||||
'手动': 'Manual',
|
||||
'定时': 'Scheduled',
|
||||
'时间': 'Time',
|
||||
'设备': 'Device',
|
||||
'结果': 'Result',
|
||||
'地址': 'Address',
|
||||
'前缀': 'Prefix',
|
||||
'出口网卡': 'Uplink',
|
||||
'协议': 'Protocol',
|
||||
'说明': 'Description',
|
||||
'端口': 'Port',
|
||||
'容器端口': 'Container Port',
|
||||
'宿主机端口': 'Host Port',
|
||||
'容器 IPv4': 'Container IPv4',
|
||||
'IPv6 地址': 'IPv6 Address',
|
||||
'LXC 名称': 'LXC Name',
|
||||
'快照时间': 'Snapshot Time',
|
||||
'删除快照': 'Delete Snapshot',
|
||||
'全局快照列表': 'Global snapshot list',
|
||||
'主机名': 'Hostname',
|
||||
'操作系统': 'Operating System',
|
||||
'内核': 'Kernel',
|
||||
'生成时间': 'Generated At',
|
||||
'系统概览': 'System Overview',
|
||||
'公网与路由': 'Public Network & Routing',
|
||||
'内存条': 'Memory Modules',
|
||||
'硬盘与健康': 'Disks & Health',
|
||||
'网卡': 'Network Interfaces',
|
||||
'显卡': 'GPUs',
|
||||
'环境支持': 'Environment Support',
|
||||
'服务管理器 systemd/OpenRC': 'Service Manager systemd/OpenRC',
|
||||
'LXC 创建工具': 'LXC Create Tool',
|
||||
'LXC 启动工具': 'LXC Start Tool',
|
||||
'iptables 网络规则': 'iptables Network Rules',
|
||||
'iproute2 网络工具': 'iproute2 Network Tool',
|
||||
'conntrack 安全扫描': 'conntrack Security Scan',
|
||||
'QEMU/KVM 虚拟机': 'QEMU/KVM Virtualization',
|
||||
'KVM cloud-init ISO 工具': 'KVM cloud-init ISO Tool',
|
||||
'ISO 备用工具': 'ISO Fallback Tool',
|
||||
'硬盘健康检测': 'Disk Health Check',
|
||||
'Certbot 证书工具 >= 5.4': 'Certbot Certificate Tool >= 5.4',
|
||||
'/dev/kvm 硬件虚拟化': '/dev/kvm Hardware Virtualization',
|
||||
'IPv4 转发': 'IPv4 Forwarding',
|
||||
'lxcfs 服务': 'lxcfs Service',
|
||||
'libvirt 服务': 'libvirt Service',
|
||||
'正在探测宿主机环境...': 'Probing host environment...',
|
||||
'暂未获取到宿主机信息': 'No host information available',
|
||||
'面板资源状态与容器概览': 'Panel resource status and container overview',
|
||||
'宿主机资源状态与容器概览': 'Host resource status and container overview',
|
||||
'账号设置': 'Account Settings',
|
||||
'当前用户名': 'Current Username',
|
||||
'新用户名,留空则不修改': 'New Username, leave blank to keep unchanged',
|
||||
'新密码,留空则不修改': 'New Password, leave blank to keep unchanged',
|
||||
'当前密码,验证身份': 'Current Password, for verification',
|
||||
'至少 3 位': 'At least 3 characters',
|
||||
'至少 6 位': 'At least 6 characters',
|
||||
'输入当前密码以确认修改': 'Enter current password to confirm changes',
|
||||
'保存修改': 'Save Changes',
|
||||
'SSL 证书': 'SSL Certificate',
|
||||
'启用 HTTPS / WSS': 'Enable HTTPS / WSS',
|
||||
'IP / 域名': 'IP / Domain',
|
||||
'服务器公网 IP 或域名': 'Server public IP or domain',
|
||||
'邮箱,可选': 'Email, optional',
|
||||
'自签证书': 'Self-signed Certificate',
|
||||
'上传证书': 'Uploaded Certificate',
|
||||
'证书 PEM / fullchain.pem': 'Certificate PEM / fullchain.pem',
|
||||
'私钥 PEM / privkey.pem': 'Private Key PEM / privkey.pem',
|
||||
'保存后自动重启服务并立即生效': 'Restart service automatically after saving',
|
||||
'保存中...': 'Saving...',
|
||||
'保存 SSL 设置': 'Save SSL Settings',
|
||||
'登录日志': 'Login Logs',
|
||||
'首页': 'First',
|
||||
'上一页': 'Previous',
|
||||
'下一页': 'Next',
|
||||
'末页': 'Last',
|
||||
'搜索端口/容器...': 'Search port/container...',
|
||||
'搜索地址/容器...': 'Search address/container...',
|
||||
'NAT4 端口': 'NAT4 Ports',
|
||||
'NAT4 端口分配': 'NAT4 Port Allocation',
|
||||
'IPv6 地址分配': 'IPv6 Address Allocation',
|
||||
'剩余端口 / 端口总数': 'Available Ports / Total Ports',
|
||||
'剩余地址 / 地址总数': 'Available Addresses / Total Addresses',
|
||||
'已分配': 'Allocated',
|
||||
'充足': 'Enough',
|
||||
'容器列表': 'Container List',
|
||||
'刷新列表': 'Refresh list',
|
||||
'创建容器': 'Create Container',
|
||||
'点击"创建容器"开始': 'Click "Create Container" to start',
|
||||
'创建中': 'Creating',
|
||||
'批量创建': 'Batch Create',
|
||||
'导入容器': 'Import Container',
|
||||
'重置密码': 'Reset Password',
|
||||
'WebSSH': 'WebSSH',
|
||||
'WebVNC': 'WebVNC',
|
||||
'发送 Ctrl+Alt+Del': 'Send Ctrl+Alt+Del',
|
||||
'重新连接': 'Reconnect',
|
||||
'关闭': 'Close',
|
||||
'已连接': 'Connected',
|
||||
'连接中...': 'Connecting...',
|
||||
'已断开': 'Disconnected',
|
||||
'连接失败': 'Connection Failed',
|
||||
'正在连接 KVM VNC 控制台...': 'Connecting to KVM VNC console...',
|
||||
'WebVNC 已断开': 'WebVNC disconnected',
|
||||
'下载': 'Download',
|
||||
'下载中': 'Downloading',
|
||||
'启用': 'Enable',
|
||||
'停用': 'Disable',
|
||||
'已启用': 'Enabled',
|
||||
'未启用': 'Disabled',
|
||||
'系统镜像': 'System Images',
|
||||
'安全检查': 'Security Check',
|
||||
'告警列表': 'Alert List',
|
||||
'自动关机已开': 'Auto-stop on',
|
||||
'自动关机已关': 'Auto-stop off',
|
||||
'暂无安全告警': 'No security alerts',
|
||||
'严重': 'Critical',
|
||||
'高': 'High',
|
||||
'中': 'Medium',
|
||||
'低': 'Low',
|
||||
'管理员': 'Admin',
|
||||
'子用户': 'Sub User',
|
||||
'公网 IPv4': 'Public IPv4',
|
||||
'IPv4 地址': 'IPv4 Addresses',
|
||||
'IPv4 段': 'IPv4 Prefixes',
|
||||
'IPv6 段': 'IPv6 Prefixes',
|
||||
'网关': 'Gateways',
|
||||
'CPU 架构': 'CPU Architecture',
|
||||
'CPU 虚拟化指令': 'CPU Virtualization Flags',
|
||||
'CPU 核显': 'Integrated GPU',
|
||||
'运行能力': 'Runtime Capability',
|
||||
'KVM 嵌套虚拟化': 'KVM Nested Virtualization',
|
||||
'支持': 'Supported',
|
||||
'未检测到': 'Not detected',
|
||||
'检测到': 'Detected',
|
||||
'有效': 'Valid',
|
||||
'已过期或未生效': 'Expired or not active',
|
||||
'是': 'Yes',
|
||||
'否': 'No',
|
||||
'开启': 'On',
|
||||
'已关闭': 'Off',
|
||||
'自动': 'Auto',
|
||||
'默认': 'Default',
|
||||
'全部': 'All',
|
||||
'无': 'None',
|
||||
'根目录': 'Root',
|
||||
'版本': 'Version',
|
||||
'当前': 'Current',
|
||||
'最近': 'Recent',
|
||||
'来源': 'Source',
|
||||
'目标': 'Target',
|
||||
'描述': 'Description',
|
||||
'备注': 'Notes',
|
||||
'搜索容器...': 'Search containers...',
|
||||
'搜索镜像...': 'Search images...',
|
||||
'搜索日志...': 'Search logs...',
|
||||
'复制成功': 'Copied',
|
||||
'复制失败': 'Copy failed',
|
||||
'请稍后重试': 'Please try again later',
|
||||
'请稍后重试。': 'Please try again later.',
|
||||
'开机中...': 'Starting...',
|
||||
'关机中...': 'Stopping...',
|
||||
'重启中...': 'Restarting...',
|
||||
'删除中...': 'Deleting...',
|
||||
'重装中...': 'Reinstalling...',
|
||||
'开机中': 'Starting',
|
||||
'关机中': 'Stopping',
|
||||
'重启中': 'Restarting',
|
||||
'删除中': 'Deleting',
|
||||
'重装中': 'Reinstalling',
|
||||
'正在初始化': 'Initializing',
|
||||
'容器总数': 'Total Containers',
|
||||
'驱动/速率': 'Driver / Speed',
|
||||
'支持 KVM + LXC': 'KVM + LXC supported',
|
||||
'仅支持 LXC': 'LXC only',
|
||||
'未满足运行环境': 'Runtime requirements not met',
|
||||
'健康': 'Healthy',
|
||||
'异常': 'Abnormal',
|
||||
'核显': 'Integrated',
|
||||
'独显': 'Discrete',
|
||||
'获取镜像列表失败': 'Failed to load image list',
|
||||
'下载失败': 'Download failed',
|
||||
'删除失败': 'Delete failed',
|
||||
'取消失败': 'Cancel failed',
|
||||
'删除镜像': 'Delete Image',
|
||||
'确定要删除该镜像缓存吗?删除后需要重新下载才能使用。': 'Delete this image cache? You must download it again before using it.',
|
||||
'取消下载并清理临时文件': 'Cancel download and clean temporary files',
|
||||
'删除镜像缓存': 'Delete image cache',
|
||||
'取消中': 'Cancelling',
|
||||
'取消中...': 'Cancelling...',
|
||||
'下载中...': 'Downloading...',
|
||||
'阶段:': 'Stage:',
|
||||
'转换中': 'Converting',
|
||||
'端口扫描': 'Port scan',
|
||||
'横向扫描': 'Lateral scan',
|
||||
'暴力破解': 'Brute force',
|
||||
'DDoS/大规模扫描': 'DDoS / large-scale scan',
|
||||
'垃圾邮件': 'Spam',
|
||||
'恶意软件': 'Malware',
|
||||
'挖矿连接': 'Mining connection',
|
||||
'代理/VPN/Tor': 'Proxy / VPN / Tor',
|
||||
'UDP反射放大': 'UDP reflection amplification',
|
||||
'高危': 'High risk',
|
||||
'中危': 'Medium risk',
|
||||
'低危': 'Low risk',
|
||||
'告警自动关机': 'Auto shutdown on alerts',
|
||||
'相关连接记录': 'Related Connection Records',
|
||||
'查看相关记录': 'View related records',
|
||||
'告警原始记录': 'Raw Alert Record',
|
||||
'正在加载连接记录...': 'Loading connection records...',
|
||||
'暂无可用连接记录。历史告警对应的 conntrack 记录可能已经过期。': 'No connection records available. Conntrack records for historical alerts may have expired.',
|
||||
'源地址': 'Source Address',
|
||||
'目标地址': 'Target Address',
|
||||
'源IP': 'Source IP',
|
||||
'次数': 'Count',
|
||||
'等级': 'Severity',
|
||||
'总览与只读': 'Overview & Read-only',
|
||||
'路由信息': 'Routing Info',
|
||||
'IPv6 状态': 'IPv6 Status',
|
||||
'镜像列表': 'Image List',
|
||||
'查看容器': 'View Container',
|
||||
'开关机/重启': 'Power / Restart',
|
||||
'重装系统': 'Reinstall OS',
|
||||
'资源/到期': 'Resources / Expiration',
|
||||
'流量管理': 'Traffic Management',
|
||||
'端口映射': 'Port Mappings',
|
||||
'分配 IPv6': 'Assign IPv6',
|
||||
'快照与终端': 'Snapshots & Terminal',
|
||||
'查看快照': 'View Snapshots',
|
||||
'创建快照': 'Create Snapshot',
|
||||
'恢复快照': 'Restore Snapshot',
|
||||
'计划/配额': 'Schedule / Quota',
|
||||
'平台管理': 'Platform Management',
|
||||
'下载镜像': 'Download Image',
|
||||
'启停镜像': 'Enable / Disable Image',
|
||||
'安全数据': 'Security Data',
|
||||
'安全扫描': 'Security Scan',
|
||||
'安全设置': 'Security Settings',
|
||||
'Swap 信息': 'Swap Info',
|
||||
'Swap 管理': 'Swap Management',
|
||||
'Key 列表': 'Key List',
|
||||
'创建 Key': 'Create Key',
|
||||
'更新 Key': 'Update Key',
|
||||
'删除 Key': 'Delete Key',
|
||||
'总览': 'Overview',
|
||||
'NAT/IPv6 路由': 'NAT / IPv6 Routing',
|
||||
'任务队列': 'Task Queue',
|
||||
'任务列表': 'Task List',
|
||||
'操作记录': 'audit records',
|
||||
'子用户列表': 'Sub-user List',
|
||||
'创建子用户': 'Create Sub-user',
|
||||
'更新子用户': 'Update Sub-user',
|
||||
'管理员接口': 'Admin API',
|
||||
'控制面板统计': 'Dashboard Stats',
|
||||
'立即安全检查': 'Run Security Check',
|
||||
'返回响应样例': 'Response Example',
|
||||
'请求参数': 'Request Parameters',
|
||||
'响应字段': 'Response Fields',
|
||||
'接口地址': 'Endpoint',
|
||||
'请求方法': 'Method',
|
||||
'权限范围': 'Scopes',
|
||||
'绑定容器': 'Bound Containers',
|
||||
'全部容器': 'All Containers',
|
||||
'全权限': 'Full Access',
|
||||
'取消全权限': 'Remove Full Access',
|
||||
'禁用这个 Key': 'Disable this key',
|
||||
'过期时间': 'Expiration Time',
|
||||
'永不过期': 'Never expires',
|
||||
'IP 白名单': 'IP Whitelist',
|
||||
'密钥名称': 'Key Name',
|
||||
'删除任务': 'Delete Task',
|
||||
'容器详情': 'Container Details',
|
||||
'资源用量': 'Resource Usage',
|
||||
'流量统计': 'Traffic Stats',
|
||||
'重置流量': 'Reset Traffic',
|
||||
'调整流量限制': 'Adjust Traffic Limit',
|
||||
'调整资源限制': 'Adjust Resource Limit',
|
||||
'重置 SSH 密码': 'Reset SSH Password',
|
||||
'端口与快照': 'Ports & Snapshots',
|
||||
'随机可用端口': 'Random Available Port',
|
||||
'添加端口映射': 'Add Port Mapping',
|
||||
'更新端口映射': 'Update Port Mapping',
|
||||
'删除端口映射': 'Delete Port Mapping',
|
||||
'快照总览': 'Snapshot Overview',
|
||||
'容器快照': 'Container Snapshots',
|
||||
'计划快照': 'Scheduled Snapshots',
|
||||
'快照配额': 'Snapshot Quota',
|
||||
'模板列表': 'Template List',
|
||||
'取消镜像下载': 'Cancel Image Download',
|
||||
'启用/禁用镜像': 'Enable / Disable Image',
|
||||
'安全连接日志': 'Security Connection Logs',
|
||||
'安全汇总': 'Security Summary',
|
||||
'更新安全设置': 'Update Security Settings',
|
||||
'调整 Swap': 'Adjust Swap',
|
||||
'批量开关机/删除/重装': 'Batch power/delete/reinstall',
|
||||
'账号与日志': 'Account & Logs',
|
||||
'API Key 列表': 'API Key List',
|
||||
'创建 API Key': 'Create API Key',
|
||||
'更新 API Key': 'Update API Key',
|
||||
'删除 API Key': 'Delete API Key',
|
||||
'30分钟': '30 minutes',
|
||||
'1小时': '1 hour',
|
||||
'1天': '1 day',
|
||||
'切换中文': 'Switch to Chinese',
|
||||
'WebSSH ticket 创建失败,请重新登录后再试': 'Failed to create WebSSH ticket. Log in again and retry.',
|
||||
'WebSSH ticket 为空,请重新登录后再试': 'WebSSH ticket is empty. Log in again and retry.',
|
||||
'WebSSH 连接失败,请确认容器已运行且 SSH 服务可用': 'WebSSH connection failed. Make sure the container is running and SSH is available.',
|
||||
'WebVNC ticket 创建失败,请重新登录后再试': 'Failed to create WebVNC ticket. Log in again and retry.',
|
||||
'WebVNC ticket 为空,请重新登录后再试': 'WebVNC ticket is empty. Log in again and retry.',
|
||||
'WebVNC 连接已断开,请确认虚拟机正在运行且 VNC 控制台可用': 'WebVNC disconnected. Make sure the VM is running and the VNC console is available.',
|
||||
'VNC 安全协商失败': 'VNC security negotiation failed',
|
||||
'当前 VNC 控制台要求密码,暂不支持自动输入': 'This VNC console requires a password. Automatic input is not supported yet.',
|
||||
'删除容器': 'Delete Container',
|
||||
'WebSSH 票据': 'WebSSH Ticket',
|
||||
'WebVNC 票据': 'WebVNC Ticket',
|
||||
'容器列表(兼容旧接口)': 'Container List (legacy-compatible API)',
|
||||
'调整到期时间': 'Adjust Expiration Time',
|
||||
'镜像管理列表': 'Image Management List',
|
||||
'批量创建容器': 'Batch Create Containers',
|
||||
'创建 WebSSH 票据': 'Create WebSSH Ticket',
|
||||
'创建 WebVNC 票据': 'Create WebVNC Ticket',
|
||||
'创建子用户链接': 'Create Sub-user Link',
|
||||
'轮换子用户密码': 'Rotate Sub-user Password',
|
||||
'子用户操作日志': 'Sub-user Audit Logs',
|
||||
'子用户登录日志': 'Sub-user Login Logs',
|
||||
'确定删除这个 API Key 吗?': 'Delete this API Key?',
|
||||
'管理外部调用凭据、权限范围与平台 API 文档': 'Manage external credentials, permission scopes, and platform API docs',
|
||||
'新的 API Key 已生成': 'New API Key generated',
|
||||
'已复制': 'Copied',
|
||||
'加载中...': 'Loading...',
|
||||
'暂无 API Key': 'No API Keys',
|
||||
'权限': 'Permissions',
|
||||
'限制': 'Limits',
|
||||
'最后使用': 'Last Used',
|
||||
'已禁用': 'Disabled',
|
||||
'不限 IP': 'Any IP',
|
||||
'从未使用': 'Never used',
|
||||
'API 文档': 'API Docs',
|
||||
'查看使用范例': 'View examples',
|
||||
'Python 使用范例': 'Python example',
|
||||
'编辑 API Key': 'Edit API Key',
|
||||
'CI/CD、计费系统、自动化脚本': 'CI/CD, billing systems, automation scripts',
|
||||
'SWAP 已调整为 16384 MB': 'SWAP adjusted to 16384 MB',
|
||||
'***60秒有效票据***': '***60-second valid ticket***',
|
||||
'WebVNC 仅适用于 KVM 虚拟机;LXC 容器会返回 VNC console is only available for KVM VMs。': 'WebVNC only applies to KVM VMs; LXC containers return "VNC console is only available for KVM VMs".',
|
||||
'该接口会进入任务队列,请随后调用 GET /api/v1/tasks 查看执行状态。': 'This API enters the task queue. Call GET /api/v1/tasks afterward to check execution status.',
|
||||
'样例中的密钥、密码和票据已脱敏;创建类接口的完整密钥只在创建响应中出现一次。': 'Keys, passwords, and tickets in examples are masked. Full secrets from create APIs appear only once in the creation response.',
|
||||
'编辑月流量限制': 'Edit Monthly Traffic Limit',
|
||||
'流量统计模式': 'Traffic Accounting Mode',
|
||||
'双向合并统计': 'Combined In+Out',
|
||||
'入站/出站分开统计': 'Separate Inbound/Outbound',
|
||||
'月流量上限 (GB,0=不限制)': 'Monthly traffic limit (GB, 0=unlimited)',
|
||||
'入站上限 (GB,0=不限制)': 'Inbound limit (GB, 0=unlimited)',
|
||||
'出站上限 (GB,0=不限制)': 'Outbound limit (GB, 0=unlimited)',
|
||||
'请输入 8-64 位,至少包含字母和数字': 'Enter 8-64 characters, including at least letters and numbers',
|
||||
'Linux LXC/KVM 修改 root SSH 密码通常无需重启;KVM 需要虚拟机运行且 guest agent 或 SSH 可用。': 'Changing the root SSH password for Linux LXC/KVM usually does not require a restart. KVM requires the VM to be running and guest agent or SSH to be available.',
|
||||
'退出全屏': 'Exit Fullscreen',
|
||||
'全屏显示': 'Fullscreen',
|
||||
'全屏': 'Fullscreen',
|
||||
'修改': 'Change',
|
||||
'关闭定时': 'Disable Schedule',
|
||||
'执行时间': 'Run Time',
|
||||
'NAT 端口管理': 'NAT Port Management',
|
||||
'添加映射': 'Add Mapping',
|
||||
'端口配额:': 'Port quota:',
|
||||
'已达到管理员分配的 NAT 端口配额': 'The NAT port quota assigned by the administrator has been reached',
|
||||
'修改端口映射': 'Edit Port Mapping',
|
||||
'重装系统会删除容器内所有数据,请谨慎操作。': 'Reinstalling the OS will delete all data in the container. Proceed carefully.',
|
||||
'选择新系统模板': 'Select New System Template',
|
||||
'确认重装': 'Confirm Reinstall',
|
||||
'当前:': 'Current:',
|
||||
'新到期日期(留空为长期有效)': 'New expiration date (leave blank for no expiration)',
|
||||
'vCPU 核数': 'vCPU Cores',
|
||||
'网络速率 (Mbps,0=不限制)': 'Network speed (Mbps, 0=unlimited)',
|
||||
'IO 速度 (MB/s,0=不限制)': 'IO speed (MB/s, 0=unlimited)',
|
||||
'磁盘容量不支持动态修改。修改后运行中的容器会立即应用新的 cgroup 限制。': 'Disk capacity cannot be changed dynamically. Running containers apply the new cgroup limits immediately.',
|
||||
'恢复': 'Restore',
|
||||
'全部 (ALL)': 'All (ALL)',
|
||||
'外部端口': 'External Port',
|
||||
'默认同内部': 'Same as internal by default',
|
||||
'随机空闲端口': 'Random Free Port',
|
||||
'随机': 'Random',
|
||||
'内部端口': 'Internal Port',
|
||||
'例如 80': 'e.g. 80',
|
||||
'暂无端口映射': 'No port mappings',
|
||||
'默认 SSH 映射不能删除': 'Default SSH mapping cannot be deleted',
|
||||
'入站 (RX)': 'Inbound (RX)',
|
||||
'(不限制)': '(unlimited)',
|
||||
'出站 (TX)': 'Outbound (TX)',
|
||||
'已用': 'Used',
|
||||
'重置': 'Reset',
|
||||
'执行中...': 'Running...',
|
||||
'点击': 'Click',
|
||||
'开始': 'Start',
|
||||
'没有匹配的容器': 'No matching containers',
|
||||
'显示': 'Showing',
|
||||
'初始化失败': 'Initialization failed',
|
||||
'初始化完成': 'Initialization complete',
|
||||
'排队等待': 'Queued',
|
||||
'处理中': 'Processing',
|
||||
'未知系统': 'Unknown system',
|
||||
'处理失败': 'Failed',
|
||||
'暂无任务': 'No tasks',
|
||||
'取消任务': 'Cancel Task',
|
||||
'硬件、网络、磁盘健康与运行环境探测报告': 'Hardware, network, disk health, and runtime environment report',
|
||||
'运行状态': 'Runtime Status',
|
||||
'未检测到内存条明细,可能缺少 dmidecode 或权限受限': 'No memory module details detected. dmidecode may be missing or permissions may be limited.',
|
||||
'插槽': 'Slot',
|
||||
'频率': 'Frequency',
|
||||
'厂商': 'Vendor',
|
||||
'型号/序列号': 'Model / Serial',
|
||||
'未检测到硬盘': 'No disks detected',
|
||||
'型号': 'Model',
|
||||
'挂载点': 'Mount Point',
|
||||
'寿命': 'Lifetime',
|
||||
'通电': 'Power-on',
|
||||
'读取': 'Reads',
|
||||
'写入': 'Writes',
|
||||
'命令数': 'Commands',
|
||||
'擦写': 'Erase Count',
|
||||
'未检测到网卡': 'No network interfaces detected',
|
||||
'未检测到显卡': 'No GPUs detected',
|
||||
'驱动': 'Driver',
|
||||
'管理 LXC / KVM 系统镜像,下载后的镜像才能用于创建容器/虚拟机。': 'Manage LXC / KVM system images. Downloaded images can be used to create containers/VMs.',
|
||||
'已下载': 'Downloaded',
|
||||
'LXC 容器镜像': 'LXC Container Images',
|
||||
'KVM 虚拟机镜像': 'KVM VM Images',
|
||||
'发行版': 'Distribution',
|
||||
'架构': 'Architecture',
|
||||
'禁用': 'Disable',
|
||||
'可用': 'Available',
|
||||
'未下载': 'Not downloaded',
|
||||
'中文': 'Chinese',
|
||||
'宿主机分配给 LXC 的 NAT4 端口和 IPv6 地址': 'NAT4 ports and IPv6 addresses assigned to LXC by the host',
|
||||
'确认删除容器': 'Delete container',
|
||||
'剩余地址 / 地址总数 ·': 'Available Addresses / Total Addresses ·',
|
||||
'结果 ': 'Result ',
|
||||
'告警列表 (': 'Alert List (',
|
||||
'当前证书:': 'Current certificate:',
|
||||
'到期时间:': 'Expires:',
|
||||
'证书路径:': 'Certificate path:',
|
||||
'最近错误:': 'Last error:',
|
||||
'1 天': '1 day',
|
||||
'3 天': '3 days',
|
||||
'7 天': '7 days',
|
||||
'14 天': '14 days',
|
||||
'10 / 页': '10 / page',
|
||||
'20 / 页': '20 / page',
|
||||
'50 / 页': '50 / page',
|
||||
'全局快照列表,共': 'Global snapshot list, total',
|
||||
'容器分配的子用户列表,共': 'Sub-user list assigned to containers, total',
|
||||
}
|
||||
|
||||
const artifactPatterns: RegExp[] = [
|
||||
/Back\s*列表/,
|
||||
/SearchName、ID、UUID、IP/,
|
||||
/All(Type|Status|系统)/,
|
||||
/AutoStop\s*已[开关]/,
|
||||
/暂\s*(None|无)\s*Security Alerts/,
|
||||
/Memory\s*使用/,
|
||||
/网络\s*Traffic/,
|
||||
/实时\s*Status/,
|
||||
/Create\s*Time/,
|
||||
/长期\s*Valid/,
|
||||
]
|
||||
|
||||
const replacements: Array<[RegExp, string]> = [
|
||||
[/Back\s*列表/g, 'Back to list'],
|
||||
[/Search\s*名称、ID、UUID、IP/g, 'Search name, ID, UUID, IP'],
|
||||
[/All\s*类型/g, 'All types'],
|
||||
[/All\s*系统/g, 'All systems'],
|
||||
[/All\s*状态/g, 'All statuses'],
|
||||
[/AllType/g, 'All types'],
|
||||
[/All系统/g, 'All systems'],
|
||||
[/AllStatus/g, 'All statuses'],
|
||||
[/SearchName、ID、UUID、IP/g, 'Search name, ID, UUID, IP'],
|
||||
[/AutoStop\s*已关/g, 'Auto-stop off'],
|
||||
[/AutoStop\s*已开/g, 'Auto-stop on'],
|
||||
[/暂\s*None\s*Security Alerts/g, 'No security alerts'],
|
||||
[/暂\s*无\s*Security Alerts/g, 'No security alerts'],
|
||||
[/WebVNC\s*初始化失败(.+)$/g, 'WebVNC initialization failed$1'],
|
||||
[/确定要删除容器\s*(.+?)\s*吗?此操作不可撤销。/g, 'Delete container $1? This action cannot be undone.'],
|
||||
[/确定要删除容器\s*(.+?)\s*吗?此操作不可撤销。/g, 'Delete container $1? This action cannot be undone.'],
|
||||
[/拍摄快照需要先关机,完成后会自动重启容器\s*(.+?)。是否继续?/g, 'Taking a snapshot requires shutdown first. Container $1 will restart automatically afterward. Continue?'],
|
||||
[/确定删除\s*(.+?)\s*的快照吗?/g, 'Delete snapshot $1?'],
|
||||
[/确定恢复到\s*(.+?)\s*的快照吗?当前容器数据会被覆盖。/g, 'Restore to snapshot $1? Current container data will be overwritten.'],
|
||||
[/旧版\s*\/api\/containers\/list\s*已兼容,但新接入请使用\s*GET\s*\/api\/v1\/containers/g, 'Legacy /api/containers/list remains compatible, but new integrations should use GET /api/v1/containers'],
|
||||
[/到期\s*(.+)$/g, 'Expires $1'],
|
||||
[/支持\s*\((.+?)\)/g, 'Supported ($1)'],
|
||||
[/下载中\s*(.+)$/g, 'Downloading $1'],
|
||||
[/结果\s*(.+)$/g, 'Result $1'],
|
||||
[/磨损\s*(.+)$/g, 'Wear $1'],
|
||||
[/擦写\s*(.+)$/g, 'Erase $1'],
|
||||
[/启停\s*(.+)$/g, 'Power cycles $1'],
|
||||
[/每\s*(.+)$/g, 'Every $1'],
|
||||
[/已开启,每\s*(.+)$/g, 'Enabled, every $1'],
|
||||
[/告警列表\s*\((\d+)\)/g, 'Alert List ($1)'],
|
||||
[/共\s*(\d+)\s*个\s*Container/g, 'Total $1 containers'],
|
||||
[/共\s*(\d+)\s*个\s*容器/g, 'Total $1 containers'],
|
||||
[/共\s*(\d+)\s*条/g, 'Total $1'],
|
||||
[/共\s*(\d+)\s*个/g, 'Total $1 items'],
|
||||
[/,筛选后\s*(\d+)\s*个/g, ', filtered $1 items'],
|
||||
[/,已选\s*(\d+)\s*个/g, ', selected $1 items'],
|
||||
[/第\s*(\d+)\/(\d+)\s*页/g, 'Page $1/$2'],
|
||||
[/显示\s*(\d+)-(\d+)\s*\/\s*(\d+)/g, 'Showing $1-$2 / $3'],
|
||||
[/显示\s*(\d+)-(\d+),共\s*(\d+)\s*条/g, 'Showing $1-$2 of $3'],
|
||||
[/搜索\s*"([^"]+)"\s*结果\s*(\d+)\s*条,/g, 'Search "$1" returned $2 results, '],
|
||||
[/搜索\s*"([^"]+)"\s*结果\s*(\d+)\s*个地址/g, 'Search "$1" returned $2 addresses, '],
|
||||
[/(\d+)\s*个/g, '$1 items'],
|
||||
[/(\d+)\s*条/g, '$1 records'],
|
||||
[/(\d+)\s*核/g, '$1 cores'],
|
||||
[/(\d+)\s*线程/g, '$1 threads'],
|
||||
[/已用/g, 'used'],
|
||||
[/未设置\s*Traffic\s*限制/g, 'No traffic limit set'],
|
||||
[/Memory\s*使用/g, 'Memory Usage'],
|
||||
[/网络\s*Traffic/g, 'Network Traffic'],
|
||||
[/实时\s*Status/g, 'Live Status'],
|
||||
[/Expiration Time\s*长期\s*Valid/g, 'Expiration Time No expiration'],
|
||||
[/长期\s*Valid/g, 'No expiration'],
|
||||
[/Create\s*Time/g, 'Created At'],
|
||||
[/CPU\s*累计\s*Time/g, 'CPU Total Time'],
|
||||
[/(\d+(?:\.\d+)?)\s*cores\s*\/\s*(\d+)\s*核/g, '$1 cores / $2 cores'],
|
||||
[/(\d+)\s*核\/(.+?)\/(\d+)\s*GB/g, '$1 cores / $2 / $3 GB'],
|
||||
[/(\d+)\s*\/\s*页/g, '$1 / page'],
|
||||
[/到期时间:/g, 'Expires: '],
|
||||
[/证书路径:/g, 'Certificate path: '],
|
||||
[/最近错误:/g, 'Last error: '],
|
||||
[/当前证书:/g, 'Current certificate: '],
|
||||
[/第\s*(\d+)\s*页/g, 'Page $1'],
|
||||
[/入\s*([^/,]+)\s*\/\s*出\s*([^,]+),累计\s*(.+)$/g, 'In $1 / Out $2, total $3'],
|
||||
[/读\s*([^/,]+)\s*\/\s*写\s*([^,]+),累计\s*([^,]+),容量\s*(.+)$/g, 'Read $1 / Write $2, total $3, capacity $4'],
|
||||
[/(.+?),筛选后\s*(\d+)\s*items/g, '$1, filtered $2 items'],
|
||||
[/(.+?),已选\s*(\d+)\s*items/g, '$1, selected $2 items'],
|
||||
[/将创建\s*(\d+)\s*个容器:(.+?)\s*至\s*(.+)$/g, 'Will create $1 containers: $2 to $3'],
|
||||
[/暂无可用的\s*(KVM|LXC)\s*系统镜像,请先在「镜像管理」中下载镜像模板。/g, 'No available $1 system images. Download image templates in Images first.'],
|
||||
[/不能小于\s*(.+)$/g, 'Cannot be less than $1'],
|
||||
[/不能大于\s*(.+)$/g, 'Cannot be greater than $1'],
|
||||
[/^(.+?)\s*-\s*操作日志$/g, '$1 - Audit Logs'],
|
||||
[/^(.+?)\s*-\s*登录日志$/g, '$1 - Login Logs'],
|
||||
[/^(.+?)。下次登录生效$/g, '$1. Takes effect at next login'],
|
||||
[/阶段:(.+)$/g, 'Stage: $1'],
|
||||
[/\$\{days\}天/g, '${days} days'],
|
||||
[/\$\{hours\}小时/g, '${hours} hours'],
|
||||
[/\$\{hours\}\s*小时/g, '${hours} hours'],
|
||||
[/\$\{Math\.floor\(diff \/ 60000\)\}分钟/g, '${Math.floor(diff / 60000)} minutes'],
|
||||
[/(\d+)分钟/g, '$1 minutes'],
|
||||
[/(\d+)小时/g, '$1 hours'],
|
||||
[/(\d+)\s*周/g, '$1 weeks'],
|
||||
[/(\d+)天/g, '$1 days'],
|
||||
[/确认删除容器\s*(.+?)\s*的快照吗?此操作不可恢复。/g, 'Delete the snapshot for container $1? This cannot be undone.'],
|
||||
[/确定要删除容器\s*(.+?)\s*吗?此操作不可撤销。/g, 'Delete container $1? This action cannot be undone.'],
|
||||
[/容器\s*(.+?)\s*已开机/g, 'Container $1 started'],
|
||||
[/容器\s*(.+?)\s*已关机/g, 'Container $1 stopped'],
|
||||
[/容器\s*(.+?)\s*已重启/g, 'Container $1 restarted'],
|
||||
]
|
||||
|
||||
export function translateText(value: string): string {
|
||||
if (!shouldTranslateText(value)) return value
|
||||
const leading = value.match(/^\s*/)?.[0] || ''
|
||||
const trailing = value.match(/\s*$/)?.[0] || ''
|
||||
const body = value.trim()
|
||||
if (!body) return value
|
||||
if (exact[body]) return leading + exact[body] + trailing
|
||||
let translated = body
|
||||
for (const [pattern, replacement] of replacements) {
|
||||
translated = translated.replace(pattern, replacement)
|
||||
}
|
||||
for (const [source, target] of Object.entries(exact).sort((a, b) => b[0].length - a[0].length)) {
|
||||
translated = translated.split(source).join(target)
|
||||
}
|
||||
translated = cleanupTranslatedText(translated)
|
||||
return leading + translated + trailing
|
||||
}
|
||||
|
||||
export function shouldTranslateText(value: string): boolean {
|
||||
return /[\u3400-\u9fff]/.test(value) || artifactPatterns.some((pattern) => pattern.test(value))
|
||||
}
|
||||
|
||||
function cleanupTranslatedText(value: string): string {
|
||||
return value
|
||||
.replace(/Back\s*List/g, 'Back to list')
|
||||
.replace(/Container\s*List/g, 'Container List')
|
||||
.replace(/Snapshot\s*List/g, 'Snapshot List')
|
||||
.replace(/All\s*Type/g, 'All types')
|
||||
.replace(/All\s*Status/g, 'All statuses')
|
||||
.replace(/All\s*System/g, 'All systems')
|
||||
.replace(/AutoStop\s*Off/g, 'Auto-stop off')
|
||||
.replace(/AutoStop\s*On/g, 'Auto-stop on')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
}
|
||||
+327
-38
@@ -11,8 +11,218 @@ LOG_FILE="${CLICD_LOG_FILE:-/var/log/clicd-install.log}"
|
||||
INSTALL_DOWNLOAD_MARKER="${CLICD_INSTALL_DOWNLOAD_MARKER:-/tmp/clicd-install-dir.$$}"
|
||||
LIBVIRT_DEFAULT_MARKER="/var/lib/clicd/kvm/default-network.created"
|
||||
|
||||
normalize_lang() {
|
||||
lang="$1"
|
||||
case "$(printf '%s' "$lang" | tr 'A-Z' 'a-z')" in
|
||||
en|en_*|en-*) echo en ;;
|
||||
zh|zh_*|zh-*) echo zh ;;
|
||||
*) echo "" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
detect_lang() {
|
||||
normalized="$(normalize_lang "${CLICD_LANG:-}")"
|
||||
if [ -n "$normalized" ]; then
|
||||
echo "$normalized"
|
||||
return
|
||||
fi
|
||||
normalized="$(normalize_lang "${LC_ALL:-${LC_MESSAGES:-${LANG:-}}}")"
|
||||
if [ -n "$normalized" ]; then
|
||||
echo "$normalized"
|
||||
return
|
||||
fi
|
||||
echo zh
|
||||
}
|
||||
|
||||
choose_language() {
|
||||
normalized="$(normalize_lang "${CLICD_LANG:-}")"
|
||||
if [ -n "$normalized" ]; then
|
||||
echo "$normalized"
|
||||
return
|
||||
fi
|
||||
case "$ACTION" in
|
||||
-h|--help|help)
|
||||
detect_lang
|
||||
return
|
||||
;;
|
||||
esac
|
||||
if [ -t 0 ]; then
|
||||
{
|
||||
echo "====================================="
|
||||
echo " 请选择语言 / Select language"
|
||||
echo "====================================="
|
||||
echo " 1) 简体中文"
|
||||
echo " 2) English"
|
||||
printf " 请输入 1/2 [1]: "
|
||||
} >&2
|
||||
IFS= read -r answer || answer=""
|
||||
case "$answer" in
|
||||
2) echo en ;;
|
||||
*) echo zh ;;
|
||||
esac
|
||||
return
|
||||
fi
|
||||
if [ -r /dev/tty ] && [ -w /dev/tty ] && { printf '' > /dev/tty; } 2>/dev/null; then
|
||||
{
|
||||
echo "====================================="
|
||||
echo " 请选择语言 / Select language"
|
||||
echo "====================================="
|
||||
echo " 1) 简体中文"
|
||||
echo " 2) English"
|
||||
printf " 请输入 1/2 [1]: "
|
||||
} > /dev/tty
|
||||
IFS= read -r answer < /dev/tty || answer=""
|
||||
case "$answer" in
|
||||
2) echo en ;;
|
||||
*) echo zh ;;
|
||||
esac
|
||||
return
|
||||
fi
|
||||
detect_lang
|
||||
}
|
||||
|
||||
CLICD_LANG_DETECTED="$(choose_language)"
|
||||
export CLICD_LANG="$CLICD_LANG_DETECTED"
|
||||
|
||||
tr_msg() {
|
||||
msg="$*"
|
||||
[ "$CLICD_LANG_DETECTED" = "en" ] || { printf '%s' "$msg"; return; }
|
||||
msg="$(printf '%s' "$msg" | sed \
|
||||
-e 's/中文安装\/卸载脚本/Installer\/Uninstaller/g' \
|
||||
-e 's/警告/Warning/g' \
|
||||
-e 's/错误/Error/g' \
|
||||
-e 's/安装\/卸载未完成。请查看日志:/Install\/uninstall did not complete. Check log: /g' \
|
||||
-e 's/如果你确认这是程序问题,请提交 issue:/If this looks like a CLICD bug, please open an issue: /g' \
|
||||
-e 's/开始:/Starting: /g' \
|
||||
-e 's/完成:/Completed: /g' \
|
||||
-e 's/步骤失败:/Step failed: /g' \
|
||||
-e 's/退出码:/exit code: /g' \
|
||||
-e 's/最近 80 行日志:/Last 80 log lines: /g' \
|
||||
-e 's/请将上述日志和系统信息提交到:/Please submit the log above and system info to: /g' \
|
||||
-e 's/系统检测:/System check: /g' \
|
||||
-e 's/当前安装包仅支持/This installer only supports/g' \
|
||||
-e 's/当前架构:/current architecture: /g' \
|
||||
-e 's/未检测到 systemd 或 OpenRC,无法安装服务。/systemd or OpenRC was not detected; cannot install service./g' \
|
||||
-e 's/暂不支持当前 Linux 发行版:/Unsupported Linux distribution: /g' \
|
||||
-e 's/请提交 issue 并附上/Please open an issue with/g' \
|
||||
-e 's/发行版/Distribution/g' \
|
||||
-e 's/不在主要支持列表,将按检测到的软件包管理器尝试安装。/is not in the primary support list; trying the detected package manager./g' \
|
||||
-e 's/存储检测:/Storage check: /g' \
|
||||
-e 's/根文件系统=/root filesystem=/g' \
|
||||
-e 's/可用空间=/available space=/g' \
|
||||
-e 's/根分区可用空间低于 5GB,下载镜像或创建 KVM\/LXC 时可能失败。/Root partition has less than 5GB available; image downloads or KVM\/LXC creation may fail./g' \
|
||||
-e 's/请使用 root 权限运行:/Run as root: /g' \
|
||||
-e 's/或执行:/Or run: /g' \
|
||||
-e 's/卸载:/Uninstall: /g' \
|
||||
-e 's/问题反馈:/Issues: /g' \
|
||||
-e 's/日志文件:/Log file: /g' \
|
||||
-e 's/仓库地址:/Repository: /g' \
|
||||
-e 's/未知操作:/Unknown action: /g' \
|
||||
-e 's/卸载会停止并删除 CLICD 服务、配置数据库、CLICD 创建的 LXC\/KVM 实例和缓存数据。/Uninstall will stop and remove the CLICD service, configuration database, CLICD-created LXC\/KVM instances, and cached data./g' \
|
||||
-e 's/为避免误删生产数据,脚本只会删除名称形如 ct-数字 的 LXC 容器、clicd-img-dl-\* 下载临时容器和 vm-数字 的 KVM 域。/To avoid deleting production data, the script only removes LXC containers named ct-NUMBER, temporary clicd-img-dl-* download containers, and KVM domains named vm-NUMBER./g' \
|
||||
-e 's/如需确认卸载,请输入:YES/Type YES to confirm uninstall:/g' \
|
||||
-e 's/已取消卸载。如需非交互卸载,请设置 CLICD_UNINSTALL_CONFIRM=1。/Uninstall cancelled. For non-interactive uninstall, set CLICD_UNINSTALL_CONFIRM=1./g' \
|
||||
-e 's/正在卸载 CLICD.../Uninstalling CLICD.../g' \
|
||||
-e 's/正在删除 CLICD 创建的 LXC 容器(\/var\/lib\/lxc\/ct-数字).../Removing CLICD-created LXC containers (\/var\/lib\/lxc\/ct-NUMBER).../g' \
|
||||
-e 's/保留 \/root\/clicd-backups,避免误删部署\/回滚备份。确认不需要后可手动删除。/Keeping \/root\/clicd-backups to avoid deleting deployment\/rollback backups. Remove it manually if no longer needed./g' \
|
||||
-e 's/CLICD 卸载完成/CLICD uninstall complete/g' \
|
||||
-e 's/已删除服务、二进制、SQLite\/配置数据、CLICD LXC\/KVM 实例、/Removed service, binary, SQLite\/config data, CLICD LXC\/KVM instances,/g' \
|
||||
-e 's/CLICD 镜像缓存、防火墙规则、主机钩子、配额记录和临时文件。/CLICD image cache, firewall rules, host hooks, quota records, and temporary files./g' \
|
||||
-e 's/已保留 \/root\/clicd-backups 和非 CLICD 的 LXC 全局缓存,避免误删生产备份\/共享镜像。/Kept \/root\/clicd-backups and non-CLICD global LXC cache to avoid deleting production backups\/shared images./g' \
|
||||
-e 's/日志:/Log: /g' \
|
||||
-e 's/兼容性检查/Compatibility check/g' \
|
||||
-e 's/存储环境检查/Storage environment check/g' \
|
||||
-e 's/安装系统依赖/Install system dependencies/g' \
|
||||
-e 's/配置内核网络参数/Configure kernel networking/g' \
|
||||
-e 's/配置运行时服务/Configure runtime services/g' \
|
||||
-e 's/配置 libvirt default NAT 网络/Configure libvirt default NAT network/g' \
|
||||
-e 's/配置 UID\/GID 映射/Configure UID\/GID mapping/g' \
|
||||
-e 's/配置 LXC 存储权限/Configure LXC storage permissions/g' \
|
||||
-e 's/检查 project quota/Check project quota/g' \
|
||||
-e 's/下载发行版包/Download release package/g' \
|
||||
-e 's/安装 CLICD 二进制/Install CLICD binary/g' \
|
||||
-e 's/安装并启动 CLICD 服务/Install and start CLICD service/g' \
|
||||
-e 's/已写入面板语言:/Panel language saved: /g' \
|
||||
-e 's/写入面板语言/Save panel language/g' \
|
||||
-e 's/面板语言写入失败,请安装后在面板右下角手动切换。/Failed to save panel language. Please switch it manually from the lower-left panel control after installation./g' \
|
||||
-e 's/正在使用 apk 安装依赖.../Installing dependencies with apk.../g' \
|
||||
-e 's/正在使用 apt 安装依赖.../Installing dependencies with apt.../g' \
|
||||
-e 's/正在使用 dnf 安装依赖.../Installing dependencies with dnf.../g' \
|
||||
-e 's/正在使用 yum 安装依赖.../Installing dependencies with yum.../g' \
|
||||
-e 's/依赖安装后仍未找到/Still missing after dependency installation: /g' \
|
||||
-e 's/,请检查 LXC 软件源\/安装日志。/. Check the LXC repository\/install log./g' \
|
||||
-e 's/,请检查系统网络工具包。/. Check the system network tools package./g' \
|
||||
-e 's/,请检查 iproute2 安装。/. Check the iproute2 installation./g' \
|
||||
-e 's/,请检查 libvirt-client\/libvirt-clients 安装。/. Check the libvirt-client\/libvirt-clients installation./g' \
|
||||
-e 's/,请检查 qemu-utils\/qemu-img 安装。/. Check the qemu-utils\/qemu-img installation./g' \
|
||||
-e 's/,请检查 cloud-image-utils\/cloud-utils 安装。/. Check the cloud-image-utils\/cloud-utils installation./g' \
|
||||
-e 's/可选依赖未安装:/Optional dependency was not installed: /g' \
|
||||
-e 's/当前系统 /Current system /g' \
|
||||
-e 's/ 未找到 dnf\/yum,无法安装依赖。/ does not have dnf\/yum; cannot install dependencies./g' \
|
||||
-e 's/Windows KVM 初始化需要 genisoimage、mkisofs 或 xorriso 中任意一个。/Windows KVM initialization requires one of genisoimage, mkisofs, or xorriso./g' \
|
||||
-e 's/未检测到 \/dev\/kvm。LXC 可用,但 KVM 虚拟机需要硬件虚拟化或嵌套虚拟化。/\/dev\/kvm was not detected. LXC is available, but KVM VMs require hardware virtualization or nested virtualization./g' \
|
||||
-e 's/正在启用内核转发配置.../Enabling kernel forwarding settings.../g' \
|
||||
-e 's/正在配置 LXC 和 KVM 服务.../Configuring LXC and KVM services.../g' \
|
||||
-e 's/服务 /Service /g' \
|
||||
-e 's/ 启动失败,将继续安装并在运行时降级处理。/ failed to start; installation will continue and runtime fallback will be used./g' \
|
||||
-e 's/未检测到 systemd 单元 /systemd unit was not detected: /g' \
|
||||
-e 's/,跳过。/; skipped./g' \
|
||||
-e 's/检测到 libvirt 传统 libvirtd 服务,已使用 libvirtd 模式。/Detected the legacy libvirt libvirtd service; using libvirtd mode./g' \
|
||||
-e 's/未检测到支持的服务管理器。CLICD 当前支持 systemd 或 OpenRC。/No supported service manager was detected. CLICD currently supports systemd or OpenRC./g' \
|
||||
-e 's/正在检查 libvirt default NAT 网络.../Checking libvirt default NAT network.../g' \
|
||||
-e 's/未找到 virsh,跳过 libvirt default NAT 网络检查。/virsh was not found; skipping the libvirt default NAT network check./g' \
|
||||
-e 's/libvirt default 网络仍未启动。请执行 virsh net-info default 查看详情。/libvirt default network is still not active. Run virsh net-info default for details./g' \
|
||||
-e 's/libvirt default NAT 网络已启用。/libvirt default NAT network is enabled./g' \
|
||||
-e 's/正在配置 subordinate UID\/GID 范围.../Configuring subordinate UID\/GID ranges.../g' \
|
||||
-e 's/根文件系统 /Root filesystem /g' \
|
||||
-e 's/ 不需要\/不适合自动启用 ext4 project quota,CLICD 将使用兼容磁盘限制模式。/ does not need or is not suitable for automatic ext4 project quota; CLICD will use compatible disk limit mode./g' \
|
||||
-e 's/ 不在自动 project quota 支持范围,CLICD 将使用兼容磁盘限制模式。/ is not supported for automatic project quota; CLICD will use compatible disk limit mode./g' \
|
||||
-e 's/根分区来源 /Root partition source /g' \
|
||||
-e 's/ 不是块设备,跳过 project quota 自动检查,CLICD 将使用兼容磁盘限制模式。/ is not a block device; skipping automatic project quota check and using compatible disk limit mode./g' \
|
||||
-e 's/未找到 tune2fs,跳过 project quota 检查,CLICD 将使用兼容磁盘限制模式。/tune2fs was not found; skipping project quota check and using compatible disk limit mode./g' \
|
||||
-e 's/检测到 ext4 project quota 已可用。/ext4 project quota is already available./g' \
|
||||
-e 's/ext4 project quota 未启用,CLICD 将自动回退到 loopback 镜像磁盘限制模式。/ext4 project quota is not enabled; CLICD will automatically fall back to loopback image disk limit mode./g' \
|
||||
-e 's/当前目录未找到 clicd 二进制,将下载发行版包。/No local clicd binary found; downloading release package./g' \
|
||||
-e 's/正在下载发行版包:/Downloading release package: /g' \
|
||||
-e 's/下载发行版包需要 curl 或 wget。/Downloading the release package requires curl or wget./g' \
|
||||
-e 's/下载的发行版包中未找到 clicd 二进制。/The downloaded release package does not contain the clicd binary./g' \
|
||||
-e 's/未找到 clicd 二进制,安装无法继续。/clicd binary was not found; installation cannot continue./g' \
|
||||
-e 's/已安装二进制:/Installed binary: /g' \
|
||||
-e 's/正在安装 CLICD 服务.../Installing CLICD service.../g' \
|
||||
-e 's/正在清理 CLICD 防火墙和网桥规则.../Cleaning CLICD firewall and bridge rules.../g' \
|
||||
-e 's/已清理 /Cleaned /g' \
|
||||
-e 's/ 中的 CLICD 配额记录/ CLICD quota records/g' \
|
||||
-e 's/跳过当前安装目录 /Skipping current installation directory /g' \
|
||||
-e 's/,避免中断后续安装步骤。/ to avoid interrupting later installation steps./g' \
|
||||
-e 's/ 被占用,终止占用进程后重试删除.../ is busy; killing occupying processes and retrying removal.../g' \
|
||||
-e 's/正在删除 CLICD 使用的 LXC 镜像缓存.../Removing LXC image cache used by CLICD.../g' \
|
||||
-e 's/正在删除 KVM 虚拟机域 /Removing KVM VM domain /g' \
|
||||
-e 's/正在销毁 CLICD 创建的 KVM 虚拟机.../Destroying CLICD-created KVM VMs.../g' \
|
||||
-e 's/检测到非 CLICD 虚拟机仍在使用 libvirt default 网络,已保留 default\/virbr0。/Non-CLICD VMs are still using the libvirt default network, so default\/virbr0 has been kept./g' \
|
||||
-e 's/正在删除 CLICD 创建的 libvirt default NAT 网络.../Removing CLICD-created libvirt default NAT network.../g' \
|
||||
-e 's/已删除 /Removed /g' \
|
||||
-e 's/检测到 /Detected /g' \
|
||||
-e 's/安装完成/Installation complete/g' \
|
||||
-e 's/Web 面板/Web panel/g' \
|
||||
-e 's/二进制/Binary/g' \
|
||||
-e 's/安装日志/Install log/g' \
|
||||
-e 's/服务/Service/g' \
|
||||
-e 's/运行日志/Runtime log/g' \
|
||||
-e 's/首次安装时的初始账号信息:/Initial account information for first installation:/g' \
|
||||
-e 's/如果没有显示密码,说明服务器已有/If no password is shown, the server already has/g' \
|
||||
-e 's/已有管理员密码使用 bcrypt 存储,无法反查;请使用面板内修改密码或重置配置。/Existing admin passwords are stored with bcrypt and cannot be recovered. Change it in the panel or reset configuration./g' \
|
||||
-e 's/:/: /g' \
|
||||
-e 's/,/, /g' \
|
||||
-e 's/。/./g' \
|
||||
-e 's/(/(/g' \
|
||||
-e 's/)/)/g' \
|
||||
-e 's/、/, /g' \
|
||||
)"
|
||||
printf '%s' "$msg"
|
||||
}
|
||||
|
||||
echo "====================================="
|
||||
echo " CLICD 中文安装/卸载脚本"
|
||||
echo " $(tr_msg "CLICD 中文安装/卸载脚本")"
|
||||
echo "====================================="
|
||||
|
||||
write_log_file() {
|
||||
@@ -22,21 +232,26 @@ write_log_file() {
|
||||
}
|
||||
|
||||
log() {
|
||||
echo "[clicd] $*"
|
||||
write_log_file "[clicd] $*"
|
||||
msg="$(tr_msg "$*")"
|
||||
echo "[clicd] $msg"
|
||||
write_log_file "[clicd] $msg"
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo "[clicd][警告] $*" >&2
|
||||
write_log_file "[警告] $*"
|
||||
label="$(tr_msg "警告")"
|
||||
msg="$(tr_msg "$*")"
|
||||
echo "[clicd][$label] $msg" >&2
|
||||
write_log_file "[$label] $msg"
|
||||
}
|
||||
|
||||
die() {
|
||||
echo "[clicd][错误] $*" >&2
|
||||
write_log_file "[错误] $*"
|
||||
label="$(tr_msg "错误")"
|
||||
msg="$(tr_msg "$*")"
|
||||
echo "[clicd][$label] $msg" >&2
|
||||
write_log_file "[$label] $msg"
|
||||
echo "" >&2
|
||||
echo "安装/卸载未完成。请查看日志:$LOG_FILE" >&2
|
||||
echo "如果你确认这是程序问题,请提交 issue:$ISSUE_URL" >&2
|
||||
echo "$(tr_msg "安装/卸载未完成。请查看日志:")$LOG_FILE" >&2
|
||||
echo "$(tr_msg "如果你确认这是程序问题,请提交 issue:")$ISSUE_URL" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -62,11 +277,11 @@ run_step() {
|
||||
fi
|
||||
rc="$?"
|
||||
echo "" >&2
|
||||
echo "[clicd][错误] 步骤失败:$step_name,退出码:$rc" >&2
|
||||
echo "[clicd][错误] 最近 80 行日志:$LOG_FILE" >&2
|
||||
echo "[clicd][$(tr_msg "错误")] $(tr_msg "步骤失败:")$(tr_msg "$step_name")$(tr_msg ",")$(tr_msg "退出码:")$rc" >&2
|
||||
echo "[clicd][$(tr_msg "错误")] $(tr_msg "最近 80 行日志:")$LOG_FILE" >&2
|
||||
tail -n 80 "$LOG_FILE" >&2 2>/dev/null || true
|
||||
echo "" >&2
|
||||
echo "请将上述日志和系统信息提交到:$ISSUE_URL" >&2
|
||||
echo "$(tr_msg "请将上述日志和系统信息提交到:")$ISSUE_URL" >&2
|
||||
exit "$rc"
|
||||
}
|
||||
|
||||
@@ -104,10 +319,10 @@ check_storage_compatibility() {
|
||||
}
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "请使用 root 权限运行:sudo ./install.sh"
|
||||
echo "或执行:curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh"
|
||||
echo "卸载:curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall"
|
||||
echo "问题反馈:$ISSUE_URL"
|
||||
echo "$(tr_msg "请使用 root 权限运行:")sudo ./install.sh"
|
||||
echo "$(tr_msg "或执行:")curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh"
|
||||
echo "$(tr_msg "卸载:")curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall"
|
||||
echo "$(tr_msg "问题反馈:")$ISSUE_URL"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -125,6 +340,28 @@ if [ -r /etc/os-release ]; then
|
||||
fi
|
||||
|
||||
usage() {
|
||||
if [ "$CLICD_LANG_DETECTED" = "en" ]; then
|
||||
cat << EOF
|
||||
Usage:
|
||||
./install.sh Install or upgrade CLICD
|
||||
./install.sh uninstall Uninstall CLICD (removes containers, VMs, image cache, and config data)
|
||||
|
||||
Environment variables:
|
||||
CLICD_REPO=owner/repo Default: ${REPO}
|
||||
CLICD_VERSION=latest|v1.0.0 Default: latest
|
||||
CLICD_LANG=en|zh Default: auto
|
||||
CLICD_LOG_FILE=/path/file.log Default: ${LOG_FILE}
|
||||
|
||||
Examples:
|
||||
curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh
|
||||
curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall
|
||||
curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall --yes
|
||||
|
||||
Log: ${LOG_FILE}
|
||||
Issues: ${ISSUE_URL}
|
||||
EOF
|
||||
return
|
||||
fi
|
||||
cat << EOF
|
||||
用法:
|
||||
./install.sh 安装或升级 CLICD
|
||||
@@ -133,6 +370,7 @@ usage() {
|
||||
环境变量:
|
||||
CLICD_REPO=owner/repo 默认:${REPO}
|
||||
CLICD_VERSION=latest|v1.0.0 默认:latest
|
||||
CLICD_LANG=en|zh 默认:自动检测
|
||||
CLICD_LOG_FILE=/path/file.log 默认:${LOG_FILE}
|
||||
|
||||
示例:
|
||||
@@ -598,9 +836,9 @@ confirm_uninstall() {
|
||||
return
|
||||
fi
|
||||
echo ""
|
||||
echo "[clicd][警告] 卸载会停止并删除 CLICD 服务、配置数据库、CLICD 创建的 LXC/KVM 实例和缓存数据。" >&2
|
||||
echo "[clicd][警告] 为避免误删生产数据,脚本只会删除名称形如 ct-数字 的 LXC 容器、clicd-img-dl-* 下载临时容器和 vm-数字 的 KVM 域。" >&2
|
||||
echo "如需确认卸载,请输入:YES" >&2
|
||||
echo "[clicd][$(tr_msg "警告")] $(tr_msg "卸载会停止并删除 CLICD 服务、配置数据库、CLICD 创建的 LXC/KVM 实例和缓存数据。")" >&2
|
||||
echo "[clicd][$(tr_msg "警告")] $(tr_msg "为避免误删生产数据,脚本只会删除名称形如 ct-数字 的 LXC 容器、clicd-img-dl-* 下载临时容器和 vm-数字 的 KVM 域。")" >&2
|
||||
echo "$(tr_msg "如需确认卸载,请输入:YES")" >&2
|
||||
if [ -r /dev/tty ]; then
|
||||
IFS= read -r answer < /dev/tty
|
||||
elif [ -t 0 ]; then
|
||||
@@ -667,13 +905,13 @@ uninstall_clicd() {
|
||||
|
||||
echo ""
|
||||
echo "====================================="
|
||||
echo " CLICD 卸载完成"
|
||||
echo " $(tr_msg "CLICD 卸载完成")"
|
||||
echo "====================================="
|
||||
echo " 已删除服务、二进制、SQLite/配置数据、CLICD LXC/KVM 实例、"
|
||||
echo " CLICD 镜像缓存、防火墙规则、主机钩子、配额记录和临时文件。"
|
||||
echo " 已保留 /root/clicd-backups 和非 CLICD 的 LXC 全局缓存,避免误删生产备份/共享镜像。"
|
||||
echo " 日志:$LOG_FILE"
|
||||
echo " 问题反馈:$ISSUE_URL"
|
||||
echo " $(tr_msg "已删除服务、二进制、SQLite/配置数据、CLICD LXC/KVM 实例、")"
|
||||
echo " $(tr_msg "CLICD 镜像缓存、防火墙规则、主机钩子、配额记录和临时文件。")"
|
||||
echo " $(tr_msg "已保留 /root/clicd-backups 和非 CLICD 的 LXC 全局缓存,避免误删生产备份/共享镜像。")"
|
||||
echo " $(tr_msg "日志:")$LOG_FILE"
|
||||
echo " $(tr_msg "问题反馈:")$ISSUE_URL"
|
||||
echo "====================================="
|
||||
}
|
||||
|
||||
@@ -1279,33 +1517,83 @@ install_service() {
|
||||
fi
|
||||
}
|
||||
|
||||
set_panel_language() {
|
||||
lang="$CLICD_LANG_DETECTED"
|
||||
db="/root/.clicd/config.db"
|
||||
if [ "$lang" != "zh" ] && [ "$lang" != "en" ]; then
|
||||
lang="zh"
|
||||
fi
|
||||
|
||||
saved=0
|
||||
i=0
|
||||
while [ ! -f "$db" ] && [ "$i" -lt 20 ]; do
|
||||
i=$((i + 1))
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ -f "$db" ] && has_cmd python3; then
|
||||
CLICD_PANEL_LANG="$lang" CLICD_DB="$db" python3 - <<'PY' >/dev/null 2>&1 && saved=1 || saved=0
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
db = os.environ["CLICD_DB"]
|
||||
lang = os.environ["CLICD_PANEL_LANG"]
|
||||
conn = sqlite3.connect(db)
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS app_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
|
||||
conn.execute("INSERT OR REPLACE INTO app_meta(key, value) VALUES('language', ?)", (lang,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
PY
|
||||
fi
|
||||
|
||||
if [ "$saved" != "1" ] && [ -f "$db" ] && has_cmd sqlite3; then
|
||||
sqlite3 "$db" "CREATE TABLE IF NOT EXISTS app_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); INSERT OR REPLACE INTO app_meta(key, value) VALUES('language', '$lang');" >/dev/null 2>&1 && saved=1 || saved=0
|
||||
fi
|
||||
|
||||
if [ "$saved" != "1" ] && has_cmd curl; then
|
||||
curl -k -fsS -X POST -H 'Content-Type: application/json' -d "{\"language\":\"$lang\"}" "https://127.0.0.1:8999/api/language" >/dev/null 2>&1 && saved=1 || \
|
||||
curl -fsS -X POST -H 'Content-Type: application/json' -d "{\"language\":\"$lang\"}" "http://127.0.0.1:8999/api/language" >/dev/null 2>&1 && saved=1 || saved=0
|
||||
fi
|
||||
|
||||
if [ "$saved" = "1" ]; then
|
||||
log "已写入面板语言:$lang"
|
||||
if has_cmd systemctl && systemctl is-active clicd >/dev/null 2>&1; then
|
||||
systemctl restart clicd >/dev/null 2>&1 || true
|
||||
elif has_cmd rc-service; then
|
||||
rc-service clicd restart >/dev/null 2>&1 || true
|
||||
fi
|
||||
else
|
||||
warn "面板语言写入失败,请安装后在面板右下角手动切换。"
|
||||
fi
|
||||
}
|
||||
|
||||
print_summary() {
|
||||
echo ""
|
||||
echo "====================================="
|
||||
echo " 安装完成"
|
||||
echo " $(tr_msg "安装完成")"
|
||||
echo "====================================="
|
||||
echo " Web 面板:http://YOUR_SERVER_IP:8999"
|
||||
echo " 二进制:/usr/local/bin/clicd"
|
||||
echo " 安装日志:$LOG_FILE"
|
||||
echo " 问题反馈:$ISSUE_URL"
|
||||
echo " $(tr_msg "Web 面板:")http://YOUR_SERVER_IP:8999"
|
||||
echo " $(tr_msg "二进制:")/usr/local/bin/clicd"
|
||||
echo " $(tr_msg "安装日志:")$LOG_FILE"
|
||||
echo " $(tr_msg "问题反馈:")$ISSUE_URL"
|
||||
if is_systemd; then
|
||||
echo " 服务:systemctl {start|stop|restart|status} clicd"
|
||||
echo " 运行日志:journalctl -u clicd -f"
|
||||
echo " $(tr_msg "服务:")systemctl {start|stop|restart|status} clicd"
|
||||
echo " $(tr_msg "运行日志:")journalctl -u clicd -f"
|
||||
elif is_openrc; then
|
||||
echo " 服务:rc-service clicd {start|stop|restart|status}"
|
||||
echo " 运行日志:tail -f /var/log/clicd.log /var/log/clicd.err"
|
||||
echo " $(tr_msg "服务:")rc-service clicd {start|stop|restart|status}"
|
||||
echo " $(tr_msg "运行日志:")tail -f /var/log/clicd.log /var/log/clicd.err"
|
||||
fi
|
||||
echo "====================================="
|
||||
echo ""
|
||||
echo "首次安装时的初始账号信息:"
|
||||
echo "$(tr_msg "首次安装时的初始账号信息:")"
|
||||
if is_systemd; then
|
||||
journalctl -u clicd --no-pager -n 80 | grep -E "Username:|Password:" || true
|
||||
else
|
||||
grep -E "Username:|Password:" /var/log/clicd.log /var/log/clicd.err 2>/dev/null || true
|
||||
fi
|
||||
echo ""
|
||||
echo "如果没有显示密码,说明服务器已有 /root/.clicd/config.db。"
|
||||
echo "已有管理员密码使用 bcrypt 存储,无法反查;请使用面板内修改密码或重置配置。"
|
||||
echo "$(tr_msg "如果没有显示密码,说明服务器已有") /root/.clicd/config.db."
|
||||
echo "$(tr_msg "已有管理员密码使用 bcrypt 存储,无法反查;请使用面板内修改密码或重置配置。")"
|
||||
}
|
||||
|
||||
run_step "兼容性检查" check_os_compatibility
|
||||
@@ -1315,10 +1603,11 @@ run_step "配置内核网络参数" configure_kernel_networking
|
||||
run_step "配置运行时服务" setup_runtime_services
|
||||
run_step "配置 libvirt default NAT 网络" setup_default_libvirt_network
|
||||
run_step "配置 UID/GID 映射" setup_subids
|
||||
run_step "Configure LXC storage permissions" configure_lxc_storage_access
|
||||
run_step "配置 LXC 存储权限" configure_lxc_storage_access
|
||||
run_step "检查 project quota" try_enable_project_quota
|
||||
run_step "下载发行版包" download_release_if_needed
|
||||
run_step "安装 CLICD 二进制" install_binary
|
||||
run_step "安装并启动 CLICD 服务" install_service
|
||||
run_step "写入面板语言" set_panel_language
|
||||
sleep 2
|
||||
print_summary
|
||||
|
||||
Reference in New Issue
Block a user