mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-05 05:36:07 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7ba19fa34 | |||
| ffedf801e7 |
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,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")
|
||||||
|
|||||||
+192
-10
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1479,11 +1479,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,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)))
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package version
|
package version
|
||||||
|
|
||||||
var (
|
var (
|
||||||
Version = "1.0.8"
|
Version = "1.0.9"
|
||||||
Repo = "MengMengCode/CLICD"
|
Repo = "MengMengCode/CLICD"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,3 +14,4 @@ func Current() string {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -193,6 +193,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
{!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 ${
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -401,7 +401,7 @@ export default function Containers() {
|
|||||||
</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}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
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 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} - 操作日志`)
|
||||||
|
} 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} - 登录日志`)
|
||||||
|
} catch {
|
||||||
|
dialog.alert('错误', '获取登录日志失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setAuditLogs(null)
|
||||||
|
setLoginLogs(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
) : auditLogs.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>
|
||||||
|
) : loginLogs.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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user