Compare commits

...

7 Commits

Author SHA1 Message Date
MengMengCode 422e48b524 release: v1.0.10 2026-06-06 15:53:14 +08:00
MengMengCode 3488b6db56 优化了一些功能 2026-06-06 15:52:27 +08:00
MengMengCode c7ba19fa34 release: v1.0.9 2026-06-06 15:10:54 +08:00
MengMengCode ffedf801e7 添加了子用户列表功能 2026-06-06 15:10:24 +08:00
MengMengCode 49d5a65357 release: v1.0.8 2026-06-06 12:57:46 +08:00
MengMengCode 8f32765ffe 修复了一些问题 2026-06-06 12:57:17 +08:00
MengMengCode 21b87d3d56 release: v1.0.7 2026-06-06 11:17:57 +08:00
29 changed files with 1684 additions and 258 deletions
+21
View File
@@ -67,6 +67,27 @@ func claimsFromToken(tokenString string) (jwt.MapClaims, bool) {
return nil, false return nil, false
} }
claims, ok := token.Claims.(jwt.MapClaims) claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, false
}
// For sub-user tokens, check token_version against stored version (password rotation invalidation)
if subUser, _ := claims["sub_user"].(string); subUser != "" {
tokenVersionFloat, hasVersion := claims["token_version"].(float64)
tokenVersion := int(tokenVersionFloat)
for i := range config.AppConfig.SubUsers {
if config.AppConfig.SubUsers[i].Username == subUser {
stored := config.AppConfig.SubUsers[i].TokenVersion
// If stored version > 0, require token_version to match exactly.
// This also rejects legacy tokens that lack token_version entirely.
if stored > 0 && (!hasVersion || tokenVersion != stored) {
return nil, false
}
break
}
}
}
return claims, ok return claims, ok
} }
+35 -4
View File
@@ -9,6 +9,7 @@ import (
"clicd/internal/config" "clicd/internal/config"
"clicd/internal/lxc" "clicd/internal/lxc"
"clicd/internal/version"
) )
var lxcManager = lxc.NewManager() var lxcManager = lxc.NewManager()
@@ -30,16 +31,35 @@ func HandleSingleContainer(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/containers/") path := strings.TrimPrefix(r.URL.Path, "/api/containers/")
parts := strings.SplitN(path, "/", 2) parts := strings.SplitN(path, "/", 2)
c := containerByIdentifier(parts[0]) c := containerByIdentifier(parts[0])
if c == nil { id := 0
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"}) if c != nil {
return id = c.ID
} }
id := c.ID
action := "" action := ""
if len(parts) > 1 { if len(parts) > 1 {
action = parts[1] action = parts[1]
} }
// Snapshot delete/restore operations: allow even if the container was deleted
isSnapshotDelete := strings.HasPrefix(action, "snapshots/") && r.Method == http.MethodDelete
isSnapshotRestore := strings.HasPrefix(action, "snapshots/") && strings.HasSuffix(action, "/restore") && r.Method == http.MethodPost
isSnapshotAction := isSnapshotDelete || isSnapshotRestore
if !isSnapshotAction && c == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Container not found"})
return
}
if isSnapshotAction && id == 0 {
// For orphaned snapshots, resolve containerID from the snapshot itself
snapshotID := strings.TrimPrefix(action, "snapshots/")
snapshotID = strings.TrimSuffix(snapshotID, "/restore")
snapshot := config.FindSnapshot(snapshotID)
if snapshot == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Snapshot not found"})
return
}
id = snapshot.ContainerID
}
switch { switch {
case action == "start" && r.Method == http.MethodPost: case action == "start" && r.Method == http.MethodPost:
HandleSingleTaskAction(w, r, id, "start") HandleSingleTaskAction(w, r, id, "start")
@@ -451,3 +471,14 @@ func deletePortMapping(w http.ResponseWriter, r *http.Request, id int, indexStr
} }
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: mappings}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: mappings})
} }
// HandleVersion returns the current CLICD version.
func HandleVersion(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{
"version": version.Current(),
}})
}
+192 -10
View File
@@ -66,7 +66,7 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
} }
containerName := c.Name containerName := c.Name
// Check if sub-user already exists for this container // Check if sub-user already exists and return the same management password.
for i := range config.AppConfig.SubUsers { for i := range config.AppConfig.SubUsers {
su := &config.AppConfig.SubUsers[i] su := &config.AppConfig.SubUsers[i]
for _, uuid := range su.ContainerUUIDs { for _, uuid := range su.ContainerUUIDs {
@@ -74,18 +74,27 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
if su.AccessCode == "" { if su.AccessCode == "" {
su.AccessCode = generateRandomStr(8) su.AccessCode = generateRandomStr(8)
} }
password := generateRandomStr(16) password := su.Password
if hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost); err == nil { message := "Sub-user link returned"
if password == "" {
password = generateRandomStr(16)
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate password"})
return
}
su.PassHash = string(hash) su.PassHash = string(hash)
su.Password = password
su.Token = ""
su.TokenVersion++
message = "Sub-user password generated"
} }
su.Password = ""
su.Token = ""
su.ContainerNames = appendUniqueString(su.ContainerNames, containerName) su.ContainerNames = appendUniqueString(su.ContainerNames, containerName)
su.ContainerUUIDs = appendUniqueString(su.ContainerUUIDs, c.UUID) su.ContainerUUIDs = appendUniqueString(su.ContainerUUIDs, c.UUID)
config.SaveConfig() config.SaveConfig()
jsonResponse(w, http.StatusOK, APIResponse{ jsonResponse(w, http.StatusOK, APIResponse{
Success: true, Success: true,
Message: "Sub-user password rotated", Message: message,
Data: newSubUserResponse(*su, password), Data: newSubUserResponse(*su, password),
}) })
return return
@@ -104,6 +113,7 @@ func HandleSubUserCreate(w http.ResponseWriter, r *http.Request) {
subUser := config.SubUser{ subUser := config.SubUser{
ID: "sub-" + generateRandomStr(8), ID: "sub-" + generateRandomStr(8),
Username: username, Username: username,
Password: password,
PassHash: string(hash), PassHash: string(hash),
ContainerNames: []string{containerName}, ContainerNames: []string{containerName},
ContainerUUIDs: []string{c.UUID}, ContainerUUIDs: []string{c.UUID},
@@ -134,17 +144,24 @@ func HandleSubUserLogin(w http.ResponseWriter, r *http.Request) {
return return
} }
clientIP := r.Header.Get("X-Forwarded-For")
if clientIP == "" {
clientIP = r.RemoteAddr
}
clientUA := r.Header.Get("User-Agent")
// Find sub-user // Find sub-user
for _, su := range config.AppConfig.SubUsers { for _, su := range config.AppConfig.SubUsers {
if su.Username == req.Username { if su.Username == req.Username {
if err := bcrypt.CompareHashAndPassword([]byte(su.PassHash), []byte(req.Password)); err == nil { if err := bcrypt.CompareHashAndPassword([]byte(su.PassHash), []byte(req.Password)); err == nil {
// Generate fresh token
containerUUIDs := activeSubUserContainerUUIDs(&su) containerUUIDs := activeSubUserContainerUUIDs(&su)
if len(containerUUIDs) == 0 { if len(containerUUIDs) == 0 {
config.AddLoginLog(su.Username, clientIP, clientUA, false)
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this user"}) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this user"})
return return
} }
tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour)) tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour), su.TokenVersion)
config.AddLoginLog(su.Username, clientIP, clientUA, true)
jsonResponse(w, http.StatusOK, APIResponse{ jsonResponse(w, http.StatusOK, APIResponse{
Success: true, Success: true,
@@ -155,6 +172,8 @@ func HandleSubUserLogin(w http.ResponseWriter, r *http.Request) {
}, },
}) })
return return
} else {
config.AddLoginLog(su.Username, clientIP, clientUA, false)
} }
} }
} }
@@ -179,19 +198,28 @@ func HandleSubUserAccessCode(w http.ResponseWriter, r *http.Request) {
} }
// Find sub-user by access code // Find sub-user by access code
clientIP := r.Header.Get("X-Forwarded-For")
if clientIP == "" {
clientIP = r.RemoteAddr
}
clientUA := r.Header.Get("User-Agent")
for _, su := range config.AppConfig.SubUsers { for _, su := range config.AppConfig.SubUsers {
if su.AccessCode == req.Code { if su.AccessCode == req.Code {
if err := bcrypt.CompareHashAndPassword([]byte(su.PassHash), []byte(req.Password)); err != nil { if err := bcrypt.CompareHashAndPassword([]byte(su.PassHash), []byte(req.Password)); err != nil {
config.AddLoginLog(su.Username, clientIP, clientUA, false)
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid password"}) jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid password"})
return return
} }
containerUUIDs := activeSubUserContainerUUIDs(&su) containerUUIDs := activeSubUserContainerUUIDs(&su)
if len(containerUUIDs) == 0 { if len(containerUUIDs) == 0 {
config.AddLoginLog(su.Username, clientIP, clientUA, false)
jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this link"}) jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "No active container is assigned to this link"})
return return
} }
tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour)) tokenStr := newSubUserToken(su.Username, containerUUIDs, time.Now().Add(24*time.Hour), su.TokenVersion)
config.AddLoginLog(su.Username, clientIP, clientUA, true)
jsonResponse(w, http.StatusOK, APIResponse{ jsonResponse(w, http.StatusOK, APIResponse{
Success: true, Success: true,
@@ -208,10 +236,11 @@ func HandleSubUserAccessCode(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid access code"}) jsonResponse(w, http.StatusUnauthorized, APIResponse{Success: false, Message: "Invalid access code"})
} }
func newSubUserToken(username string, containerUUIDs []string, expiresAt time.Time) string { func newSubUserToken(username string, containerUUIDs []string, expiresAt time.Time, tokenVersion int) string {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub_user": username, "sub_user": username,
"container_uuids": containerUUIDs, "container_uuids": containerUUIDs,
"token_version": tokenVersion,
"exp": expiresAt.Unix(), "exp": expiresAt.Unix(),
"iat": time.Now().Unix(), "iat": time.Now().Unix(),
}) })
@@ -461,3 +490,156 @@ func splitBy(s, sep string) []string {
result = append(result, current) result = append(result, current)
return result return result
} }
// SubUserListItem is the enriched sub-user info returned by the list API
type SubUserListItem struct {
ID string `json:"id"`
Username string `json:"username"`
ContainerNames []string `json:"container_names"`
ContainerUUIDs []string `json:"container_uuids"`
ContainerName string `json:"container_name"`
ContainerUUID string `json:"container_uuid"`
AccessCode string `json:"access_code"`
Password string `json:"password,omitempty"`
CreatedAt string `json:"created_at"`
LastLogin string `json:"last_login"`
LastLoginIP string `json:"last_login_ip"`
LastLoginUA string `json:"last_login_ua"`
}
// HandleSubUserList returns the list of all sub-users with container info
func HandleSubUserList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
result := make([]SubUserListItem, 0, len(config.AppConfig.SubUsers))
for _, su := range config.AppConfig.SubUsers {
item := SubUserListItem{
ID: su.ID,
Username: su.Username,
ContainerNames: su.ContainerNames,
ContainerUUIDs: su.ContainerUUIDs,
AccessCode: su.AccessCode,
Password: su.Password,
CreatedAt: su.CreatedAt,
}
// Resolve container name from first active UUID
for _, uuid := range su.ContainerUUIDs {
if c := config.FindContainerByUUID(uuid); c != nil {
item.ContainerName = c.Name
item.ContainerUUID = c.UUID
break
}
}
if item.ContainerName == "" && len(su.ContainerNames) > 0 {
item.ContainerName = su.ContainerNames[0]
}
// Find last login time
for i := len(config.AppConfig.LoginLogs) - 1; i >= 0; i-- {
log := config.AppConfig.LoginLogs[i]
if log.Username == su.Username {
item.LastLogin = log.Time
item.LastLoginIP = log.IP
item.LastLoginUA = log.UserAgent
break
}
}
// Skip orphaned sub-users with no active containers
if item.ContainerName == "" && item.ContainerUUID == "" {
continue
}
result = append(result, item)
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: result})
}
// HandleSubUserAction handles actions on a specific sub-user
func HandleSubUserAction(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/sub-users/")
parts := strings.SplitN(path, "/", 2)
subUserID := parts[0]
action := ""
if len(parts) > 1 {
action = parts[1]
}
// Find sub-user
var target *config.SubUser
for i := range config.AppConfig.SubUsers {
if config.AppConfig.SubUsers[i].ID == subUserID {
target = &config.AppConfig.SubUsers[i]
break
}
}
if target == nil {
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Sub-user not found"})
return
}
switch {
case action == "rotate-password" && r.Method == http.MethodPost:
password := generateRandomStr(16)
if hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost); err == nil {
target.PassHash = string(hash)
target.Password = password
target.Token = ""
target.TokenVersion++ // invalidate all existing tokens
config.SaveConfig()
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{
"password": password,
"access_code": target.AccessCode,
"username": target.Username,
}})
return
}
jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to generate password"})
case action == "audit-logs" && r.Method == http.MethodGet:
// Filter audit logs for this sub-user
logs := filterSubUserAuditLogs(target.Username)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
case action == "login-logs" && r.Method == http.MethodGet:
// Filter login logs for this sub-user
logs := filterSubUserLoginLogs(target.Username)
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: logs})
default:
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Action not found"})
}
}
func filterSubUserAuditLogs(username string) []config.AuditLog {
result := make([]config.AuditLog, 0)
for i := len(config.AppConfig.AuditLogs) - 1; i >= 0; i-- {
log := config.AppConfig.AuditLogs[i]
if log.User == username || strings.HasPrefix(log.User, "user:") && strings.Contains(log.User, username) {
result = append(result, log)
}
}
if result == nil {
result = []config.AuditLog{}
}
return result
}
func filterSubUserLoginLogs(username string) []config.SavedLoginLog {
result := make([]config.SavedLoginLog, 0)
for i := len(config.AppConfig.LoginLogs) - 1; i >= 0; i-- {
log := config.AppConfig.LoginLogs[i]
if log.Username == username {
result = append(result, log)
}
}
if result == nil {
result = []config.SavedLoginLog{}
}
return result
}
+18 -4
View File
@@ -35,6 +35,8 @@ type Task struct {
Config lxc.ContainerConfig `json:"config,omitempty"` Config lxc.ContainerConfig `json:"config,omitempty"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
User string `json:"user,omitempty"` // who created this task User string `json:"user,omitempty"` // who created this task
IP string `json:"ip,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
} }
type TaskQueue struct { type TaskQueue struct {
@@ -100,6 +102,10 @@ func (q *TaskQueue) EnqueueBatch(taskType TaskType, ids []int, templateID string
} }
func (q *TaskQueue) EnqueueBatchWithUser(taskType TaskType, ids []int, templateID string, user string) []string { func (q *TaskQueue) EnqueueBatchWithUser(taskType TaskType, ids []int, templateID string, user string) []string {
return q.EnqueueBatchWithAudit(taskType, ids, templateID, user, "", "")
}
func (q *TaskQueue) EnqueueBatchWithAudit(taskType TaskType, ids []int, templateID string, user string, ip string, userAgent string) []string {
q.mu.Lock() q.mu.Lock()
defer q.mu.Unlock() defer q.mu.Unlock()
var result []string var result []string
@@ -109,7 +115,7 @@ func (q *TaskQueue) EnqueueBatchWithUser(taskType TaskType, ids []int, templateI
if c != nil { if c != nil {
name = c.Name name = c.Name
} }
result = append(result, q.enqueueSingleWithUser(id, name, taskType, templateID, user)) result = append(result, q.enqueueSingleWithAudit(id, name, taskType, templateID, user, ip, userAgent))
} }
q.persistTasks() q.persistTasks()
return result return result
@@ -168,6 +174,10 @@ func (q *TaskQueue) enqueueSingle(containerID int, containerName string, taskTyp
} }
func (q *TaskQueue) enqueueSingleWithUser(containerID int, containerName string, taskType TaskType, templateID string, user string) string { func (q *TaskQueue) enqueueSingleWithUser(containerID int, containerName string, taskType TaskType, templateID string, user string) string {
return q.enqueueSingleWithAudit(containerID, containerName, taskType, templateID, user, "", "")
}
func (q *TaskQueue) enqueueSingleWithAudit(containerID int, containerName string, taskType TaskType, templateID string, user string, ip string, userAgent string) string {
id := q.nextID id := q.nextID
q.nextID++ q.nextID++
task := &Task{ task := &Task{
@@ -179,6 +189,8 @@ func (q *TaskQueue) enqueueSingleWithUser(containerID int, containerName string,
CreatedAt: time.Now().Format("2006-01-02 15:04:05"), CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
TemplateID: templateID, TemplateID: templateID,
User: user, User: user,
IP: ip,
UserAgent: userAgent,
} }
q.enqueueTask(task) q.enqueueTask(task)
return task.ID return task.ID
@@ -318,10 +330,10 @@ func (q *TaskQueue) opWorker() {
if err != nil { if err != nil {
task.Status = "failed" task.Status = "failed"
task.Error = err.Error() task.Error = err.Error()
config.AddAuditLog(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser) config.AddAuditLogFull(string(task.Type), task.ContainerName, "失败: "+err.Error(), auditUser, task.IP, task.UserAgent, false, err.Error())
} else { } else {
task.Status = "done" task.Status = "done"
config.AddAuditLog(string(task.Type), task.ContainerName, "成功", auditUser) config.AddAuditLogFull(string(task.Type), task.ContainerName, "成功", auditUser, task.IP, task.UserAgent, true, "")
switch task.Type { switch task.Type {
case TaskStart: case TaskStart:
config.UpdateContainerStatus(task.ContainerID, "running") config.UpdateContainerStatus(task.ContainerID, "running")
@@ -418,6 +430,8 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
user = "user:" + subUser user = "user:" + subUser
} }
} }
ip := clientIP(r)
userAgent := r.Header.Get("User-Agent")
var taskType TaskType var taskType TaskType
var templateID string var templateID string
@@ -452,7 +466,7 @@ func HandleSingleTaskAction(w http.ResponseWriter, r *http.Request, id int, acti
return return
} }
ids := globalQueue.EnqueueBatchWithUser(taskType, []int{id}, templateID, user) ids := globalQueue.EnqueueBatchWithAudit(taskType, []int{id}, templateID, user, ip, userAgent)
jsonResponse(w, http.StatusAccepted, APIResponse{ jsonResponse(w, http.StatusAccepted, APIResponse{
Success: true, Success: true,
Message: "Task queued", Message: "Task queued",
+42 -10
View File
@@ -47,11 +47,15 @@ type SavedLoginLog struct {
// AuditLog represents an operation log entry // AuditLog represents an operation log entry
type AuditLog struct { type AuditLog struct {
Time string `json:"time"` Time string `json:"time"`
Action string `json:"action"` Action string `json:"action"`
Target string `json:"target"` Target string `json:"target"`
Detail string `json:"detail"` Detail string `json:"detail"`
User string `json:"user"` User string `json:"user"`
IP string `json:"ip,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
Success *bool `json:"success,omitempty"`
Error string `json:"error,omitempty"`
} }
// OversellConfig controls host-level overselling behavior // OversellConfig controls host-level overselling behavior
@@ -139,13 +143,14 @@ func DeleteApiKey(id string) {
type SubUser struct { type SubUser struct {
ID string `json:"id"` ID string `json:"id"`
Username string `json:"username"` Username string `json:"username"`
Password string `json:"-"` Password string `json:"password,omitempty"`
PassHash string `json:"pass_hash"` PassHash string `json:"pass_hash"`
ContainerNames []string `json:"container_names"` ContainerNames []string `json:"container_names"`
ContainerUUIDs []string `json:"container_uuids,omitempty"` ContainerUUIDs []string `json:"container_uuids,omitempty"`
Token string `json:"-"` Token string `json:"-"`
AccessCode string `json:"access_code"` AccessCode string `json:"access_code"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
TokenVersion int `json:"token_version"`
} }
type Snapshot struct { type Snapshot struct {
@@ -437,10 +442,6 @@ func migrateSubUsers() bool {
changed = true changed = true
} }
} }
if su.Password != "" {
su.Password = ""
changed = true
}
if su.Token != "" { if su.Token != "" {
su.Token = "" su.Token = ""
changed = true changed = true
@@ -536,6 +537,8 @@ func RemoveContainer(id int) bool {
if c.ID == id { if c.ID == id {
removeSubUserContainerAccess(c.Name, c.UUID) removeSubUserContainerAccess(c.Name, c.UUID)
removeContainerSnapshotMetadata(id) removeContainerSnapshotMetadata(id)
// Clear snapshot schedule for this container
clearContainerSnapshotSchedule(&AppConfig.Containers[i])
AppConfig.Containers = append(AppConfig.Containers[:i], AppConfig.Containers[i+1:]...) AppConfig.Containers = append(AppConfig.Containers[:i], AppConfig.Containers[i+1:]...)
SaveConfig() SaveConfig()
return true return true
@@ -544,6 +547,15 @@ func RemoveContainer(id int) bool {
return false return false
} }
func clearContainerSnapshotSchedule(c *Container) {
c.SnapshotScheduleEnabled = false
c.SnapshotScheduleIntervalHours = 0
c.SnapshotScheduleTime = ""
c.SnapshotScheduleLastRun = ""
c.SnapshotScheduleNextRun = ""
c.SnapshotScheduleCreatedBy = ""
}
func AddSnapshot(snapshot Snapshot) { func AddSnapshot(snapshot Snapshot) {
AppConfig.Snapshots = append(AppConfig.Snapshots, snapshot) AppConfig.Snapshots = append(AppConfig.Snapshots, snapshot)
SaveConfig() SaveConfig()
@@ -723,6 +735,26 @@ func AddAuditLog(action, target, detail, user string) {
SaveConfig() SaveConfig()
} }
func AddAuditLogFull(action, target, detail, user, ip, userAgent string, success bool, errMsg string) {
s := success
log := AuditLog{
Time: time.Now().Format("2006-01-02 15:04:05"),
Action: action,
Target: target,
Detail: detail,
User: user,
IP: ip,
UserAgent: userAgent,
Success: &s,
Error: errMsg,
}
AppConfig.AuditLogs = append(AppConfig.AuditLogs, log)
if len(AppConfig.AuditLogs) > 500 {
AppConfig.AuditLogs = AppConfig.AuditLogs[len(AppConfig.AuditLogs)-500:]
}
SaveConfig()
}
// SaveTasks persists the task queue to config // SaveTasks persists the task queue to config
func SaveTasks(tasks []SavedTask) { func SaveTasks(tasks []SavedTask) {
AppConfig.Tasks = tasks AppConfig.Tasks = tasks
+209 -28
View File
@@ -1,6 +1,7 @@
package lxc package lxc
import ( import (
"bufio"
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
@@ -77,6 +78,9 @@ func (m *Manager) WarmRunningContainersSSH() {
continue continue
} }
config.UpdateContainerStatus(c.ID, "running") config.UpdateContainerStatus(c.ID, "running")
if c.IP != "" && m.containerPortListening(c.LxcName(), 22) {
continue
}
m.WarmSSHAsync(c.ID, "running container scan") m.WarmSSHAsync(c.ID, "running container scan")
} }
} }
@@ -105,19 +109,23 @@ func (m *Manager) updateAllRates() {
} }
lxcName := c.LxcName() lxcName := c.LxcName()
// Read raw bytes // Cache init PID once per scan so getContainerNetworkBytes / getContainerDiskIOBytes
memUsage := readIntCommand(fmt.Sprintf( // don't each fork lxc-info separately.
"cat /sys/fs/cgroup/lxc/%[1]s/memory.current 2>/dev/null || "+ initPID := m.getContainerInitPID(lxcName)
"cat /sys/fs/cgroup/lxc.payload.%[1]s/memory.current 2>/dev/null || "+
"cat /sys/fs/cgroup/memory/lxc/%[1]s/memory.usage_in_bytes 2>/dev/null || echo 0", shellQuote(lxcName)))
cpuUsec := uint64(readIntCommand(fmt.Sprintf( // Read memory from cgroup directly (no shell fork)
"(cat /sys/fs/cgroup/lxc/%[1]s/cpu.stat 2>/dev/null || "+ memUsage := readCgroupFile(lxcName,
"cat /sys/fs/cgroup/lxc.payload.%[1]s/cpu.stat 2>/dev/null) | "+ "/sys/fs/cgroup/lxc/%s/memory.current",
"awk '/usage_usec/ {print $2; found=1} END {if (!found) print 0}'", shellQuote(lxcName)))) "/sys/fs/cgroup/lxc.payload.%s/memory.current",
"/sys/fs/cgroup/memory/lxc/%s/memory.usage_in_bytes")
rxBytes, txBytes := m.getContainerNetworkBytes(lxcName) // Read cpu usage from cgroup directly (no shell | awk fork)
readBytes, writeBytes := m.getContainerDiskIOBytes(lxcName) cpuUsec := readCgroupCPUUsec(lxcName,
"/sys/fs/cgroup/lxc/%s/cpu.stat",
"/sys/fs/cgroup/lxc.payload.%s/cpu.stat")
rxBytes, txBytes := getNetworkBytesForPID(initPID)
readBytes, writeBytes := getDiskIOBytesForPID(initPID)
now := time.Now() now := time.Now()
sample := containerUsageSample{ sample := containerUsageSample{
@@ -378,6 +386,7 @@ func (m *Manager) CreateContainer(cfg ContainerConfig) error {
if err := m.shiftRootfsForUnprivileged(lxcName); err != nil { if err := m.shiftRootfsForUnprivileged(lxcName); err != nil {
_ = m.cleanupContainerStorage(lxcName) _ = m.cleanupContainerStorage(lxcName)
config.RemoveContainer(id)
return err return err
} }
@@ -454,13 +463,13 @@ IPv6AcceptRA=no
// preconfigureSSH installs and configures SSH directly in the rootfs before first boot. // preconfigureSSH installs and configures SSH directly in the rootfs before first boot.
func (m *Manager) preconfigureSSH(rootfsPath, password, templateID string) error { func (m *Manager) preconfigureSSH(rootfsPath, password, templateID string) error {
_ = templateID _ = templateID
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel() defer cancel()
cmd := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(password, false)) cmd := m.rootfsCommand(rootfsPath, "sh", "-c", sshSetupScript(password, false))
cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...) cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if ctx.Err() == context.DeadlineExceeded { if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("timed out after 120s, output: %s", string(output)) return fmt.Errorf("timed out after 180s, output: %s", string(output))
} }
if err != nil { if err != nil {
return fmt.Errorf("%v, output: %s", err, string(output)) return fmt.Errorf("%v, output: %s", err, string(output))
@@ -980,6 +989,16 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
if _, err := os.Stat(marker); err == nil { if _, err := os.Stat(marker); err == nil {
return nil return nil
} }
m.unmountRootfsChildMounts(rootfsPath)
rootInfo, err := os.Lstat(rootfsPath)
if err != nil {
return err
}
rootStat, ok := rootInfo.Sys().(*syscall.Stat_t)
if !ok {
return fmt.Errorf("failed to read rootfs device for %s", rootfsPath)
}
rootDev := rootStat.Dev
if err := filepath.WalkDir(rootfsPath, func(path string, _ os.DirEntry, walkErr error) error { if err := filepath.WalkDir(rootfsPath, func(path string, _ os.DirEntry, walkErr error) error {
if walkErr != nil { if walkErr != nil {
@@ -993,6 +1012,12 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
if !ok { if !ok {
return fmt.Errorf("failed to read uid/gid for %s", path) return fmt.Errorf("failed to read uid/gid for %s", path)
} }
if path != rootfsPath && stat.Dev != rootDev {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
uid := int(stat.Uid) uid := int(stat.Uid)
gid := int(stat.Gid) gid := int(stat.Gid)
if uid >= uidBase && uid < uidBase+65536 && gid >= gidBase && gid < gidBase+65536 { if uid >= uidBase && uid < uidBase+65536 && gid >= gidBase && gid < gidBase+65536 {
@@ -1027,6 +1052,34 @@ func (m *Manager) shiftRootfsForUnprivileged(lxcName string) error {
return nil return nil
} }
func (m *Manager) unmountRootfsChildMounts(rootfsPath string) {
rootAbs, err := filepath.Abs(rootfsPath)
if err != nil {
return
}
out, err := exec.Command("findmnt", "-R", "-n", "-o", "TARGET", rootfsPath).Output()
if err != nil {
return
}
targets := strings.Split(strings.TrimSpace(string(out)), "\n")
for i, j := 0, len(targets)-1; i < j; i, j = i+1, j-1 {
targets[i], targets[j] = targets[j], targets[i]
}
for _, target := range targets {
target = strings.TrimSpace(target)
if target == "" {
continue
}
targetAbs, err := filepath.Abs(target)
if err != nil || targetAbs == rootAbs {
continue
}
if strings.HasPrefix(targetAbs, rootAbs+string(os.PathSeparator)) {
exec.Command("umount", "-R", "-l", targetAbs).Run()
}
}
}
func (m *Manager) rootfsShifted(lxcName string) bool { func (m *Manager) rootfsShifted(lxcName string) bool {
marker := filepath.Join(m.LxcPath, lxcName, "rootfs", ".clicd-unprivileged-shifted") marker := filepath.Join(m.LxcPath, lxcName, "rootfs", ".clicd-unprivileged-shifted")
_, err := os.Stat(marker) _, err := os.Stat(marker)
@@ -1474,11 +1527,20 @@ func (m *Manager) DestroyContainer(id int) error {
} }
return fmt.Errorf("container still exists after cleanup with status %s", status) return fmt.Errorf("container still exists after cleanup with status %s", status)
} }
snapshotDir := filepath.Join(snapshotBaseDir(), lxcName) // Remove snapshot physical files (by container ID, not lxcName)
snapshotDir := filepath.Join(snapshotBaseDir(), strconv.Itoa(id))
if err := safePathUnder(snapshotDir, snapshotBaseDir()); err == nil { if err := safePathUnder(snapshotDir, snapshotBaseDir()); err == nil {
os.RemoveAll(snapshotDir) os.RemoveAll(snapshotDir)
} }
// Also remove any legacy snapshot dir that used lxcName
legacySnapshotDir := filepath.Join(snapshotBaseDir(), lxcName)
if legacySnapshotDir != snapshotDir {
if err := safePathUnder(legacySnapshotDir, snapshotBaseDir()); err == nil {
os.RemoveAll(legacySnapshotDir)
}
}
if !config.RemoveContainer(id) { if !config.RemoveContainer(id) {
return fmt.Errorf("container destroyed but config entry was not removed: %d", id) return fmt.Errorf("container destroyed but config entry was not removed: %d", id)
} }
@@ -1513,12 +1575,12 @@ func (m *Manager) EnsureSSH(id int) error {
script := sshSetupScript(c.SSHPassword, true) script := sshSetupScript(c.SSHPassword, true)
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel() defer cancel()
cmd := exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", script) cmd := exec.CommandContext(ctx, "lxc-attach", "-n", lxcName, "--", "sh", "-c", script)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if ctx.Err() == context.DeadlineExceeded { if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("timed out configuring SSH in container %d after 90s; package manager or service startup may be stuck, output: %s", id, string(output)) return fmt.Errorf("timed out configuring SSH in container %d after 180s; package manager or service startup may be stuck, output: %s", id, string(output))
} }
if err != nil { if err != nil {
return fmt.Errorf("failed to configure SSH in container %d: %v, output: %s", id, err, string(output)) return fmt.Errorf("failed to configure SSH in container %d: %v, output: %s", id, err, string(output))
@@ -1626,7 +1688,10 @@ install_sshd() {
sleep 3 sleep 3
done done
elif command -v apk >/dev/null 2>&1; then elif command -v apk >/dev/null 2>&1; then
run_timeout 60 apk add --no-cache openssh-server openssh-client shadow iproute2 procps net-tools && return 0 for i in 1 2 3; do
run_timeout 120 apk add --no-cache openssh-server openssh-client shadow iproute2 procps net-tools && return 0
sleep 3
done
elif command -v pacman >/dev/null 2>&1; then elif command -v pacman >/dev/null 2>&1; then
run_timeout 45 pacman -Syu --noconfirm >/dev/null 2>&1 || true run_timeout 45 pacman -Syu --noconfirm >/dev/null 2>&1 || true
run_timeout 90 pacman -S --noconfirm openssh shadow iproute2 procps-ng net-tools && return 0 run_timeout 90 pacman -S --noconfirm openssh shadow iproute2 procps-ng net-tools && return 0
@@ -2245,10 +2310,38 @@ func (m *Manager) getContainerNetworkBytes(lxcName string) (uint64, uint64) {
if pid == "" { if pid == "" {
return 0, 0 return 0, 0
} }
dir := fmt.Sprintf("/proc/%s/net", pid) return readProcNetDev(fmt.Sprintf("/proc/%s/net/dev", pid))
rx := readIntCommand(fmt.Sprintf("cat %s/dev 2>/dev/null | awk '{rx+=$2; tx+=$10} END {print rx}' || echo 0", shellQuote(dir))) }
tx := readIntCommand(fmt.Sprintf("cat %s/dev 2>/dev/null | awk '{rx+=$2; tx+=$10} END {print tx}' || echo 0", shellQuote(dir)))
return uint64(rx), uint64(tx) // readProcNetDev parses /proc/PID/net/dev directly (no shell/awk fork).
func readProcNetDev(path string) (uint64, uint64) {
data, err := os.ReadFile(path)
if err != nil {
return 0, 0
}
var rx, tx uint64
scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
line := scanner.Text()
// Skip header lines
if strings.Contains(line, "|") || strings.Contains(line, "face") || strings.TrimSpace(line) == "" {
continue
}
fields := strings.Fields(line)
// Fields: face | rx_bytes rx_packets rx_errs rx_drop rx_fifo rx_frame rx_compressed rx_multicast | tx_bytes tx_packets tx_errs tx_drop tx_fifo tx_colls tx_carrier tx_compressed
// Skip loopback (face starts with "lo")
if len(fields) < 10 {
continue
}
if strings.HasPrefix(fields[0], "lo") {
continue
}
r, _ := strconv.ParseUint(fields[1], 10, 64)
t, _ := strconv.ParseUint(fields[9], 10, 64)
rx += r
tx += t
}
return rx, tx
} }
func (m *Manager) getContainerDiskIOBytes(lxcName string) (uint64, uint64) { func (m *Manager) getContainerDiskIOBytes(lxcName string) (uint64, uint64) {
@@ -2256,10 +2349,22 @@ func (m *Manager) getContainerDiskIOBytes(lxcName string) (uint64, uint64) {
if pid == "" { if pid == "" {
return 0, 0 return 0, 0
} }
// /proc/PID/io format: "field_name: value" per line data, err := os.ReadFile(fmt.Sprintf("/proc/%s/io", pid))
// Fields: rchar, wchar, syscr, syscw, read_bytes, write_bytes, cancelled_write_bytes if err != nil {
readBytes := uint64(readIntCommand(fmt.Sprintf("awk '/^read_bytes:/ {print $2}' /proc/%s/io 2>/dev/null || echo 0", pid))) return 0, 0
writeBytes := uint64(readIntCommand(fmt.Sprintf("awk '/^write_bytes:/ {print $2}' /proc/%s/io 2>/dev/null || echo 0", pid))) }
var readBytes, writeBytes uint64
scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "read_bytes:") {
val := strings.TrimSpace(strings.TrimPrefix(line, "read_bytes:"))
readBytes, _ = strconv.ParseUint(val, 10, 64)
} else if strings.HasPrefix(line, "write_bytes:") {
val := strings.TrimSpace(strings.TrimPrefix(line, "write_bytes:"))
writeBytes, _ = strconv.ParseUint(val, 10, 64)
}
}
return readBytes, writeBytes return readBytes, writeBytes
} }
@@ -2272,6 +2377,73 @@ func (m *Manager) getContainerInitPID(lxcName string) string {
return strings.TrimSpace(string(out)) return strings.TrimSpace(string(out))
} }
// readCgroupFile tries each path template in order, reads the file directly (no shell),
// and returns the first valid int64 value.
func readCgroupFile(name string, paths ...string) int64 {
for _, tmpl := range paths {
data, err := os.ReadFile(fmt.Sprintf(tmpl, name))
if err != nil {
continue
}
val, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
if err == nil && val > 0 {
return val
}
}
return 0
}
// readCgroupCPUUsec tries each path template, reads cpu.stat, and extracts usage_usec.
func readCgroupCPUUsec(name string, paths ...string) uint64 {
for _, tmpl := range paths {
data, err := os.ReadFile(fmt.Sprintf(tmpl, name))
if err != nil {
continue
}
scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "usage_usec ") {
val, err := strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(line, "usage_usec")), 10, 64)
if err == nil {
return val
}
}
}
}
return 0
}
// getNetworkBytesForPID reads /proc/PID/net/dev for a given PID (no lxc-info needed).
func getNetworkBytesForPID(pid string) (uint64, uint64) {
if pid == "" {
return 0, 0
}
return readProcNetDev(fmt.Sprintf("/proc/%s/net/dev", pid))
}
// getDiskIOBytesForPID reads /proc/PID/io for a given PID (no lxc-info needed).
func getDiskIOBytesForPID(pid string) (uint64, uint64) {
if pid == "" {
return 0, 0
}
data, err := os.ReadFile(fmt.Sprintf("/proc/%s/io", pid))
if err != nil {
return 0, 0
}
var readBytes, writeBytes uint64
scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "read_bytes:") {
readBytes, _ = strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(line, "read_bytes:")), 10, 64)
} else if strings.HasPrefix(line, "write_bytes:") {
writeBytes, _ = strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(line, "write_bytes:")), 10, 64)
}
}
return readBytes, writeBytes
}
// getContainerUptimeSeconds returns how long the container has been running (in seconds). // getContainerUptimeSeconds returns how long the container has been running (in seconds).
func (m *Manager) getContainerUptimeSeconds(lxcName string) float64 { func (m *Manager) getContainerUptimeSeconds(lxcName string) float64 {
pid := m.getContainerInitPID(lxcName) pid := m.getContainerInitPID(lxcName)
@@ -2399,6 +2571,7 @@ func (m *Manager) AccumulateTraffic() {
lastTrafficSnapshotMu.Lock() lastTrafficSnapshotMu.Lock()
defer lastTrafficSnapshotMu.Unlock() defer lastTrafficSnapshotMu.Unlock()
changed := false
for i := range config.AppConfig.Containers { for i := range config.AppConfig.Containers {
c := &config.AppConfig.Containers[i] c := &config.AppConfig.Containers[i]
if c.Status != "running" { if c.Status != "running" {
@@ -2412,17 +2585,25 @@ func (m *Manager) AccumulateTraffic() {
c.TrafficUsedTX = 0 c.TrafficUsedTX = 0
c.TrafficResetDate = currentMonth c.TrafficResetDate = currentMonth
delete(lastTrafficSnapshot, c.LxcName()) delete(lastTrafficSnapshot, c.LxcName())
changed = true
} }
rx, tx := m.getContainerNetworkBytes(c.LxcName()) rx, tx := m.getContainerNetworkBytes(c.LxcName())
prev, exists := lastTrafficSnapshot[c.LxcName()] prev, exists := lastTrafficSnapshot[c.LxcName()]
// Only add the DELTA (increment since last snapshot) // Only add the DELTA (increment since last snapshot)
if exists && rx >= prev.RXBytes && tx >= prev.TXBytes { if exists && rx >= prev.RXBytes && tx >= prev.TXBytes {
c.TrafficUsedRX += int64(rx - prev.RXBytes) deltaRX := int64(rx - prev.RXBytes)
c.TrafficUsedTX += int64(tx - prev.TXBytes) deltaTX := int64(tx - prev.TXBytes)
if deltaRX > 0 || deltaTX > 0 {
c.TrafficUsedRX += deltaRX
c.TrafficUsedTX += deltaTX
changed = true
}
} }
lastTrafficSnapshot[c.LxcName()] = trafficSample{RXBytes: rx, TXBytes: tx} lastTrafficSnapshot[c.LxcName()] = trafficSample{RXBytes: rx, TXBytes: tx}
} }
config.SaveConfig() if changed {
config.SaveConfig()
}
} }
// GetTrafficInfo returns traffic usage info for a container // GetTrafficInfo returns traffic usage info for a container
+2 -1
View File
@@ -45,7 +45,8 @@ func (m *Manager) CreateSnapshot(id int, createdBy string, scheduled bool, rotat
now := time.Now() now := time.Now()
snapshotID := fmt.Sprintf("snap-%d-%s", id, now.Format("20060102150405-000000000")) snapshotID := fmt.Sprintf("snap-%d-%s", id, now.Format("20060102150405-000000000"))
snapshotDir := filepath.Join(snapshotBaseDir(), lxcName, snapshotID) // Use container ID instead of lxcName to avoid collision when containers are recreated
snapshotDir := filepath.Join(snapshotBaseDir(), strconv.Itoa(id), snapshotID)
if err := safePathUnder(snapshotDir, snapshotBaseDir()); err != nil { if err := safePathUnder(snapshotDir, snapshotBaseDir()); err != nil {
return config.Snapshot{}, err return config.Snapshot{}, err
} }
+5 -15
View File
@@ -7,11 +7,9 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"time"
"clicd/internal/api" "clicd/internal/api"
"clicd/internal/config" "clicd/internal/config"
"clicd/internal/lxc"
) )
// webFS holds embedded frontend files // webFS holds embedded frontend files
@@ -100,6 +98,8 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/sub-user/create", corsMiddleware(api.AdminMiddleware(api.HandleSubUserCreate))) mux.HandleFunc("/api/sub-user/create", corsMiddleware(api.AdminMiddleware(api.HandleSubUserCreate)))
mux.HandleFunc("/api/sub-user/login", corsMiddleware(api.HandleSubUserLogin)) mux.HandleFunc("/api/sub-user/login", corsMiddleware(api.HandleSubUserLogin))
mux.HandleFunc("/api/sub-user/access", corsMiddleware(api.HandleSubUserAccessCode)) mux.HandleFunc("/api/sub-user/access", corsMiddleware(api.HandleSubUserAccessCode))
mux.HandleFunc("/api/sub-users", corsMiddleware(api.AdminMiddleware(api.HandleSubUserList)))
mux.HandleFunc("/api/sub-users/", corsMiddleware(api.AdminMiddleware(api.HandleSubUserAction)))
mux.HandleFunc("/api/audit-logs", corsMiddleware(api.AdminMiddleware(api.HandleAuditLogs))) mux.HandleFunc("/api/audit-logs", corsMiddleware(api.AdminMiddleware(api.HandleAuditLogs)))
mux.HandleFunc("/api/security/alerts", corsMiddleware(api.AdminMiddleware(api.HandleSecurityAlerts))) mux.HandleFunc("/api/security/alerts", corsMiddleware(api.AdminMiddleware(api.HandleSecurityAlerts)))
mux.HandleFunc("/api/security/check", corsMiddleware(api.AdminMiddleware(api.HandleSecurityCheck))) mux.HandleFunc("/api/security/check", corsMiddleware(api.AdminMiddleware(api.HandleSecurityCheck)))
@@ -112,6 +112,9 @@ func setupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/api-keys", corsMiddleware(api.AdminMiddleware(api.HandleApiKeys))) mux.HandleFunc("/api/api-keys", corsMiddleware(api.AdminMiddleware(api.HandleApiKeys)))
mux.HandleFunc("/api/api-keys/", corsMiddleware(api.AdminMiddleware(api.HandleApiKeyDelete))) mux.HandleFunc("/api/api-keys/", corsMiddleware(api.AdminMiddleware(api.HandleApiKeyDelete)))
// Version (public)
mux.HandleFunc("/api/version", corsMiddleware(api.HandleVersion))
// Static files // Static files
if webFS != nil { if webFS != nil {
fs := http.FileServer(webFS) fs := http.FileServer(webFS)
@@ -146,7 +149,6 @@ func setupRoutes(mux *http.ServeMux) {
func Run() error { func Run() error {
// Use embedded frontend files // Use embedded frontend files
webFS = GetEmbeddedFS() webFS = GetEmbeddedFS()
startExpiryMonitor()
mux := http.NewServeMux() mux := http.NewServeMux()
setupRoutes(mux) setupRoutes(mux)
@@ -163,15 +165,3 @@ func Run() error {
return server.ListenAndServe() return server.ListenAndServe()
} }
func startExpiryMonitor() {
manager := lxc.NewManager()
go func() {
manager.StopExpiredContainers(time.Now())
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for now := range ticker.C {
manager.StopExpiredContainers(now)
}
}()
}
-1
View File
@@ -1 +0,0 @@
+5 -1
View File
@@ -1,7 +1,7 @@
package version package version
var ( var (
Version = "1.0.6" Version = "1.0.10"
Repo = "MengMengCode/CLICD" Repo = "MengMengCode/CLICD"
) )
@@ -12,3 +12,7 @@ func Current() string {
return Version return Version
} }
+9 -1
View File
@@ -5,8 +5,16 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CLICD - LXC Container Manager</title> <title>CLICD - LXC Container Manager</title>
<script>
(function() {
var theme = localStorage.getItem('clicd_theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
})();
</script>
</head> </head>
<body class="bg-white text-black"> <body class="bg-white text-black dark:bg-gray-950 dark:text-white">
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
+4 -2
View File
@@ -12,6 +12,7 @@ import Settings from './pages/Settings'
import ImageManagement from './pages/ImageManagement' import ImageManagement from './pages/ImageManagement'
import Snapshots from './pages/Snapshots' import Snapshots from './pages/Snapshots'
import Routing from './pages/Routing' import Routing from './pages/Routing'
import SubUserManagement from './pages/SubUserManagement'
import Layout from './components/Layout' import Layout from './components/Layout'
function ProtectedRoute({ children }: { children: React.ReactNode }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -19,8 +20,8 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
if (isLoading) { if (isLoading) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-white"> <div className="min-h-screen flex items-center justify-center bg-white dark:bg-gray-950">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black dark:border-white"></div>
</div> </div>
) )
} }
@@ -63,6 +64,7 @@ function App() {
<Route path="routing" element={<Routing />} /> <Route path="routing" element={<Routing />} />
<Route path="audit-logs" element={<AuditLogs />} /> <Route path="audit-logs" element={<AuditLogs />} />
<Route path="api-integration" element={<ApiIntegration />} /> <Route path="api-integration" element={<ApiIntegration />} />
<Route path="sub-users" element={<SubUserManagement />} />
<Route path="settings" element={<Settings />} /> <Route path="settings" element={<Settings />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
@@ -7,6 +7,7 @@ interface CreateContainerModalProps {
isOpen: boolean isOpen: boolean
onClose: () => void onClose: () => void
onSuccess: (containers: CreateContainerRequest[]) => void | Promise<void> onSuccess: (containers: CreateContainerRequest[]) => void | Promise<void>
existingNames?: string[]
} }
const defaultForm: CreateContainerRequest = { const defaultForm: CreateContainerRequest = {
@@ -29,7 +30,7 @@ const defaultForm: CreateContainerRequest = {
expires_at: '', expires_at: '',
} }
export default function CreateContainerModal({ isOpen, onClose, onSuccess }: CreateContainerModalProps) { export default function CreateContainerModal({ isOpen, onClose, onSuccess, existingNames = [] }: CreateContainerModalProps) {
const dialog = useDialog() const dialog = useDialog()
const [templates, setTemplates] = useState<Template[]>([]) const [templates, setTemplates] = useState<Template[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -37,6 +38,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
const [form, setForm] = useState<CreateContainerRequest>(defaultForm) const [form, setForm] = useState<CreateContainerRequest>(defaultForm)
const [hostInfo, setHostInfo] = useState<HostInfo | null>(null) const [hostInfo, setHostInfo] = useState<HostInfo | null>(null)
const [ipv6Status, setIPv6Status] = useState<IPv6Status | null>(null) const [ipv6Status, setIPv6Status] = useState<IPv6Status | null>(null)
const [nameError, setNameError] = useState('')
useEffect(() => { useEffect(() => {
if (!isOpen) return if (!isOpen) return
@@ -83,6 +85,34 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
// SSH port preview (will be allocated sequentially, starting around 22000+) // SSH port preview (will be allocated sequentially, starting around 22000+)
const sshPortPreview = 22000 const sshPortPreview = 22000
// Find next available batch index to avoid name conflicts
const batchStartIndex = useMemo(() => {
if (batchCount <= 1 || !form.name) return 1
const prefix = `${form.name}-`
let maxIdx = 0
for (const existing of existingNames) {
if (existing.startsWith(prefix)) {
const suffix = existing.slice(prefix.length)
const idx = parseInt(suffix, 10)
if (!isNaN(idx) && idx > maxIdx) {
maxIdx = idx
}
}
}
return maxIdx + 1
}, [form.name, batchCount, existingNames])
const handleNameChange = (value: string) => {
setForm({ ...form, name: value })
if (/\s/.test(value)) {
setNameError('容器名称不能包含空格')
} else if (value && existingNames.includes(value) && batchCount === 1) {
setNameError('该容器名称已存在')
} else {
setNameError('')
}
}
const handleSubmit = async () => { const handleSubmit = async () => {
if (!form.name || !form.template_id) { if (!form.name || !form.template_id) {
dialog.alert('提示', '请填写容器名称并选择系统模板') dialog.alert('提示', '请填写容器名称并选择系统模板')
@@ -93,8 +123,9 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
// Build batch of containers // Build batch of containers
const containers: CreateContainerRequest[] = [] const containers: CreateContainerRequest[] = []
const startIndex = batchStartIndex
for (let i = 0; i < batchCount; i++) { for (let i = 0; i < batchCount; i++) {
const name = batchCount > 1 ? `${boundedForm.name}-${i + 1}` : boundedForm.name const name = batchCount > 1 ? `${boundedForm.name}-${startIndex + i}` : boundedForm.name
containers.push({ containers.push({
...boundedForm, ...boundedForm,
name, name,
@@ -137,17 +168,18 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess }: Cre
<input <input
type="text" type="text"
value={form.name} value={form.name}
onChange={(event) => setForm({ ...form, name: event.target.value })} onChange={(event) => handleNameChange(event.target.value)}
className={inputClass} className={`${inputClass} ${nameError ? 'border-red-400 focus:ring-red-400 focus:border-red-400' : ''}`}
placeholder="my-container" placeholder="my-container"
required required
/> />
{nameError && <p className="text-xs text-red-500 mt-1">{nameError}</p>}
</Field> </Field>
<Field label="批量创建数量"> <Field label="批量创建数量">
<NumberInput value={batchCount} min={1} max={50} onChange={(value) => setBatchCount(Math.max(1, value || 1))} /> <NumberInput value={batchCount} min={1} max={50} onChange={(value) => setBatchCount(Math.max(1, value || 1))} />
</Field> </Field>
</div> </div>
{batchCount > 1 && <p className="text-xs text-gray-400"> {batchCount} {form.name}-1 {form.name}-{batchCount}</p>} {batchCount > 1 && <p className="text-xs text-gray-400"> {batchCount} {form.name}-{batchStartIndex} {form.name}-{batchStartIndex + batchCount - 1}</p>}
<Field label="系统模板"> <Field label="系统模板">
{templates.length === 0 ? ( {templates.length === 0 ? (
+1 -1
View File
@@ -6,7 +6,7 @@ export default function Layout() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false) const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
return ( return (
<div className="min-h-screen bg-gray-50 flex"> <div className="min-h-screen bg-gray-50 flex dark:bg-gray-950">
<Sidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed(!sidebarCollapsed)} /> <Sidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed(!sidebarCollapsed)} />
<main className={`flex-1 transition-all duration-300 ${sidebarCollapsed ? 'ml-16' : 'ml-60'}`}> <main className={`flex-1 transition-all duration-300 ${sidebarCollapsed ? 'ml-16' : 'ml-60'}`}>
<div className="p-6"> <div className="p-6">
+35 -21
View File
@@ -1,5 +1,6 @@
import { ReactNode } from 'react' import { ReactNode } from 'react'
import { RefreshCw } from 'lucide-react' import { RefreshCw } from 'lucide-react'
import { useTheme } from '../contexts/ThemeContext'
export type StatsRangeKey = '30m' | '1h' | '1d' | '1w' export type StatsRangeKey = '30m' | '1h' | '1d' | '1w'
@@ -45,17 +46,19 @@ export default function ResourceStatsPanel({
charts: ResourceChartConfig[] charts: ResourceChartConfig[]
}) { }) {
return ( return (
<section className="border border-gray-200 rounded-lg bg-white overflow-hidden"> <section className="border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 overflow-hidden">
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b border-gray-200 bg-white"> <div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
<h2 className="text-sm font-semibold text-gray-950"></h2> <h2 className="text-sm font-semibold text-gray-950 dark:text-white"></h2>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="inline-flex rounded border border-gray-200 bg-gray-50 p-0.5"> <div className="inline-flex rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 p-0.5">
{(Object.keys(rangeLabels) as StatsRangeKey[]).map((item) => ( {(Object.keys(rangeLabels) as StatsRangeKey[]).map((item) => (
<button <button
key={item} key={item}
onClick={() => onRangeChange(item)} onClick={() => onRangeChange(item)}
className={`h-7 px-3 rounded text-xs font-medium transition-colors ${ className={`h-7 px-3 rounded text-xs font-medium transition-colors ${
range === item ? 'bg-gray-800 text-white shadow-sm' : 'text-gray-500 hover:text-gray-900' range === item
? 'bg-gray-800 text-white shadow-sm dark:bg-white dark:text-black'
: 'text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white'
}`} }`}
> >
{rangeLabels[item]} {rangeLabels[item]}
@@ -64,7 +67,7 @@ export default function ResourceStatsPanel({
</div> </div>
<button <button
onClick={onRefresh} onClick={onRefresh}
className="h-8 w-8 inline-flex items-center justify-center rounded border border-gray-200 text-gray-500 hover:bg-gray-50 hover:text-gray-900" className="h-8 w-8 inline-flex items-center justify-center rounded border border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white"
title="刷新" title="刷新"
> >
<RefreshCw className="w-4 h-4" /> <RefreshCw className="w-4 h-4" />
@@ -90,11 +93,11 @@ function DetailedChart({ chart, className }: { chart: ResourceChartConfig; class
<div className={`p-4 ${className}`}> <div className={`p-4 ${className}`}>
<div className="flex items-start justify-between gap-3 mb-2"> <div className="flex items-start justify-between gap-3 mb-2">
<div> <div>
<div className="flex items-center gap-1.5 text-sm font-semibold text-gray-950"> <div className="flex items-center gap-1.5 text-sm font-semibold text-gray-950 dark:text-white">
<span className="text-gray-500">{chart.icon}</span> <span className="text-gray-500 dark:text-gray-400">{chart.icon}</span>
<span>{chart.title}</span> <span>{chart.title}</span>
</div> </div>
{chart.detail && <p className="mt-0.5 text-[11px] text-gray-400">{chart.detail}</p>} {chart.detail && <p className="mt-0.5 text-[11px] text-gray-400 dark:text-gray-500">{chart.detail}</p>}
</div> </div>
<div className="grid grid-cols-3 gap-3 text-right"> <div className="grid grid-cols-3 gap-3 text-right">
<Stat label="当前" value={chart.formatValue(chart.current)} /> <Stat label="当前" value={chart.formatValue(chart.current)} />
@@ -115,8 +118,8 @@ function DetailedChart({ chart, className }: { chart: ResourceChartConfig; class
function Stat({ label, value }: { label: string; value: string }) { function Stat({ label, value }: { label: string; value: string }) {
return ( return (
<div> <div>
<div className="text-[10px] text-gray-400">{label}</div> <div className="text-[10px] text-gray-400 dark:text-gray-500">{label}</div>
<div className="text-xs font-semibold text-gray-900 tabular-nums whitespace-nowrap">{value}</div> <div className="text-xs font-semibold text-gray-900 dark:text-gray-100 tabular-nums whitespace-nowrap">{value}</div>
</div> </div>
) )
} }
@@ -132,6 +135,9 @@ function LineAreaChart({
formatValue: (value: number) => string formatValue: (value: number) => string
unitLabel?: string unitLabel?: string
}) { }) {
const { theme } = useTheme()
const isDark = theme === 'dark'
const width = 520 const width = 520
const height = 150 const height = 150
const left = 50 const left = 50
@@ -158,12 +164,20 @@ function LineAreaChart({
const yTicks = [1, 0.5, 0] const yTicks = [1, 0.5, 0]
const xTicks = [0, 0.5, 1] const xTicks = [0, 0.5, 1]
// Dark mode colors
const gridStroke = isDark ? '#374151' : '#e5e7eb'
const gridStrokeV = isDark ? '#1f2937' : '#edf0f2'
const axisStroke = isDark ? '#9ca3af' : '#888'
const lineStroke = isDark ? '#f9fafb' : '#444'
const gradientTop = isDark ? '#f9fafb' : '#555'
const gradientBottom = isDark ? '#374151' : '#555'
return ( return (
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-[140px]" preserveAspectRatio="none"> <svg viewBox={`0 0 ${width} ${height}`} className="w-full h-[140px]" preserveAspectRatio="none">
<defs> <defs>
<linearGradient id="resource-chart-fill" x1="0" x2="0" y1="0" y2="1"> <linearGradient id="resource-chart-fill" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#555" stopOpacity="0.25" /> <stop offset="0%" stopColor={gradientTop} stopOpacity="0.25" />
<stop offset="100%" stopColor="#555" stopOpacity="0.02" /> <stop offset="100%" stopColor={gradientBottom} stopOpacity="0.02" />
</linearGradient> </linearGradient>
</defs> </defs>
@@ -171,8 +185,8 @@ function LineAreaChart({
const y = top + (1 - tick) * innerHeight const y = top + (1 - tick) * innerHeight
return ( return (
<g key={tick}> <g key={tick}>
<line x1={left} y1={y} x2={left + innerWidth} y2={y} stroke="#e5e7eb" strokeDasharray="3 3" /> <line x1={left} y1={y} x2={left + innerWidth} y2={y} stroke={gridStroke} strokeDasharray="3 3" />
<text x={left - 8} y={y + 3} textAnchor="end" fontSize="10" fill="#888"> <text x={left - 8} y={y + 3} textAnchor="end" fontSize="10" fill={axisStroke}>
{formatValue(maxValue * tick)} {formatValue(maxValue * tick)}
</text> </text>
</g> </g>
@@ -184,8 +198,8 @@ function LineAreaChart({
const ts = minTs + tick * span const ts = minTs + tick * span
return ( return (
<g key={tick}> <g key={tick}>
<line x1={x} y1={top} x2={x} y2={top + innerHeight} stroke="#edf0f2" strokeDasharray="3 3" /> <line x1={x} y1={top} x2={x} y2={top + innerHeight} stroke={gridStrokeV} strokeDasharray="3 3" />
<text x={x} y={height - 5} textAnchor={tick === 0 ? 'start' : tick === 1 ? 'end' : 'middle'} fontSize="10" fill="#888"> <text x={x} y={height - 5} textAnchor={tick === 0 ? 'start' : tick === 1 ? 'end' : 'middle'} fontSize="10" fill={axisStroke}>
{formatTime(ts)} {formatTime(ts)}
</text> </text>
</g> </g>
@@ -193,15 +207,15 @@ function LineAreaChart({
})} })}
{unitLabel && ( {unitLabel && (
<text x={left - 45} y={top + 10} fontSize="10" fill="#888"> <text x={left - 45} y={top + 10} fontSize="10" fill={axisStroke}>
{unitLabel} {unitLabel}
</text> </text>
)} )}
<line x1={left} y1={top} x2={left} y2={top + innerHeight} stroke="#888" /> <line x1={left} y1={top} x2={left} y2={top + innerHeight} stroke={axisStroke} />
<line x1={left} y1={top + innerHeight} x2={left + innerWidth} y2={top + innerHeight} stroke="#888" /> <line x1={left} y1={top + innerHeight} x2={left + innerWidth} y2={top + innerHeight} stroke={axisStroke} />
<polygon points={area} fill="url(#resource-chart-fill)" /> <polygon points={area} fill="url(#resource-chart-fill)" />
<polyline points={line} fill="none" stroke="#444" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> <polyline points={line} fill="none" stroke={lineStroke} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
) )
} }
+14 -7
View File
@@ -1,4 +1,5 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { useTheme } from '../contexts/ThemeContext'
interface RingStatProps { interface RingStatProps {
value: number value: number
@@ -10,11 +11,17 @@ interface RingStatProps {
} }
export function RingStat({ value, max = 100, label, subLabel, size = 120, strokeWidth = 8 }: RingStatProps) { export function RingStat({ value, max = 100, label, subLabel, size = 120, strokeWidth = 8 }: RingStatProps) {
const { theme } = useTheme()
const isDark = theme === 'dark'
const radius = (size - strokeWidth) / 2 const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI const circumference = radius * 2 * Math.PI
const percentage = Math.min(Math.max(value / max * 100, 0), 100) const percentage = Math.min(Math.max(value / max * 100, 0), 100)
const strokeDashoffset = circumference - (percentage / 100) * circumference const strokeDashoffset = circumference - (percentage / 100) * circumference
const bgStroke = isDark ? '#374151' : '#f3f4f6'
const progressStroke = isDark ? '#f9fafb' : '#000000'
return ( return (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div className="relative" style={{ width: size, height: size }}> <div className="relative" style={{ width: size, height: size }}>
@@ -25,7 +32,7 @@ export function RingStat({ value, max = 100, label, subLabel, size = 120, stroke
cy={size / 2} cy={size / 2}
r={radius} r={radius}
fill="none" fill="none"
stroke="#f3f4f6" stroke={bgStroke}
strokeWidth={strokeWidth} strokeWidth={strokeWidth}
/> />
{/* Progress ring */} {/* Progress ring */}
@@ -34,7 +41,7 @@ export function RingStat({ value, max = 100, label, subLabel, size = 120, stroke
cy={size / 2} cy={size / 2}
r={radius} r={radius}
fill="none" fill="none"
stroke="#000000" stroke={progressStroke}
strokeWidth={strokeWidth} strokeWidth={strokeWidth}
strokeLinecap="round" strokeLinecap="round"
strokeDasharray={circumference} strokeDasharray={circumference}
@@ -44,12 +51,12 @@ export function RingStat({ value, max = 100, label, subLabel, size = 120, stroke
</svg> </svg>
{/* Center value */} {/* Center value */}
<div className="absolute inset-0 flex flex-col items-center justify-center"> <div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-2xl font-bold text-black">{value.toFixed(percentage < 1 ? 2 : 1)}%</span> <span className="text-2xl font-bold text-black dark:text-white">{value.toFixed(percentage < 1 ? 2 : 1)}%</span>
</div> </div>
</div> </div>
<div className="mt-2 text-center"> <div className="mt-2 text-center">
<div className="text-sm font-medium text-gray-800">{label}</div> <div className="text-sm font-medium text-gray-800 dark:text-gray-200">{label}</div>
{subLabel && <div className="text-xs text-gray-400 mt-0.5">{subLabel}</div>} {subLabel && <div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{subLabel}</div>}
</div> </div>
</div> </div>
) )
@@ -96,8 +103,8 @@ export default function RingStats({
const hasSwap = swapTotal > 0 const hasSwap = swapTotal > 0
return ( return (
<div className="bg-white border border-gray-200 rounded-lg p-5"> <div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-5">
<h2 className="text-sm font-semibold text-black mb-4"></h2> <h2 className="text-sm font-semibold text-black dark:text-white mb-4"></h2>
<div className={`grid ${hasSwap ? 'grid-cols-5' : 'grid-cols-4'} gap-3`}> <div className={`grid ${hasSwap ? 'grid-cols-5' : 'grid-cols-4'} gap-3`}>
<RingStat <RingStat
value={cpuPercent} value={cpuPercent}
+83 -28
View File
@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { import {
ChevronLeft, ChevronLeft,
@@ -6,15 +7,19 @@ import {
Camera, Camera,
LayoutDashboard, LayoutDashboard,
LogOut, LogOut,
Moon,
Package, Package,
Route, Route,
ScrollText, ScrollText,
Server, Server,
Settings2, Settings2,
ShieldAlert, ShieldAlert,
Sun,
UserCog, UserCog,
} from 'lucide-react' } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { useTheme } from '../contexts/ThemeContext'
import { getVersion } from '../services/api'
import AppIcon from './AppIcon' import AppIcon from './AppIcon'
interface SidebarProps { interface SidebarProps {
@@ -26,6 +31,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const { logout, isSubUser } = useAuth() const { logout, isSubUser } = useAuth()
const { theme, toggleTheme } = useTheme()
const [version, setVersion] = useState('')
useEffect(() => {
getVersion()
.then(res => {
if (res.data?.data?.version) {
setVersion(res.data.data.version)
}
})
.catch(() => {})
}, [])
const isContainerPage = const isContainerPage =
location.pathname.startsWith('/containers') || location.pathname.startsWith('/containers') ||
@@ -42,27 +59,27 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
return ( return (
<aside <aside
className={`fixed left-0 top-0 h-full bg-white border-r border-gray-200 flex flex-col transition-all duration-300 z-30 ${ className={`fixed left-0 top-0 h-full bg-white border-r border-gray-200 flex flex-col transition-all duration-300 z-30 dark:bg-gray-900 dark:border-gray-700 ${
collapsed ? 'w-16' : 'w-60' collapsed ? 'w-16' : 'w-60'
}`} }`}
> >
<div className="flex items-center justify-between h-14 px-4 border-b border-gray-200"> <div className="flex items-center justify-between h-14 px-4 border-b border-gray-200 dark:border-gray-700">
{!collapsed && ( {!collapsed && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center"> <div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center dark:bg-gray-800">
<AppIcon className="w-5 h-5" /> <AppIcon className="w-5 h-5" />
</div> </div>
<span className="font-bold text-black text-sm">CLICD</span> <span className="font-bold text-black text-sm dark:text-white">CLICD</span>
</div> </div>
)} )}
{collapsed && ( {collapsed && (
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center mx-auto"> <div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center mx-auto dark:bg-gray-800">
<AppIcon className="w-5 h-5" /> <AppIcon className="w-5 h-5" />
</div> </div>
)} )}
<button <button
onClick={onToggle} onClick={onToggle}
className="p-1 rounded hover:bg-gray-100 text-gray-500" className="p-1 rounded hover:bg-gray-100 text-gray-500 dark:hover:bg-gray-800 dark:text-gray-400"
title="切换侧边栏" title="切换侧边栏"
> >
{collapsed ? ( {collapsed ? (
@@ -79,8 +96,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
onClick={() => navigate('/')} onClick={() => navigate('/')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
location.pathname === '/' location.pathname === '/'
? 'bg-black text-white' ? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`} }`}
> >
<LayoutDashboard className="w-4 h-4" /> <LayoutDashboard className="w-4 h-4" />
@@ -92,8 +109,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
onClick={() => navigate('/containers')} onClick={() => navigate('/containers')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isContainerPage isContainerPage
? 'bg-black text-white' ? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`} }`}
> >
<Server className="w-4 h-4" /> <Server className="w-4 h-4" />
@@ -105,8 +122,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
onClick={() => navigate('/images')} onClick={() => navigate('/images')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isImagesPage isImagesPage
? 'bg-black text-white' ? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`} }`}
> >
<Package className="w-4 h-4" /> <Package className="w-4 h-4" />
@@ -120,8 +137,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
onClick={() => navigate('/oversell')} onClick={() => navigate('/oversell')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isOversellPage isOversellPage
? 'bg-black text-white' ? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`} }`}
> >
<Settings2 className="w-4 h-4" /> <Settings2 className="w-4 h-4" />
@@ -132,8 +149,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
onClick={() => navigate('/security')} onClick={() => navigate('/security')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isSecurityPage isSecurityPage
? 'bg-black text-white' ? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`} }`}
> >
<ShieldAlert className="w-4 h-4" /> <ShieldAlert className="w-4 h-4" />
@@ -144,8 +161,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
onClick={() => navigate('/snapshots')} onClick={() => navigate('/snapshots')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isSnapshotsPage isSnapshotsPage
? 'bg-black text-white' ? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`} }`}
> >
<Camera className="w-4 h-4" /> <Camera className="w-4 h-4" />
@@ -156,8 +173,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
onClick={() => navigate('/routing')} onClick={() => navigate('/routing')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isRoutingPage isRoutingPage
? 'bg-black text-white' ? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`} }`}
> >
<Route className="w-4 h-4" /> <Route className="w-4 h-4" />
@@ -168,20 +185,32 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
onClick={() => navigate('/audit-logs')} onClick={() => navigate('/audit-logs')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isAuditLogsPage isAuditLogsPage
? 'bg-black text-white' ? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`} }`}
> >
<ScrollText className="w-4 h-4" /> <ScrollText className="w-4 h-4" />
{!collapsed && <span></span>} {!collapsed && <span></span>}
</button> </button>
<button
onClick={() => navigate('/sub-users')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
location.pathname.startsWith('/sub-users')
? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`}
>
<UserCog className="w-4 h-4" />
{!collapsed && <span></span>}
</button>
<button <button
onClick={() => navigate('/api-integration')} onClick={() => navigate('/api-integration')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isApiIntegrationPage isApiIntegrationPage
? 'bg-black text-white' ? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`} }`}
> >
<Code2 className="w-4 h-4" /> <Code2 className="w-4 h-4" />
@@ -192,8 +221,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
onClick={() => navigate('/settings')} onClick={() => navigate('/settings')}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${ className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
isSettingsPage isSettingsPage
? 'bg-black text-white' ? 'bg-black text-white dark:bg-white dark:text-black'
: 'text-gray-700 hover:bg-gray-100' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
}`} }`}
> >
<UserCog className="w-4 h-4" /> <UserCog className="w-4 h-4" />
@@ -203,10 +232,36 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
)} )}
</nav> </nav>
<div className="border-t border-gray-200 p-2"> <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>
{/* Version */}
{version && (
<div className={`px-3 py-2 text-xs text-gray-400 dark:text-gray-500 ${collapsed ? 'text-center' : ''}`}>
{collapsed ? (
<span title={`v${version}`}>v{version.split('.').slice(0, 2).join('.')}</span>
) : (
<span>v{version}</span>
)}
</div>
)}
{/* Logout */}
<button <button
onClick={logout} onClick={logout}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm text-gray-600 hover:bg-gray-100 transition-colors" 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"
> >
<LogOut className="w-4 h-4" /> <LogOut className="w-4 h-4" />
{!collapsed && <span>退</span>} {!collapsed && <span>退</span>}
+43
View File
@@ -0,0 +1,43 @@
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
type Theme = 'light' | 'dark'
interface ThemeContextType {
theme: Theme
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextType>({ theme: 'light', toggleTheme: () => {} })
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
if (typeof window === 'undefined') return 'light'
const stored = localStorage.getItem('clicd_theme') as Theme | null
if (stored === 'dark' || stored === 'light') return stored
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
})
useEffect(() => {
const root = document.documentElement
if (theme === 'dark') {
root.classList.add('dark')
} else {
root.classList.remove('dark')
}
localStorage.setItem('clicd_theme', theme)
}, [theme])
const toggleTheme = useCallback(() => {
setTheme(prev => (prev === 'dark' ? 'light' : 'dark'))
}, [])
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
return useContext(ThemeContext)
}
+131 -3
View File
@@ -14,19 +14,147 @@ body {
color: #000000; color: #000000;
} }
/* Scrollbar */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 6px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: #f1f1f1; background: #f1f1f1;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #888; background: #888;
border-radius: 3px; border-radius: 3px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: #555; background: #555;
} }
/* ============ DARK MODE OVERRIDES ============ */
.dark ::-webkit-scrollbar-track {
background: #1f2937;
}
.dark ::-webkit-scrollbar-thumb {
background: #4b5563;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: #6b7280;
}
.dark body {
background-color: #030712;
color: #f9fafb;
}
/* Background overrides */
.dark .bg-white { background-color: #111827 !important; }
.dark .bg-gray-50 { background-color: #030712 !important; }
.dark .bg-gray-100 { background-color: #1f2937 !important; }
.dark .bg-gray-200 { background-color: #374151 !important; }
/* Text overrides */
.dark .text-black { color: #f9fafb !important; }
.dark .text-gray-950 { color: #f9fafb !important; }
.dark .text-gray-900 { color: #f3f4f6 !important; }
.dark .text-gray-800 { color: #e5e7eb !important; }
.dark .text-gray-700 { color: #d1d5db !important; }
.dark .text-gray-600 { color: #9ca3af !important; }
.dark .text-gray-500 { color: #9ca3af !important; }
.dark .text-gray-400 { color: #6b7280 !important; }
/* Border overrides */
.dark .border-gray-100 { border-color: #1f2937 !important; }
.dark .border-gray-200 { border-color: #374151 !important; }
.dark .border-gray-300 { border-color: #4b5563 !important; }
/* Divider overrides */
.dark .divide-gray-50 > :not([hidden]) ~ :not([hidden]) { border-color: #1f2937 !important; }
.dark .divide-gray-100 > :not([hidden]) ~ :not([hidden]) { border-color: #1f2937 !important; }
/* Hover background overrides */
.dark .hover\:bg-gray-50:hover { background-color: #1f2937 !important; }
.dark .hover\:bg-gray-100:hover { background-color: #1f2937 !important; }
.dark .hover\:bg-gray-200:hover { background-color: #374151 !important; }
/* Hover text overrides */
.dark .hover\:text-black:hover { color: #f9fafb !important; }
.dark .hover\:text-gray-900:hover { color: #f3f4f6 !important; }
/* Shadow */
.dark .shadow-sm { box-shadow: 0 1px 2px 0 rgba(0,0,0,0.3) !important; }
.dark .shadow-md { box-shadow: 0 4px 6px -1px rgba(0,0,0,0.4) !important; }
/* bg-black buttons in dark mode -> light */
.dark .bg-black { background-color: #f9fafb !important; }
.dark .bg-black + span,
.dark button.bg-black { color: #111827 !important; }
.dark button.bg-black span { color: #111827 !important; }
/* Fix for CTA buttons (bg-black text-white) */
.dark button.bg-black,
.dark a.bg-black {
background-color: #f9fafb !important;
color: #111827 !important;
}
/* Fix nested text-white inside bg-black in dark mode */
.dark .bg-black .text-white,
.dark .bg-black.text-white {
color: #111827 !important;
}
/* Invert sidebar active state */
.dark button.bg-black.text-white,
.dark button.bg-black > span {
color: #111827 !important;
}
.dark button.bg-black svg {
color: #111827 !important;
}
/* Hover: bg-gray-800 in dark mode */
.dark .hover\:bg-gray-800:hover { background-color: #e5e7eb !important; color: #111827 !important; }
/* Status badge backgrounds */
.dark .bg-green-50 { background-color: #064e3b !important; }
.dark .bg-red-50 { background-color: #450a0a !important; }
.dark .bg-amber-50 { background-color: #451a03 !important; }
.dark .bg-emerald-50 { background-color: #064e3b !important; }
.dark .bg-amber-100 { background-color: #78350f !important; }
/* Status badge text */
.dark .text-green-700 { color: #6ee7b7 !important; }
.dark .text-red-600 { color: #fca5a5 !important; }
.dark .text-red-700 { color: #fca5a5 !important; }
.dark .text-amber-600 { color: #fcd34d !important; }
.dark .text-amber-700 { color: #fcd34d !important; }
.dark .text-emerald-700 { color: #6ee7b7 !important; }
/* Focus ring */
.dark .focus\:ring-black:focus { --tw-ring-color: #f9fafb !important; }
.dark .focus\:border-black:focus { border-color: #f9fafb !important; }
/* Accent */
.dark .accent-black { accent-color: #f9fafb !important; }
/* Spinner */
.dark .border-black { border-color: #f9fafb !important; }
.dark .border-b-black { border-bottom-color: #f9fafb !important; }
.dark .border-t-black { border-top-color: #f9fafb !important; }
.dark .animate-spin.rounded-full { border-color: #f9fafb !important; border-bottom-color: transparent !important; }
/* Placeholder */
.dark .placeholder-gray-400::placeholder { color: #6b7280 !important; }
/* Success/Error text standalone */
.dark .text-green-600 { color: #6ee7b7 !important; }
/* Modal backdrop */
.dark .bg-black\/50 { background-color: rgba(0,0,0,0.7) !important; }
/* Toggle / switch */
.dark .bg-gray-300 { background-color: #4b5563 !important; }
.dark .peer-checked\:bg-black:checked ~ * { background-color: #f9fafb !important; }
.dark .peer-checked\:bg-black:checked + *,
.dark input.peer:checked + .peer-checked\:bg-black { background-color: #f9fafb !important; }
+8 -5
View File
@@ -3,17 +3,20 @@ import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import App from './App' import App from './App'
import { AuthProvider } from './contexts/AuthContext' import { AuthProvider } from './contexts/AuthContext'
import { ThemeProvider } from './contexts/ThemeContext'
import { DialogProvider } from './components/Dialog' import { DialogProvider } from './components/Dialog'
import './index.css' import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter> <BrowserRouter>
<AuthProvider> <ThemeProvider>
<DialogProvider> <AuthProvider>
<App /> <DialogProvider>
</DialogProvider> <App />
</AuthProvider> </DialogProvider>
</AuthProvider>
</ThemeProvider>
</BrowserRouter> </BrowserRouter>
</React.StrictMode>, </React.StrictMode>,
) )
+6 -14
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { Key, Plus, Trash2, Copy, RefreshCw, Code, X } from 'lucide-react' import { Key, Plus, Trash2, Copy, RefreshCw, Code, X } from 'lucide-react'
import api, { APIResponse } from '../services/api' import api, { APIResponse } from '../services/api'
import { copyToClipboard } from '../utils/clipboard'
interface ApiKeyItem { interface ApiKeyItem {
id: string id: string
@@ -62,21 +63,12 @@ export default function ApiIntegration() {
} catch { /* ignore */ } } catch { /* ignore */ }
} }
const copyKey = () => { const copyKey = async () => {
try { const copied = await copyToClipboard(newKey)
navigator.clipboard.writeText(newKey) if (copied) {
} catch { setCopiedKey(true)
const ta = document.createElement('textarea') setTimeout(() => setCopiedKey(false), 2000)
ta.value = newKey
ta.style.position = 'fixed'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
} }
setCopiedKey(true)
setTimeout(() => setCopiedKey(false), 2000)
} }
return ( return (
+9 -55
View File
@@ -66,6 +66,7 @@ import { useDialog } from '../components/Dialog'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import WebSSHViewer from '../components/WebSSHViewer' import WebSSHViewer from '../components/WebSSHViewer'
import { RingStat } from '../components/RingStats' import { RingStat } from '../components/RingStats'
import { copyToClipboard } from '../utils/clipboard'
import ResourceStatsPanel, { import ResourceStatsPanel, {
ChartPoint, ChartPoint,
ResourceChartConfig, ResourceChartConfig,
@@ -619,19 +620,7 @@ export default function ContainerDetail() {
} }
const copyText = async (text: string) => { const copyText = async (text: string) => {
try { await copyToClipboard(text)
await copyText(text)
} catch {
// Fallback for HTTP (non-secure context)
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
} }
if (loading) { if (loading) {
@@ -676,7 +665,6 @@ export default function ContainerDetail() {
const managementUrl = subUser?.access_code const managementUrl = subUser?.access_code
? `${window.location.origin}/login?code=${encodeURIComponent(subUser.access_code)}` ? `${window.location.origin}/login?code=${encodeURIComponent(subUser.access_code)}`
: '' : ''
const managementPassword = subUser?.password || ''
const charts: ResourceChartConfig[] = [ const charts: ResourceChartConfig[] = [
{ {
title: 'CPU 使用率', title: 'CPU 使用率',
@@ -1263,55 +1251,21 @@ export default function ContainerDetail() {
</Modal> </Modal>
)} )}
{showSubUser && subUser && false && (
<Modal title="管理链接" onClose={() => setShowSubUser(false)}>
<div className="space-y-4">
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-xs text-amber-800">
</div>
<div className="bg-gray-50 rounded-lg p-4 text-sm space-y-3">
<div className="flex items-start justify-between gap-3">
<span className="shrink-0 text-gray-500"></span>
<div className="flex min-w-0 items-center gap-1">
<span className="font-mono text-xs text-black break-all">{managementUrl}</span>
<button onClick={() => copyText(managementUrl)} className="shrink-0 p-0.5 text-gray-400 hover:text-black rounded"><Copy className="w-3 h-3" /></button>
</div>
</div>
<div className="flex items-start justify-between gap-3">
<span className="shrink-0 text-gray-500"></span>
<div className="flex min-w-0 items-center gap-1">
<span className="font-mono text-xs text-black">{subUser?.username}</span>
<button onClick={() => copyText(subUser?.username || '')} className="shrink-0 p-0.5 text-gray-400 hover:text-black rounded"><Copy className="w-3 h-3" /></button>
</div>
</div>
<div className="flex items-start justify-between gap-3">
<span className="shrink-0 text-gray-500"></span>
<div className="flex min-w-0 items-center gap-1">
<span className="font-mono text-xs text-black">{managementPassword}</span>
<button onClick={() => copyText(managementPassword)} className="shrink-0 p-0.5 text-gray-400 hover:text-black rounded"><Copy className="w-3 h-3" /></button>
</div>
</div>
</div>
<p className="text-xs text-gray-400"> token使</p>
</div>
</Modal>
)}
{showSubUser && subUser && ( {showSubUser && subUser && (
<Modal title="管理链接" onClose={() => setShowSubUser(false)}> <Modal title="管理链接" onClose={() => setShowSubUser(false)}>
<div className="bg-gray-50 rounded-lg p-4 text-sm space-y-3"> <div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 text-sm space-y-3">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<span className="shrink-0 text-gray-500"></span> <span className="shrink-0 text-gray-500 dark:text-gray-400"></span>
<div className="flex min-w-0 items-center gap-1"> <div className="flex min-w-0 items-center gap-1">
<span className="font-mono text-xs text-black break-all">{managementUrl}</span> <span className="font-mono text-xs text-black dark:text-white break-all">{managementUrl}</span>
<button onClick={() => copyText(managementUrl)} className="shrink-0 p-0.5 text-gray-400 hover:text-black rounded"><Copy className="w-3 h-3" /></button> <button onClick={() => copyText(managementUrl)} className="shrink-0 p-0.5 text-gray-400 hover:text-black dark:hover:text-white rounded"><Copy className="w-3 h-3" /></button>
</div> </div>
</div> </div>
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<span className="shrink-0 text-gray-500"></span> <span className="shrink-0 text-gray-500 dark:text-gray-400"></span>
<div className="flex min-w-0 items-center gap-1"> <div className="flex min-w-0 items-center gap-1">
<span className="font-mono text-xs text-black">{managementPassword}</span> <span className="font-mono text-xs text-black dark:text-white">{subUser.password || ''}</span>
<button onClick={() => copyText(managementPassword)} className="shrink-0 p-0.5 text-gray-400 hover:text-black rounded"><Copy className="w-3 h-3" /></button> <button onClick={() => copyText(subUser.password || '')} className="shrink-0 p-0.5 text-gray-400 hover:text-black dark:hover:text-white rounded"><Copy className="w-3 h-3" /></button>
</div> </div>
</div> </div>
</div> </div>
+226 -20
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState, type ReactNode } from 'react' import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { import {
ArrowDown, ArrowDown,
@@ -12,6 +12,7 @@ import {
Plus, Plus,
RefreshCw, RefreshCw,
RotateCcw, RotateCcw,
Search,
Server, Server,
Square, Square,
Trash2, Trash2,
@@ -46,6 +47,11 @@ export default function Containers() {
const [showTasks, setShowTasks] = useState(false) const [showTasks, setShowTasks] = useState(false)
const [tasks, setTasks] = useState<Task[]>([]) const [tasks, setTasks] = useState<Task[]>([])
const [queuedCreates, setQueuedCreates] = useState<Record<string, CreateContainerRequest>>({}) const [queuedCreates, setQueuedCreates] = useState<Record<string, CreateContainerRequest>>({})
const [searchText, setSearchText] = useState('')
const [systemFilter, setSystemFilter] = useState('all')
const [statusFilter, setStatusFilter] = useState('all')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const refreshUsage = useCallback(async (items: Container[]) => { const refreshUsage = useCallback(async (items: Container[]) => {
const targets = items.filter((container) => container.status === 'running') const targets = items.filter((container) => container.status === 'running')
@@ -102,18 +108,6 @@ export default function Containers() {
}) })
} }
const toggleAll = () => {
const selectableIDs = displayContainers
.filter((container) => !container.isPlaceholder && !taskStatusMap[container.id] && !taskNameMap[container.name])
.map((container) => container.id)
if (selected.size === selectableIDs.length) {
setSelected(new Set())
} else {
setSelected(new Set(selectableIDs))
}
}
// Map of container_id -> current task status. // Map of container_id -> current task status.
// For create tasks, container_id may be 0 initially but gets set after creation, // For create tasks, container_id may be 0 initially but gets set after creation,
// so we also index by container_name as fallback for placeholder items. // so we also index by container_name as fallback for placeholder items.
@@ -170,6 +164,44 @@ export default function Containers() {
const displayContainers = buildDisplayContainers(containers, queuedCreates, tasks) const displayContainers = buildDisplayContainers(containers, queuedCreates, tasks)
const activeTaskCount = tasks.filter((task) => task.status === 'pending' || task.status === 'running').length const activeTaskCount = tasks.filter((task) => task.status === 'pending' || task.status === 'running').length
const systemOptions = useMemo(() => buildSystemOptions(displayContainers), [displayContainers])
const filteredContainers = useMemo(() => {
return filterContainers(displayContainers, {
search: searchText,
system: systemFilter,
status: statusFilter,
taskStatusMap,
taskNameMap,
})
}, [displayContainers, searchText, systemFilter, statusFilter, tasks])
const totalPages = Math.max(1, Math.ceil(filteredContainers.length / pageSize))
const currentPage = Math.min(page, totalPages)
const pageStart = (currentPage - 1) * pageSize
const pageContainers = filteredContainers.slice(pageStart, pageStart + pageSize)
const selectableIDs = filteredContainers
.filter((container) => !container.isPlaceholder && !taskStatusMap[container.id] && !taskNameMap[container.name])
.map((container) => container.id)
const allFilteredSelected = selectableIDs.length > 0 && selectableIDs.every((id) => selected.has(id))
useEffect(() => {
setPage(1)
}, [searchText, systemFilter, statusFilter, pageSize])
const toggleAll = () => {
if (allFilteredSelected) {
setSelected((prev) => {
const next = new Set(prev)
selectableIDs.forEach((id) => next.delete(id))
return next
})
} else {
setSelected((prev) => {
const next = new Set(prev)
selectableIDs.forEach((id) => next.add(id))
return next
})
}
}
const handleCreateQueued = async (items: CreateContainerRequest[]) => { const handleCreateQueued = async (items: CreateContainerRequest[]) => {
setQueuedCreates((current) => { setQueuedCreates((current) => {
@@ -193,12 +225,16 @@ export default function Containers() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div className="min-w-[180px]">
<h1 className="text-2xl font-bold text-black"></h1> <h1 className="text-2xl font-bold text-black"></h1>
<p className="text-sm text-gray-500 mt-1"> {displayContainers.length} {selected.size > 0 && `,已选 ${selected.size}`}</p> <p className="text-sm text-gray-500 mt-1">
{displayContainers.length}
{filteredContainers.length !== displayContainers.length && `,筛选后 ${filteredContainers.length}`}
{selected.size > 0 && `,已选 ${selected.size}`}
</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center justify-end gap-2">
{selected.size > 0 && ( {selected.size > 0 && (
<div className="flex items-center gap-1.5 bg-gray-50 border border-gray-200 rounded-md px-3 py-1.5"> <div className="flex items-center gap-1.5 bg-gray-50 border border-gray-200 rounded-md px-3 py-1.5">
<span className="text-xs text-gray-500 mr-1">{selected.size} </span> <span className="text-xs text-gray-500 mr-1">{selected.size} </span>
@@ -216,6 +252,53 @@ export default function Containers() {
</button> </button>
</div> </div>
)} )}
{displayContainers.length > 0 && (
<>
<div className="relative w-[260px]">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-400" />
<input
value={searchText}
onChange={(event) => setSearchText(event.target.value)}
className="h-8 w-full rounded-md border border-gray-300 bg-white pl-8 pr-2 text-xs text-black outline-none focus:border-black focus:ring-2 focus:ring-black"
placeholder="搜索名称、ID、UUID、IP"
/>
</div>
<select
value={systemFilter}
onChange={(event) => setSystemFilter(event.target.value)}
className="h-8 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 outline-none focus:border-black focus:ring-2 focus:ring-black"
title="系统筛选"
>
<option value="all"></option>
{systemOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
<select
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value)}
className="h-8 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 outline-none focus:border-black focus:ring-2 focus:ring-black"
title="状态筛选"
>
<option value="all"></option>
<option value="running">线</option>
<option value="stopped">线</option>
<option value="task"></option>
<option value="creating"></option>
<option value="failed"></option>
</select>
<select
value={pageSize}
onChange={(event) => setPageSize(Number(event.target.value))}
className="h-8 rounded-md border border-gray-300 bg-white px-2 text-xs text-gray-700 outline-none focus:border-black focus:ring-2 focus:ring-black"
title="每页数量"
>
<option value={10}>10 / </option>
<option value={20}>20 / </option>
<option value={50}>50 / </option>
</select>
</>
)}
<button <button
onClick={handleRefreshList} onClick={handleRefreshList}
disabled={refreshing} disabled={refreshing}
@@ -267,7 +350,8 @@ export default function Containers() {
{!isSubUser && ( {!isSubUser && (
<input <input
type="checkbox" type="checkbox"
checked={displayContainers.length > 0 && selected.size === displayContainers.filter((container) => !container.isPlaceholder && !taskStatusMap[container.id] && !taskNameMap[container.name]).length} checked={allFilteredSelected}
disabled={selectableIDs.length === 0}
onChange={toggleAll} onChange={toggleAll}
className="w-4 h-4 rounded border-gray-300 text-black focus:ring-black accent-black" className="w-4 h-4 rounded border-gray-300 text-black focus:ring-black accent-black"
/> />
@@ -287,7 +371,7 @@ export default function Containers() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-100"> <tbody className="divide-y divide-gray-100">
{displayContainers.map((container) => { {pageContainers.map((container) => {
const isRunning = container.status === 'running' const isRunning = container.status === 'running'
const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask const task = (container.id > 0 ? taskStatusMap[container.id] : taskNameMap[container.name]) || container.createTask
const isPlaceholder = !!container.isPlaceholder const isPlaceholder = !!container.isPlaceholder
@@ -398,10 +482,54 @@ export default function Containers() {
</tbody> </tbody>
</table> </table>
</div> </div>
{filteredContainers.length === 0 ? (
<div className="border-t border-gray-100 px-4 py-10 text-center text-sm text-gray-500">
</div>
) : (
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-gray-100 px-4 py-3">
<div className="text-xs text-gray-500">
{pageStart + 1}-{Math.min(pageStart + pageSize, filteredContainers.length)} / {filteredContainers.length}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setPage(1)}
disabled={currentPage === 1}
className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40"
>
</button>
<button
onClick={() => setPage((value) => Math.max(1, value - 1))}
disabled={currentPage === 1}
className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40"
>
</button>
<span className="px-2 text-xs text-gray-500">
{currentPage} / {totalPages}
</span>
<button
onClick={() => setPage((value) => Math.min(totalPages, value + 1))}
disabled={currentPage === totalPages}
className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40"
>
</button>
<button
onClick={() => setPage(totalPages)}
disabled={currentPage === totalPages}
className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40"
>
</button>
</div>
</div>
)}
</div> </div>
)} )}
<CreateContainerModal isOpen={showCreate} onClose={() => setShowCreate(false)} onSuccess={handleCreateQueued} /> <CreateContainerModal isOpen={showCreate} onClose={() => setShowCreate(false)} onSuccess={handleCreateQueued} existingNames={containers.map(c => c.name)} />
{showTasks && ( {showTasks && (
<TaskQueueModal <TaskQueueModal
tasks={tasks} tasks={tasks}
@@ -585,6 +713,84 @@ function hasActiveTasks(tasks: Task[]) {
return tasks.some((task) => task.status === 'pending' || task.status === 'running') return tasks.some((task) => task.status === 'pending' || task.status === 'running')
} }
type ContainerFilters = {
search: string
system: string
status: string
taskStatusMap: Record<number, Task>
taskNameMap: Record<string, Task>
}
function filterContainers(containers: DisplayContainer[], filters: ContainerFilters): DisplayContainer[] {
const keyword = filters.search.trim().toLowerCase()
return containers.filter((container) => {
const task = (container.id > 0 ? filters.taskStatusMap[container.id] : filters.taskNameMap[container.name]) || container.createTask
if (filters.system !== 'all' && getSystemFilterValue(container.template) !== filters.system) {
return false
}
if (filters.status !== 'all' && getContainerStatusFilterValue(container, task) !== filters.status) {
return false
}
if (!keyword) return true
const fields = [
String(container.id),
container.name,
container.uuid,
container.ip,
container.ipv6,
container.template,
getTemplateName(container.template),
getSystemFilterLabel(getSystemFilterValue(container.template)),
String(container.ssh_port || ''),
]
return fields.some((field) => field.toLowerCase().includes(keyword))
})
}
function buildSystemOptions(containers: DisplayContainer[]) {
const systems = new Map<string, string>()
for (const container of containers) {
const value = getSystemFilterValue(container.template)
systems.set(value, getSystemFilterLabel(value))
}
return Array.from(systems.entries())
.map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label))
}
function getSystemFilterValue(template: string) {
if (template.startsWith('ubuntu')) return 'ubuntu'
if (template.startsWith('debian')) return 'debian'
if (template.startsWith('alpine')) return 'alpine'
if (template.startsWith('centos')) return 'centos'
if (template.startsWith('archlinux')) return 'archlinux'
if (template.startsWith('fedora')) return 'fedora'
if (template.startsWith('rockylinux')) return 'rockylinux'
return template || 'unknown'
}
function getSystemFilterLabel(system: string) {
const labels: Record<string, string> = {
ubuntu: 'Ubuntu',
debian: 'Debian',
alpine: 'Alpine',
centos: 'CentOS',
archlinux: 'Arch Linux',
fedora: 'Fedora',
rockylinux: 'Rocky Linux',
unknown: '未知系统',
}
return labels[system] || system
}
function getContainerStatusFilterValue(container: DisplayContainer, task?: Task) {
if (task?.status === 'failed') return 'failed'
if (container.isPlaceholder || task?.type === 'create') return 'creating'
if (task && task.status !== 'done' && task.status !== 'failed') return 'task'
return container.status === 'running' ? 'running' : 'stopped'
}
function taskLineLabel(task: Task, actionLabels: Record<string, string>) { function taskLineLabel(task: Task, actionLabels: Record<string, string>) {
if (task.status === 'failed') return task.type === 'create' ? '初始化失败' : '处理失败' if (task.status === 'failed') return task.type === 'create' ? '初始化失败' : '处理失败'
if (task.type === 'create' && task.status === 'done') return '初始化完成' if (task.type === 'create' && task.status === 'done') return '初始化完成'
+90 -20
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { Network, RefreshCw, Route, Server } from 'lucide-react' import { Network, RefreshCw, Route, Search, Server, X } from 'lucide-react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { getRoutingInfo, RoutingInfo } from '../services/api' import { getRoutingInfo, RoutingInfo, NAT4Route, IPv6Route } from '../services/api'
export default function Routing() { export default function Routing() {
const navigate = useNavigate() const navigate = useNavigate()
@@ -10,6 +10,8 @@ export default function Routing() {
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
const [nat4Page, setNat4Page] = useState(1) const [nat4Page, setNat4Page] = useState(1)
const [ipv6Page, setIPv6Page] = useState(1) const [ipv6Page, setIPv6Page] = useState(1)
const [nat4Search, setNat4Search] = useState('')
const [ipv6Search, setIPv6Search] = useState('')
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
try { try {
@@ -25,6 +27,39 @@ export default function Routing() {
useEffect(() => { fetchData() }, [fetchData]) useEffect(() => { fetchData() }, [fetchData])
const nat4Mappings = routing?.nat4_mappings || []
const ipv6Assignments = routing?.ipv6_assignments || []
const ipv6Prefix = routing?.ipv6_prefixes?.[0]?.prefix || '-'
// Filter helpers
const matchesNat4Search = (m: NAT4Route, query: string) => {
if (!query) return true
const q = query.toLowerCase()
return (
String(m.host_port).includes(q) ||
String(m.container_port).includes(q) ||
m.container_name.toLowerCase().includes(q) ||
m.lxc_name.toLowerCase().includes(q) ||
(m.ip || '').toLowerCase().includes(q)
)
}
const matchesIPv6Search = (item: IPv6Route, query: string) => {
if (!query) return true
const q = query.toLowerCase()
return (
(item.address || '').toLowerCase().includes(q) ||
item.container_name.toLowerCase().includes(q) ||
item.lxc_name.toLowerCase().includes(q)
)
}
const filteredNat4 = useMemo(() => nat4Mappings.filter(m => matchesNat4Search(m, nat4Search)), [nat4Mappings, nat4Search])
const filteredIPv6 = useMemo(() => ipv6Assignments.filter(m => matchesIPv6Search(m, ipv6Search)), [ipv6Assignments, ipv6Search])
// Reset page on search change
useEffect(() => { setNat4Page(1) }, [nat4Search])
useEffect(() => { setIPv6Page(1) }, [ipv6Search])
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
@@ -33,16 +68,13 @@ export default function Routing() {
) )
} }
const nat4Mappings = routing?.nat4_mappings || []
const ipv6Assignments = routing?.ipv6_assignments || []
const ipv6Prefix = routing?.ipv6_prefixes?.[0]?.prefix || '-'
const pageSize = 10 const pageSize = 10
const nat4TotalPages = Math.max(1, Math.ceil(nat4Mappings.length / pageSize)) const nat4TotalPages = Math.max(1, Math.ceil(filteredNat4.length / pageSize))
const ipv6TotalPages = Math.max(1, Math.ceil(ipv6Assignments.length / pageSize)) const ipv6TotalPages = Math.max(1, Math.ceil(filteredIPv6.length / pageSize))
const currentNat4Page = Math.min(nat4Page, nat4TotalPages) const currentNat4Page = Math.min(nat4Page, nat4TotalPages)
const currentIPv6Page = Math.min(ipv6Page, ipv6TotalPages) const currentIPv6Page = Math.min(ipv6Page, ipv6TotalPages)
const pagedNat4Mappings = nat4Mappings.slice((currentNat4Page - 1) * pageSize, currentNat4Page * pageSize) const pagedNat4Mappings = filteredNat4.slice((currentNat4Page - 1) * pageSize, currentNat4Page * pageSize)
const pagedIPv6Assignments = ipv6Assignments.slice((currentIPv6Page - 1) * pageSize, currentIPv6Page * pageSize) const pagedIPv6Assignments = filteredIPv6.slice((currentIPv6Page - 1) * pageSize, currentIPv6Page * pageSize)
return ( return (
<div className="space-y-5"> <div className="space-y-5">
@@ -80,10 +112,29 @@ export default function Routing() {
/> />
</div> </div>
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white"> <div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
<div className="border-b border-gray-200 px-4 py-3"> <div className="border-b border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between gap-3">
<div className="text-sm font-medium text-black">NAT4 </div> <div>
<div className="mt-1 text-xs text-gray-500"> {nat4Mappings.length} </div> <div className="text-sm font-medium text-black dark:text-white">NAT4 </div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{nat4Search ? `搜索 "${nat4Search}" 结果 ${filteredNat4.length} 条,` : ''} {nat4Mappings.length}
</div>
</div>
<div className="relative w-48">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
<input
type="text"
value={nat4Search}
onChange={e => setNat4Search(e.target.value)}
placeholder="搜索端口/容器..."
className="w-full pl-8 pr-7 py-1.5 text-xs border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-black dark:text-white focus:outline-none focus:ring-1 focus:ring-black dark:focus:ring-white"
/>
{nat4Search && (
<button onClick={() => setNat4Search('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<X className="w-3 h-3" />
</button>
)}
</div>
</div> </div>
{nat4Mappings.length === 0 ? ( {nat4Mappings.length === 0 ? (
<EmptyState icon={<Route className="h-7 w-7 text-gray-400" />} text="暂无 NAT4 端口映射" /> <EmptyState icon={<Route className="h-7 w-7 text-gray-400" />} text="暂无 NAT4 端口映射" />
@@ -130,7 +181,7 @@ export default function Routing() {
<Pagination <Pagination
page={currentNat4Page} page={currentNat4Page}
totalPages={nat4TotalPages} totalPages={nat4TotalPages}
totalItems={nat4Mappings.length} totalItems={filteredNat4.length}
pageSize={pageSize} pageSize={pageSize}
onPageChange={setNat4Page} onPageChange={setNat4Page}
/> />
@@ -138,10 +189,29 @@ export default function Routing() {
)} )}
</div> </div>
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white"> <div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
<div className="border-b border-gray-200 px-4 py-3"> <div className="border-b border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between gap-3">
<div className="text-sm font-medium text-black">IPv6 </div> <div>
<div className="mt-1 text-xs text-gray-500"> {ipv6Assignments.length} </div> <div className="text-sm font-medium text-black dark:text-white">IPv6 </div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{ipv6Search ? `搜索 "${ipv6Search}" 结果 ${filteredIPv6.length} 条,` : ''} {ipv6Assignments.length}
</div>
</div>
<div className="relative w-48">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
<input
type="text"
value={ipv6Search}
onChange={e => setIPv6Search(e.target.value)}
placeholder="搜索地址/容器..."
className="w-full pl-8 pr-7 py-1.5 text-xs border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-black dark:text-white focus:outline-none focus:ring-1 focus:ring-black dark:focus:ring-white"
/>
{ipv6Search && (
<button onClick={() => setIPv6Search('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<X className="w-3 h-3" />
</button>
)}
</div>
</div> </div>
{ipv6Assignments.length === 0 ? ( {ipv6Assignments.length === 0 ? (
<EmptyState icon={<Network className="h-7 w-7 text-gray-400" />} text="暂无 IPv6 地址分配" /> <EmptyState icon={<Network className="h-7 w-7 text-gray-400" />} text="暂无 IPv6 地址分配" />
@@ -184,7 +254,7 @@ export default function Routing() {
<Pagination <Pagination
page={currentIPv6Page} page={currentIPv6Page}
totalPages={ipv6TotalPages} totalPages={ipv6TotalPages}
totalItems={ipv6Assignments.length} totalItems={filteredIPv6.length}
pageSize={pageSize} pageSize={pageSize}
onPageChange={setIPv6Page} onPageChange={setIPv6Page}
/> />
+35 -2
View File
@@ -1,13 +1,16 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Camera, RefreshCw, Server } from 'lucide-react' import { Camera, RefreshCw, Server, Trash2 } from 'lucide-react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { getSnapshots, Snapshot } from '../services/api' import { deleteContainerSnapshot, getSnapshots, Snapshot } from '../services/api'
import { useDialog } from '../components/Dialog'
export default function Snapshots() { export default function Snapshots() {
const navigate = useNavigate() const navigate = useNavigate()
const dialog = useDialog()
const [snapshots, setSnapshots] = useState<Snapshot[]>([]) const [snapshots, setSnapshots] = useState<Snapshot[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
const [deleting, setDeleting] = useState<string | null>(null)
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
try { try {
@@ -23,6 +26,25 @@ export default function Snapshots() {
useEffect(() => { fetchData() }, [fetchData]) useEffect(() => { fetchData() }, [fetchData])
const handleDelete = async (snapshot: Snapshot) => {
const confirmed = await dialog.confirm(
'删除快照',
`确认删除容器 ${snapshot.container_name} 的快照吗?此操作不可恢复。`
)
if (!confirmed) return
setDeleting(snapshot.id)
try {
await deleteContainerSnapshot(snapshot.container_id, snapshot.id)
setSnapshots(prev => prev.filter(s => s.id !== snapshot.id))
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
dialog.alert('删除失败', error.response?.data?.message || '请稍后重试')
} finally {
setDeleting(null)
}
}
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
@@ -66,6 +88,7 @@ export default function Snapshots() {
<th className="px-4 py-3 text-left font-medium"></th> <th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium"></th> <th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th> <th className="px-4 py-3 text-right font-medium"></th>
<th className="px-4 py-3 text-center font-medium"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-100"> <tbody className="divide-y divide-gray-100">
@@ -89,6 +112,16 @@ export default function Snapshots() {
</td> </td>
<td className="px-4 py-3 text-gray-600">{snapshot.created_by || '-'}</td> <td className="px-4 py-3 text-gray-600">{snapshot.created_by || '-'}</td>
<td className="px-4 py-3 text-right font-mono text-xs text-gray-600">{formatBytes(snapshot.size_bytes || 0)}</td> <td className="px-4 py-3 text-right font-mono text-xs text-gray-600">{formatBytes(snapshot.size_bytes || 0)}</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => handleDelete(snapshot)}
disabled={deleting === snapshot.id}
className="inline-flex items-center justify-center p-1.5 rounded text-red-500 hover:bg-red-50 transition-colors disabled:opacity-50"
title="删除快照"
>
<Trash2 className="h-4 w-4" />
</button>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
+367
View File
@@ -0,0 +1,367 @@
import { useCallback, useEffect, useState } from 'react'
import { Copy, KeyRound, LogIn, RefreshCw, ScrollText, UserCog, X } from 'lucide-react'
import { useDialog } from '../components/Dialog'
import api, { AuditLog, LoginLog } from '../services/api'
import { copyToClipboard } from '../utils/clipboard'
interface SubUserItem {
id: string
username: string
container_names: string[]
container_uuids: string[]
container_name: string
container_uuid: string
access_code: string
password?: string
created_at: string
last_login: string
last_login_ip: string
last_login_ua: string
}
interface AuditLogExt extends AuditLog {
ip?: string
user_agent?: string
success?: boolean
error?: string
}
export default function SubUserManagement() {
const dialog = useDialog()
const [users, setUsers] = useState<SubUserItem[]>([])
const [loading, setLoading] = useState(true)
const [auditLogs, setAuditLogs] = useState<AuditLogExt[] | null>(null)
const [loginLogs, setLoginLogs] = useState<LoginLog[] | null>(null)
const [modalTitle, setModalTitle] = useState('')
const [passwordUser, setPasswordUser] = useState<SubUserItem | null>(null)
const [rotatingPassword, setRotatingPassword] = useState(false)
const [logPage, setLogPage] = useState(1)
const [logPageSize, setLogPageSize] = useState(10)
const fetchUsers = useCallback(async () => {
try {
const res = await api.get<{ success: boolean; data: SubUserItem[] }>('/sub-users')
setUsers(res.data.data || [])
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchUsers() }, [fetchUsers])
const managementUrl = (user: SubUserItem) => `${window.location.origin}/login?code=${user.access_code}`
const copyText = async (text: string) => {
await copyToClipboard(text)
}
const rotatePassword = async (user: SubUserItem) => {
setRotatingPassword(true)
try {
const res = await api.post(`/sub-users/${user.id}/rotate-password`)
const data = res.data.data
const updatedUser = {
...user,
username: data?.username || user.username,
access_code: data?.access_code || user.access_code,
password: data?.password || '',
}
setUsers((prev) => prev.map((item) => (item.id === user.id ? updatedUser : item)))
setPasswordUser(updatedUser)
} catch (err: unknown) {
const error = err as { response?: { data?: { message?: string } } }
dialog.alert('轮换失败', error.response?.data?.message || '请稍后重试')
} finally {
setRotatingPassword(false)
}
}
const showAuditLogs = async (user: SubUserItem) => {
try {
const res = await api.get(`/sub-users/${user.id}/audit-logs`)
setAuditLogs(res.data.data || [])
setLoginLogs(null)
setModalTitle(`${user.username} - 操作日志`)
setLogPage(1)
} catch {
dialog.alert('错误', '获取操作日志失败')
}
}
const showLoginLogs = async (user: SubUserItem) => {
try {
const res = await api.get(`/sub-users/${user.id}/login-logs`)
setLoginLogs(res.data.data || [])
setAuditLogs(null)
setModalTitle(`${user.username} - 登录日志`)
setLogPage(1)
} catch {
dialog.alert('错误', '获取登录日志失败')
}
}
const closeModal = () => {
setAuditLogs(null)
setLoginLogs(null)
}
const currentLogTotal = auditLogs?.length ?? loginLogs?.length ?? 0
const logTotalPages = Math.max(1, Math.ceil(currentLogTotal / logPageSize))
const currentLogPage = Math.min(logPage, logTotalPages)
const logStart = (currentLogPage - 1) * logPageSize
const currentAuditLogs = auditLogs?.slice(logStart, logStart + logPageSize)
const currentLoginLogs = loginLogs?.slice(logStart, logStart + logPageSize)
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-black" />
</div>
)
}
return (
<div className="space-y-5">
<div>
<h1 className="text-xl font-semibold text-black dark:text-white"></h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400"> {users.length} </p>
</div>
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
{users.length === 0 ? (
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-lg bg-gray-100 dark:bg-gray-800">
<UserCog className="h-7 w-7 text-gray-400" />
</div>
<div className="text-sm font-medium text-gray-700 dark:text-gray-300"></div>
</div>
) : (
<table className="w-full min-w-[820px] text-sm">
<thead className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-xs text-gray-500 dark:text-gray-400">
<tr>
<th className="px-4 py-3 text-left font-medium w-12">#</th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium">UUID</th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-center font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{users.map((user, index) => (
<tr key={user.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
<td className="px-4 py-3 text-gray-400 dark:text-gray-500">{index + 1}</td>
<td className="px-4 py-3 font-medium text-black dark:text-white">{user.container_name || '-'}</td>
<td className="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-400">{user.container_uuid || '-'}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
{user.last_login ? (
<div>
<div className="text-xs">{user.last_login}</div>
<div className="text-xs text-gray-400 dark:text-gray-500">{user.last_login_ip}</div>
</div>
) : (
<span className="text-gray-400"></span>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-center gap-1">
<button
onClick={() => setPasswordUser(user)}
className="inline-flex items-center gap-1 px-2 py-1.5 rounded text-xs text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/30 transition-colors"
title="查看密码"
>
<KeyRound className="w-3.5 h-3.5" />
</button>
<button
onClick={() => showAuditLogs(user)}
className="inline-flex items-center gap-1 px-2 py-1.5 rounded text-xs text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/30 transition-colors"
title="查看操作日志"
>
<ScrollText className="w-3.5 h-3.5" />
</button>
<button
onClick={() => showLoginLogs(user)}
className="inline-flex items-center gap-1 px-2 py-1.5 rounded text-xs text-green-600 hover:bg-green-50 dark:hover:bg-green-900/30 transition-colors"
title="查看登录日志"
>
<LogIn className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{passwordUser && (
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 shadow-xl w-full max-w-lg overflow-hidden">
<div className="flex items-center justify-between gap-3 px-5 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-semibold text-black dark:text-white"></h3>
<div className="flex items-center gap-2">
<button
onClick={() => rotatePassword(passwordUser)}
disabled={rotatingPassword}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded text-xs text-amber-700 bg-amber-50 hover:bg-amber-100 dark:text-amber-300 dark:bg-amber-900/30 dark:hover:bg-amber-900/50 disabled:opacity-50"
title="轮换密码"
>
<RefreshCw className={`w-3.5 h-3.5 ${rotatingPassword ? 'animate-spin' : ''}`} />
{rotatingPassword ? '轮换中...' : '轮换密码'}
</button>
<button onClick={() => setPasswordUser(null)} className="p-1 text-gray-400 hover:text-black dark:hover:text-white rounded">
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="p-5">
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 text-sm space-y-3">
<div className="flex items-start justify-between gap-3">
<span className="shrink-0 text-gray-500 dark:text-gray-400"></span>
<span className="min-w-0 text-right font-medium text-black dark:text-white break-all">{passwordUser.username}</span>
</div>
<div className="flex items-start justify-between gap-3">
<span className="shrink-0 text-gray-500 dark:text-gray-400"></span>
<div className="flex min-w-0 items-center gap-1">
<span className="font-mono text-xs text-black dark:text-white break-all">{managementUrl(passwordUser)}</span>
<button onClick={() => copyText(managementUrl(passwordUser))} className="shrink-0 p-0.5 text-gray-400 hover:text-black dark:hover:text-white rounded" title="复制">
<Copy className="w-3 h-3" />
</button>
</div>
</div>
<div className="flex items-start justify-between gap-3">
<span className="shrink-0 text-gray-500 dark:text-gray-400"></span>
<div className="flex min-w-0 items-center gap-1">
<span className="font-mono text-xs text-black dark:text-white break-all">
{passwordUser.password || '未保存,请轮换生成新密码'}
</span>
{passwordUser.password && (
<button onClick={() => copyText(passwordUser.password || '')} className="shrink-0 p-0.5 text-gray-400 hover:text-black dark:hover:text-white rounded" title="复制">
<Copy className="w-3 h-3" />
</button>
)}
</div>
</div>
</div>
</div>
</div>
</div>
)}
{/* Log Modal */}
{(auditLogs || loginLogs) && (
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 shadow-xl w-full max-w-3xl max-h-[85vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-semibold text-black dark:text-white">{modalTitle}</h3>
<button onClick={closeModal} className="p-1 text-gray-400 hover:text-black dark:hover:text-white rounded">
<X className="w-4 h-4" />
</button>
</div>
<div className="overflow-auto flex-1">
{auditLogs && (
<table className="w-full text-sm">
<thead className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-xs text-gray-500 dark:text-gray-400 sticky top-0">
<tr>
<th className="px-4 py-2 text-left"></th>
<th className="px-4 py-2 text-left"></th>
<th className="px-4 py-2 text-left">IP</th>
<th className="px-4 py-2 text-left">UA</th>
<th className="px-4 py-2 text-center"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{auditLogs.length === 0 ? (
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-400"></td></tr>
) : currentAuditLogs?.map((log, i) => (
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-gray-800">
<td className="px-4 py-2 text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">{log.time}</td>
<td className="px-4 py-2 text-xs text-gray-700 dark:text-gray-300">{log.action}</td>
<td className="px-4 py-2 text-xs font-mono text-gray-500 dark:text-gray-400">{log.ip || '-'}</td>
<td className="px-4 py-2 text-xs text-gray-500 dark:text-gray-400 max-w-[200px] truncate" title={log.user_agent}>{log.user_agent || '-'}</td>
<td className="px-4 py-2 text-center">
{log.success !== undefined ? (
log.success ? (
<span className="inline-flex px-2 py-0.5 rounded text-xs bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400"></span>
) : (
<span className="inline-flex px-2 py-0.5 rounded text-xs bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-400" title={log.error}>{log.error ? '失败' : '失败'}</span>
)
) : (
<span className="text-gray-400">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
{loginLogs && (
<table className="w-full text-sm">
<thead className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-xs text-gray-500 dark:text-gray-400 sticky top-0">
<tr>
<th className="px-4 py-2 text-left"></th>
<th className="px-4 py-2 text-left"> IP</th>
<th className="px-4 py-2 text-left">UA</th>
<th className="px-4 py-2 text-center"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{loginLogs.length === 0 ? (
<tr><td colSpan={4} className="px-4 py-8 text-center text-gray-400"></td></tr>
) : currentLoginLogs?.map((log, i) => (
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-gray-800">
<td className="px-4 py-2 text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">{log.time}</td>
<td className="px-4 py-2 text-xs font-mono text-gray-500 dark:text-gray-400">{log.ip}</td>
<td className="px-4 py-2 text-xs text-gray-500 dark:text-gray-400 max-w-[250px] truncate" title={log.user_agent}>{log.user_agent}</td>
<td className="px-4 py-2 text-center">
{log.success ? (
<span className="inline-flex px-2 py-0.5 rounded text-xs bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400"></span>
) : (
<span className="inline-flex px-2 py-0.5 rounded text-xs bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-400"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{currentLogTotal > 0 && (
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-gray-200 dark:border-gray-700 px-5 py-3">
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<span>
{logStart + 1}-{Math.min(logStart + logPageSize, currentLogTotal)} / {currentLogTotal}
</span>
<select
value={logPageSize}
onChange={(event) => {
setLogPageSize(Number(event.target.value))
setLogPage(1)
}}
className="h-7 rounded border border-gray-300 bg-white px-2 text-xs text-gray-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300"
>
<option value={10}>10 / </option>
<option value={20}>20 / </option>
<option value={50}>50 / </option>
</select>
</div>
<div className="flex items-center gap-1">
<button onClick={() => setLogPage(1)} disabled={currentLogPage === 1} className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"></button>
<button onClick={() => setLogPage((page) => Math.max(1, page - 1))} disabled={currentLogPage === 1} className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"></button>
<span className="px-2 text-xs text-gray-500 dark:text-gray-400">{currentLogPage} / {logTotalPages}</span>
<button onClick={() => setLogPage((page) => Math.min(logTotalPages, page + 1))} disabled={currentLogPage === logTotalPages} className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"></button>
<button onClick={() => setLogPage(logTotalPages)} disabled={currentLogPage === logTotalPages} className="rounded border border-gray-200 px-2.5 py-1.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"></button>
</div>
</div>
)}
</div>
</div>
)}
</div>
)
}
+12
View File
@@ -197,6 +197,14 @@ export interface LoginLog {
success: boolean success: boolean
} }
export interface AuditLog {
time: string
action: string
target: string
detail: string
user: string
}
export const getLoginLogs = () => export const getLoginLogs = () =>
api.get<APIResponse<LoginLog[]>>('/login-logs') api.get<APIResponse<LoginLog[]>>('/login-logs')
@@ -553,4 +561,8 @@ export const getSecuritySummary = () =>
export const createWebSSHTicket = (containerName: string) => export const createWebSSHTicket = (containerName: string) =>
api.post<APIResponse<{ ticket: string }>>('/ssh-ticket', { container_name: containerName }) api.post<APIResponse<{ ticket: string }>>('/ssh-ticket', { container_name: containerName })
// Version
export const getVersion = () =>
api.get<APIResponse<{ version: string }>>('/version')
export default api export default api
+44
View File
@@ -0,0 +1,44 @@
export async function copyToClipboard(text: string): Promise<boolean> {
if (!text) return false
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// Fall through for non-secure HTTP origins where Clipboard API is blocked.
}
}
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.top = '0'
textarea.style.left = '0'
textarea.style.width = '1px'
textarea.style.height = '1px'
textarea.style.opacity = '0'
textarea.style.pointerEvents = 'none'
const selection = document.getSelection()
const selectedRange = selection?.rangeCount ? selection.getRangeAt(0) : null
document.body.appendChild(textarea)
textarea.focus({ preventScroll: true })
textarea.select()
textarea.setSelectionRange(0, textarea.value.length)
let copied = false
try {
copied = document.execCommand('copy')
} finally {
document.body.removeChild(textarea)
if (selection && selectedRange) {
selection.removeAllRanges()
selection.addRange(selectedRange)
}
}
return copied
}
+1
View File
@@ -1,5 +1,6 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
darkMode: 'class',
content: [ content: [
"./index.html", "./index.html",
"./src/**/*.{js,ts,jsx,tsx}", "./src/**/*.{js,ts,jsx,tsx}",